From 8c77aff8e18b79326005ee60cbbeba8cacaace6a Mon Sep 17 00:00:00 2001 From: forhappy Date: Thu, 30 Jul 2026 07:48:49 -0700 Subject: [PATCH 1/4] docs: design framework activation evidence hardening --- ...30-framework-activation-evidence-design.md | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-framework-activation-evidence-design.md diff --git a/docs/superpowers/specs/2026-07-30-framework-activation-evidence-design.md b/docs/superpowers/specs/2026-07-30-framework-activation-evidence-design.md new file mode 100644 index 00000000..84f5f684 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-framework-activation-evidence-design.md @@ -0,0 +1,384 @@ +# Framework Activation Evidence Hardening Design + +**Date:** 2026-07-30 + +**Status:** Approved for implementation planning + +**Implementation root:** `/Users/haipingfu/graphify/compass` + +## Purpose + +Compass currently allows some framework detectors to activate from weak textual +or path evidence. The most concrete false positive is Laravel: a PHP file under +a directory named `routes` can cause `Route::get(...)` to be interpreted as a +Laravel route even when `Route` resolves to an unrelated class. + +This work strengthens framework extraction in two ordered deliveries: + +1. fix Laravel activation immediately; and +2. introduce one shared activation-evidence mechanism and migrate every + existing framework pack to it. + +The accuracy gain must not introduce repeated whole-file scans or change the +public framework-fact schema. + +## Goals + +- Laravel route facts are emitted only when the static-call receiver resolves + to `Illuminate\Support\Facades\Route`. +- Laravel aliases and fully qualified facade references remain supported. +- A directory named `routes` is never sufficient Laravel evidence. +- Dynamic or unresolved Laravel handlers do not produce facts that claim exact + route-to-handler resolution. +- Every framework detector passes through a shared activation decision before + emitting framework facts. +- Exact imports, receiver identities, decorators, attributes, macros, + manifests, and framework-owned configuration formats are distinguished from + weak naming conventions. +- Weak path and filename conventions may support activation but may not, by + themselves, activate code-driven framework facts. +- Near matches remain available to the generic language graph and do not + become framework facts. +- Evidence is collected at most once per relevant syntax tree or declarative + artifact and reused by the pack detector. +- Existing framework fact limits, provenance, route normalization, and public + serialization remain compatible. + +## Non-goals + +- Replace generic language extraction or symbol resolution. +- Add probabilistic confidence scoring to the public graph format. +- Infer dynamic PHP values, execute framework code, or load application + containers. +- Make package-manager network calls. +- Redesign route normalization, resource expansion, or handler identity. +- Add a public framework plugin API. +- Require all frameworks to use identical evidence. Each framework keeps an + explicit policy matching its actual programming model. + +## Chosen approach + +Compass will use a staged, typed evidence model. + +The Laravel delivery first proves the behavior with a PHP-specific import +table and AST receiver resolution. It is intentionally shippable on its own. +The second delivery extracts the reusable concepts into an internal evidence +module and moves all packs behind it. + +A disposable Laravel-only string guard was rejected because it would still +mis-handle aliases and would be replaced during the second delivery. A +registry-first rewrite was rejected because it would delay the requested +Laravel correction and combine the behavioral fix with a wider refactor. + +## Delivery 1: Laravel evidence gate + +### Import and receiver resolution + +The PHP framework detector will traverse the tree-sitter syntax tree once to +build a local import table. Each entry records: + +- the local binding; +- the normalized fully qualified target; +- the import anchor; and +- whether the binding is explicit or aliased. + +For example: + +```php +use Illuminate\Support\Facades\Route; +use Illuminate\Support\Facades\Route as Router; +``` + +resolve `Route` and `Router`, respectively, to +`Illuminate\Support\Facades\Route`. + +Laravel route extraction will inspect scoped-call AST nodes rather than search +the source for the literal text `Route::`. A call is eligible only when its +receiver is one of: + +- a local name that resolves through the import table to the Laravel facade; + or +- the fully qualified `\Illuminate\Support\Facades\Route` name. + +An unqualified `Route` with no matching import, a local class named `Route`, +and an alias resolving to any other fully qualified class are ineligible. The +parent directory and filename are not part of this decision. + +### Static route shapes + +After receiver resolution, the detector retains the currently supported +Laravel operations: + +- HTTP methods; +- `any`; +- `match`; +- `resource`; and +- `prefix(...)->group(...)` composition. + +Paths, method lists, prefixes, controller names, and action names must remain +statically inspectable wherever Compass emits an exact normalized route or +handler reference. Supported literal string handlers and controller/action +arrays continue to work. Variables, computed array members, variable static +methods, and otherwise unresolved handlers produce no Laravel framework fact. + +This is fail-closed framework extraction. The generic PHP extractor may still +record the underlying call and symbols. + +### Laravel data flow + +```text +PHP syntax tree + | + +--> one import-table traversal + | + +--> scoped-call traversal + | + +--> resolve receiver to exact facade identity + | + +--> validate static route shape + | + +--> normalize route and handler + | + `--> RawFrameworkFact +``` + +Prefix/group discovery will use the same resolved receiver identity. An +unrelated `Route::prefix(...)` cannot affect a valid or invalid route. + +## Delivery 2: shared activation evidence + +### Internal model + +`frameworks/evidence.rs` will own internal activation types. They are not +serialized into the graph: + +```text +ActivationEvidence + framework + kind + canonical_identity + local_identity + anchor + strength + +EvidenceKind + Manifest + Import + Receiver + DecoratorOrAttribute + Macro + ConfigurationContract + Convention + +EvidenceStrength + Direct + Supporting +``` + +An `EvidenceSet` is scoped to one source file or declarative artifact and is +immutable after collection. A pack's `ActivationPolicy` declares the direct +evidence combinations required for each fact family. The evaluator returns an +activation decision plus the matched evidence for internal diagnostics. + +The initial public `RawFrameworkFact` and provenance structures do not change. + +### Evidence rules + +Direct evidence proves a framework-owned construct: + +- an import or namespace resolves to a framework package; +- a call receiver was constructed from or resolves to that package; +- a decorator, attribute, annotation, or macro resolves to the framework; +- a manifest explicitly declares the framework dependency; or +- a file satisfies a framework-owned declarative configuration contract. + +Supporting evidence narrows context but does not prove ownership: + +- generic directories such as `routes`, `controllers`, or `models`; +- conventional filenames without an exact framework artifact contract; +- framework-like method names; and +- capitalization or suffix conventions. + +Declarative artifacts are distinct from generic path conventions. For example, +Play's `conf/routes` grammar and Drupal's `*.routing.yml` schema are direct +configuration-contract evidence because their parsers require the +framework-owned record shape. A directory merely named `routes` is supporting +evidence. + +File-system routers such as Next.js and Nuxt require both their exact route +location contract and project/package evidence identifying the framework. +This prevents an arbitrary `pages` directory from activating a framework. + +### Collection and evaluation + +Framework detection receives a `FrameworkDetectionContext` containing the +path, language, source, syntax root when available, and project evidence when +the extraction caller has it. Language-aware collectors populate one +`EvidenceSet`; packs query it instead of rescanning source text independently. + +The control flow becomes: + +```text +source/artifact + project evidence + | + v + language-aware collector + | + v + immutable EvidenceSet + | + +------+-------------------+ + | | + activation policy construct parser + | | + +------------+-------------+ + | + emit only when both pass +``` + +Activation answers “does this construct belong to the framework?” Construct +parsing answers “what route or domain fact does it declare?” Keeping these +questions separate prevents a method name or path from silently becoming +framework identity. + +### Pack migration + +Every module invoked by `frameworks/mod.rs` will consume the shared mechanism: + +- PHP: Laravel and Drupal; +- Python: Django, Flask, and FastAPI; +- Ruby: Rails; +- Java/Kotlin: Spring; +- Go: existing HTTP framework detectors; +- Rust: Axum, Actix, and Rocket; +- C#: ASP.NET; +- Swift: Vapor; +- JavaScript/TypeScript/TSX: Express, NestJS, React Router, Vue Router, and + file-system routing; +- declarative packs: Play and Drupal routing configuration; and +- enterprise domain-fact detectors. + +Migration is fact-family-specific. A file may activate one framework route +family without activating unrelated ORM, messaging, or job facts. The +enterprise detector therefore declares separate policies for each domain +family instead of using one broad “framework present” switch. + +The migration removes equivalent pack-local activation scans after their +policies are covered. Framework-specific construct parsing stays in the +existing pack files. + +## Performance design + +The Laravel delivery performs one import traversal and one route-call +traversal. It does not run one import lookup per call or construct regular +expressions from source-controlled names. + +The shared delivery collects evidence once and passes immutable references to +policies and parsers. Import maps and project manifest evidence use normalized +hash-map or set lookups. Pack migration must remove redundant +`body.contains(...)` activation scans when the same identity is available in +the evidence set. + +Performance acceptance is: + +- asymptotically linear collection in syntax-tree nodes plus emitted facts; +- no per-call whole-file scan; +- the existing framework fact limit remains enforced; and +- the repository's framework resolution scale/performance tests remain within + their current ceilings. + +Focused benchmarks will compare pre-change and post-change extraction for a +route-heavy corpus. Any measurable regression beyond ordinary benchmark noise +must be explained before the shared migration is accepted. + +## Error and fallback behavior + +- Invalid UTF-8 follows the existing lossy/empty-source behavior and must not + panic. +- A malformed or incomplete import produces no direct identity evidence. +- An unsupported dynamic construct produces no exact framework fact. +- Syntax recovery nodes may contribute facts only when receiver identity and + all required static fields remain unambiguous. +- Missing project evidence disables framework facts whose policy requires it; + it does not disable generic extraction. +- Evidence collection respects existing per-file fact limits and uses + saturating or checked position conversion consistent with current packs. +- Failure in one pack's policy does not activate a fallback heuristic. + +## Testing strategy + +### Laravel regression tests + +Tests will be added before implementation and must initially fail for the +current detector. Fixtures cover: + +- the canonical Laravel facade import; +- an aliased Laravel facade import; +- the fully qualified facade receiver; +- a wrong `Acme\Routing\Route` import inside a `routes` directory; +- an unimported `Route` inside a `routes` directory; +- a local class named `Route`; +- a dynamic handler; +- a variable static method; +- supported string and controller/action-array handlers; +- `match`, `resource`, and prefix/group behavior; and +- an unrelated prefix receiver. + +Negative cases assert the absence of Laravel facts, not the absence of generic +PHP nodes or calls. + +### Shared-mechanism tests + +The evidence module receives table-driven unit tests for: + +- direct versus supporting evidence; +- all-of and any-of policy clauses; +- canonical identity matching; +- framework and fact-family isolation; and +- deterministic evidence ordering. + +Each existing pack gains or retains: + +- at least one positive exact-evidence fixture; +- an alias or equivalent identity-resolution fixture where the language + supports aliases; +- one wrong-framework near match; +- one missing-evidence near match; and +- static-shape negatives for facts that claim exact targets. + +Cross-pack tests verify that evidence for one framework cannot activate +another and that generic path conventions do not activate code-driven packs. +Declarative and file-system routing tests verify their explicit configuration +and project-evidence policies. + +Existing integration, serialization, limit, and scale tests remain green. + +## Delivery and review sequence + +1. Add failing Laravel adversarial tests. +2. Implement PHP import/alias and scoped-call receiver resolution. +3. Verify Laravel positives, negatives, integration tests, and focused + extraction performance. +4. Commit the Laravel fix as an independently reviewable change. +5. Add the internal evidence model and policy unit tests. +6. Introduce the detection context and project-evidence input. +7. Migrate packs in language-sized commits, removing superseded activation + scans as each pack moves. +8. Run the full Compass test suite and framework scale/performance checks. +9. Run `graphify update .` from the outer repository after all code changes. + +## Acceptance criteria + +The work is complete when: + +- the Laravel false positives described above are regression-tested and + eliminated; +- valid canonical, aliased, and fully qualified Laravel routes still resolve; +- every framework fact emitted through `frameworks/mod.rs` has passed a shared + activation policy; +- no code-driven pack activates from path convention alone; +- framework-owned declarative artifacts use explicit configuration contracts; +- file-system routers require project framework evidence; +- the public framework-fact schema is unchanged; +- relevant unit, integration, limit, and performance tests pass; and +- `graphify update .` completes successfully. From 3a8ebad80b48cadfa44077ffe775e63ae0798912 Mon Sep 17 00:00:00 2001 From: forhappy Date: Thu, 30 Jul 2026 08:11:43 -0700 Subject: [PATCH 2/4] feat(frameworks): gate activation with direct evidence --- .../src/frameworks/csharp.rs | 16 +- .../src/frameworks/enterprise.rs | 159 ++++++-- .../src/frameworks/evidence.rs | 151 ++++++++ .../src/frameworks/file_routes.rs | 50 ++- crates/compass-languages/src/frameworks/go.rs | 26 +- .../compass-languages/src/frameworks/java.rs | 18 +- .../compass-languages/src/frameworks/mod.rs | 4 +- .../compass-languages/src/frameworks/php.rs | 348 ++++++++++++++---- .../compass-languages/src/frameworks/play.rs | 10 + .../src/frameworks/python.rs | 60 ++- .../compass-languages/src/frameworks/ruby.rs | 32 +- .../compass-languages/src/frameworks/rust.rs | 32 +- .../compass-languages/src/frameworks/swift.rs | 9 +- .../compass-languages/src/frameworks/text.rs | 61 --- .../src/frameworks/typescript.rs | 58 ++- .../tests/php_ruby_jvm_routes.rs | 70 ++++ 16 files changed, 891 insertions(+), 213 deletions(-) create mode 100644 crates/compass-languages/src/frameworks/evidence.rs diff --git a/crates/compass-languages/src/frameworks/csharp.rs b/crates/compass-languages/src/frameworks/csharp.rs index 2e94c257..90654083 100644 --- a/crates/compass-languages/src/frameworks/csharp.rs +++ b/crates/compass-languages/src/frameworks/csharp.rs @@ -4,12 +4,26 @@ use regex::Regex; use serde_json::Map; use tree_sitter::Node; +use super::evidence::{EvidenceKind, EvidenceSet}; use super::text::{join_route_path, line_anchor, normalize_route_path, text}; use super::{RawFrameworkFact, RawFrameworkOrigin, RawRouteFact}; pub(super) fn detect(path: &Path, source: &[u8], _root: Node<'_>) -> Vec { let body = text(source); - if !body.contains("Microsoft.AspNetCore.Mvc") && !body.contains("[ApiController]") { + let evidence = EvidenceSet::new() + .direct_if( + body.contains("Microsoft.AspNetCore.Mvc"), + "aspnet", + EvidenceKind::Import, + "Microsoft.AspNetCore.Mvc", + ) + .supporting_if( + body.contains("[ApiController]"), + "aspnet", + EvidenceKind::DecoratorOrAttribute, + "ApiController", + ); + if !evidence.activates("aspnet") { return Vec::new(); } let Ok(route_attribute) = Regex::new(r#"\[Route\(\s*"([^"]*)"\s*\)\]"#) else { diff --git a/crates/compass-languages/src/frameworks/enterprise.rs b/crates/compass-languages/src/frameworks/enterprise.rs index c08478b3..a48bb418 100644 --- a/crates/compass-languages/src/frameworks/enterprise.rs +++ b/crates/compass-languages/src/frameworks/enterprise.rs @@ -3,27 +3,117 @@ use std::path::Path; use regex::Regex; use serde_json::{Map, Value}; +use super::evidence::{EvidenceKind, EvidenceSet}; use super::text::{line_anchor, text}; use super::{RawDomainFact, RawFrameworkFact, RawFrameworkOrigin}; pub(super) fn detect(path: &Path, source: &[u8], language: &str) -> Vec { let body = text(source); + let evidence = enterprise_evidence(body, language); match language { - "python" => python(path, source, body), - "typescript" | "tsx" | "javascript" => typescript(path, source, body), - "java" => java(path, source, body), - "csharp" => csharp(path, source, body), - "ruby" => ruby(path, source, body), - "php" => php(path, source, body), - "go" => go(path, source, body), - "rust" => rust(path, source, body), + "python" => python(path, source, body, &evidence), + "typescript" | "tsx" | "javascript" => typescript(path, source, body, &evidence), + "java" => java(path, source, body, &evidence), + "csharp" => csharp(path, source, body, &evidence), + "ruby" => ruby(path, source, body, &evidence), + "php" => php(path, source, body, &evidence), + "go" => go(path, source, body, &evidence), + "rust" => rust(path, source, body, &evidence), _ => Vec::new(), } } -fn python(path: &Path, source: &[u8], body: &str) -> Vec { +fn enterprise_evidence(body: &str, language: &str) -> EvidenceSet { + match language { + "python" => EvidenceSet::new() + .direct_if( + body.contains("from celery") || body.contains("import celery"), + "celery", + EvidenceKind::Import, + "celery", + ) + .direct_if( + body.contains("django.db"), + "django-orm", + EvidenceKind::Import, + "django.db", + ) + .direct_if( + body.contains("sqlalchemy"), + "sqlalchemy", + EvidenceKind::Import, + "sqlalchemy", + ), + "typescript" | "tsx" | "javascript" => EvidenceSet::new() + .direct_if( + body.contains("@nestjs/"), + "nestjs", + EvidenceKind::Import, + "@nestjs/", + ) + .direct_if( + body.contains("typeorm"), + "typeorm", + EvidenceKind::Import, + "typeorm", + ), + "java" => EvidenceSet::new() + .direct_if( + body.contains("org.springframework."), + "spring", + EvidenceKind::Import, + "org.springframework", + ) + .direct_if( + body.contains("jakarta.persistence") || body.contains("javax.persistence"), + "jpa", + EvidenceKind::Import, + "JPA persistence namespace", + ), + "csharp" => EvidenceSet::new() + .direct_if( + body.contains("Microsoft.Extensions.Hosting"), + "aspnet", + EvidenceKind::Import, + "Microsoft.Extensions.Hosting", + ) + .direct_if( + body.contains("System.ComponentModel.DataAnnotations.Schema"), + "entity-framework", + EvidenceKind::Import, + "System.ComponentModel.DataAnnotations.Schema", + ), + "ruby" => EvidenceSet::new().direct_if( + body.contains("< ApplicationRecord") || body.contains("< ActiveRecord::Base"), + "active-record", + EvidenceKind::Receiver, + "ActiveRecord base class", + ), + "php" => EvidenceSet::new().direct_if( + body.contains("Illuminate\\Database\\Eloquent"), + "eloquent", + EvidenceKind::Import, + "Illuminate\\Database\\Eloquent", + ), + "go" => EvidenceSet::new().direct_if( + body.contains("gorm.io/gorm"), + "gorm", + EvidenceKind::Import, + "gorm.io/gorm", + ), + "rust" => EvidenceSet::new().direct_if( + body.contains("diesel::") || body.contains("use diesel"), + "diesel", + EvidenceKind::Import, + "diesel", + ), + _ => EvidenceSet::new(), + } +} + +fn python(path: &Path, source: &[u8], body: &str, evidence: &EvidenceSet) -> Vec { let mut facts = Vec::new(); - if body.contains("celery") { + if evidence.activates("celery") { let mut pending_task = None::<(Option, Option, usize, String)>; let task = Regex::new(r#"^\s*@(?:app\.task|shared_task)(?:\((.*)\))?"#).ok(); let function = Regex::new(r"^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)").ok(); @@ -71,8 +161,8 @@ fn python(path: &Path, source: &[u8], body: &str) -> Vec { offset += line.len(); } } - if body.contains("django.db") || body.contains("sqlalchemy") { - let framework = if body.contains("django.db") { + if evidence.activates("django-orm") || evidence.activates("sqlalchemy") { + let framework = if evidence.activates("django-orm") { "django-orm" } else { "sqlalchemy" @@ -93,9 +183,14 @@ fn python(path: &Path, source: &[u8], body: &str) -> Vec { facts } -fn typescript(path: &Path, source: &[u8], body: &str) -> Vec { +fn typescript( + path: &Path, + source: &[u8], + body: &str, + evidence: &EvidenceSet, +) -> Vec { let mut facts = Vec::new(); - if body.contains("@nestjs/") { + if evidence.activates("nestjs") { let class = Regex::new(r"\bclass\s+([A-Za-z_]\w*)").ok(); let method = Regex::new(r"^\s*(?:async\s+)?([A-Za-z_]\w*)\s*\(").ok(); let publish = Regex::new(r#"\.(emit|publish|send)\(\s*["'`]([^"'`]+)["'`]"#).ok(); @@ -154,7 +249,7 @@ fn typescript(path: &Path, source: &[u8], body: &str) -> Vec { offset += line.len(); } } - if body.contains("typeorm") { + if evidence.activates("typeorm") { facts.extend(decorator_table_mappings( "typeorm", path, @@ -167,7 +262,7 @@ fn typescript(path: &Path, source: &[u8], body: &str) -> Vec { facts } -fn java(path: &Path, source: &[u8], body: &str) -> Vec { +fn java(path: &Path, source: &[u8], body: &str, evidence: &EvidenceSet) -> Vec { let mut facts = Vec::new(); let class = Regex::new(r"\bclass\s+([A-Za-z_]\w*)").ok(); let method = Regex::new( @@ -247,7 +342,9 @@ fn java(path: &Path, source: &[u8], body: &str) -> Vec { } else { format!("{}.{}", owner, name.as_str()) }; - if let Some((kind, subject, relationship, at, anchor_line)) = pending_message.take() { + if evidence.activates("spring") + && let Some((kind, subject, relationship, at, anchor_line)) = pending_message.take() + { facts.push(message_fact( "spring", &kind, @@ -267,7 +364,9 @@ fn java(path: &Path, source: &[u8], body: &str) -> Vec { &anchor_line, )); } - if let Some((schedule, at, anchor_line)) = pending_job.take() { + if evidence.activates("spring") + && let Some((schedule, at, anchor_line)) = pending_job.take() + { facts.push(job_fact( "spring", &handler, @@ -283,15 +382,15 @@ fn java(path: &Path, source: &[u8], body: &str) -> Vec { } offset += line.len(); } - if body.contains("jakarta.persistence") || body.contains("javax.persistence") { + if evidence.activates("jpa") { facts.extend(java_table_mappings(path, source, body)); } facts } -fn csharp(path: &Path, source: &[u8], body: &str) -> Vec { +fn csharp(path: &Path, source: &[u8], body: &str, evidence: &EvidenceSet) -> Vec { let mut facts = Vec::new(); - if body.contains("BackgroundService") || body.contains("IHostedService") { + if evidence.activates("aspnet") { let class = Regex::new(r"\bclass\s+([A-Za-z_]\w*)\s*:[^{]*(?:BackgroundService|IHostedService)") .ok(); @@ -319,7 +418,7 @@ fn csharp(path: &Path, source: &[u8], body: &str) -> Vec { } } } - if body.contains("System.ComponentModel.DataAnnotations.Schema") { + if evidence.activates("entity-framework") { facts.extend(decorator_table_mappings( "entity-framework", path, @@ -332,8 +431,8 @@ fn csharp(path: &Path, source: &[u8], body: &str) -> Vec { facts } -fn ruby(path: &Path, source: &[u8], body: &str) -> Vec { - if !body.contains("ActiveRecord") && !body.contains("ApplicationRecord") { +fn ruby(path: &Path, source: &[u8], body: &str, evidence: &EvidenceSet) -> Vec { + if !evidence.activates("active-record") { return Vec::new(); } class_table_mappings( @@ -346,8 +445,8 @@ fn ruby(path: &Path, source: &[u8], body: &str) -> Vec { ) } -fn php(path: &Path, source: &[u8], body: &str) -> Vec { - if !body.contains("Illuminate\\Database\\Eloquent") { +fn php(path: &Path, source: &[u8], body: &str, evidence: &EvidenceSet) -> Vec { + if !evidence.activates("eloquent") { return Vec::new(); } class_table_mappings( @@ -360,8 +459,8 @@ fn php(path: &Path, source: &[u8], body: &str) -> Vec { ) } -fn go(path: &Path, source: &[u8], body: &str) -> Vec { - if !body.contains("gorm.io/gorm") { +fn go(path: &Path, source: &[u8], body: &str, evidence: &EvidenceSet) -> Vec { + if !evidence.activates("gorm") { return Vec::new(); } let Ok(pattern) = Regex::new( @@ -390,8 +489,8 @@ fn go(path: &Path, source: &[u8], body: &str) -> Vec { .collect() } -fn rust(path: &Path, source: &[u8], body: &str) -> Vec { - if !body.contains("diesel") { +fn rust(path: &Path, source: &[u8], body: &str, evidence: &EvidenceSet) -> Vec { + if !evidence.activates("diesel") { return Vec::new(); } decorator_table_mappings( diff --git a/crates/compass-languages/src/frameworks/evidence.rs b/crates/compass-languages/src/frameworks/evidence.rs new file mode 100644 index 00000000..ac12b522 --- /dev/null +++ b/crates/compass-languages/src/frameworks/evidence.rs @@ -0,0 +1,151 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum EvidenceKind { + Import, + Receiver, + DecoratorOrAttribute, + Macro, + ConfigurationContract, + Convention, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum EvidenceStrength { + Direct, + Supporting, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct ActivationEvidence { + pub framework: &'static str, + pub kind: EvidenceKind, + pub identity: String, + pub strength: EvidenceStrength, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(super) struct EvidenceSet { + entries: Vec, +} + +impl EvidenceSet { + pub(super) fn new() -> Self { + Self::default() + } + + #[must_use] + pub(super) fn direct_if( + mut self, + condition: bool, + framework: &'static str, + kind: EvidenceKind, + identity: impl Into, + ) -> Self { + if condition { + self.entries.push(ActivationEvidence { + framework, + kind, + identity: identity.into(), + strength: EvidenceStrength::Direct, + }); + } + self + } + + #[must_use] + pub(super) fn supporting_if( + mut self, + condition: bool, + framework: &'static str, + kind: EvidenceKind, + identity: impl Into, + ) -> Self { + if condition { + self.entries.push(ActivationEvidence { + framework, + kind, + identity: identity.into(), + strength: EvidenceStrength::Supporting, + }); + } + self + } + + pub(super) fn activates(&self, framework: &str) -> bool { + self.entries.iter().any(|evidence| { + evidence.framework == framework && evidence.strength == EvidenceStrength::Direct + }) + } + + #[cfg(test)] + fn evidence(&self, framework: &str) -> impl Iterator { + self.entries + .iter() + .filter(move |evidence| evidence.framework == framework) + } +} + +#[cfg(test)] +mod tests { + use super::{EvidenceKind, EvidenceSet, EvidenceStrength}; + + #[test] + fn supporting_conventions_cannot_activate_a_framework() { + let evidence = + EvidenceSet::new().supporting_if(true, "laravel", EvidenceKind::Convention, "routes/"); + + assert!(!evidence.activates("laravel")); + } + + #[test] + fn direct_evidence_activates_only_its_framework() { + let evidence = EvidenceSet::new() + .direct_if( + true, + "laravel", + EvidenceKind::Import, + "Illuminate\\Support\\Facades\\Route", + ) + .supporting_if(true, "laravel", EvidenceKind::Convention, "routes/"); + + assert!(evidence.activates("laravel")); + assert!(!evidence.activates("symfony")); + assert_eq!(evidence.evidence("laravel").count(), 2); + assert!(evidence.evidence("laravel").any(|item| { + item.kind == EvidenceKind::Import && item.strength == EvidenceStrength::Direct + })); + } + + #[test] + fn false_conditions_do_not_record_evidence() { + let evidence = EvidenceSet::new() + .direct_if(false, "spring", EvidenceKind::Import, "spring") + .supporting_if( + false, + "spring", + EvidenceKind::DecoratorOrAttribute, + "@RestController", + ); + + assert!(!evidence.activates("spring")); + assert_eq!(evidence.evidence("spring").count(), 0); + } + + #[test] + fn evidence_kinds_cover_receiver_macro_and_configuration_contracts() { + let evidence = EvidenceSet::new() + .direct_if(true, "flask", EvidenceKind::Receiver, "flask.Flask") + .direct_if(true, "rocket", EvidenceKind::Macro, "rocket::get") + .direct_if( + true, + "play", + EvidenceKind::ConfigurationContract, + "conf/routes", + ); + + assert!( + ["flask", "rocket", "play"] + .into_iter() + .all(|framework| evidence.activates(framework)) + ); + } +} diff --git a/crates/compass-languages/src/frameworks/file_routes.rs b/crates/compass-languages/src/frameworks/file_routes.rs index a873c6b2..3429ba4d 100644 --- a/crates/compass-languages/src/frameworks/file_routes.rs +++ b/crates/compass-languages/src/frameworks/file_routes.rs @@ -3,6 +3,7 @@ use std::path::Path; use regex::Regex; use serde_json::{Map, Value}; +use super::evidence::{EvidenceKind, EvidenceSet}; use super::{ RawDomainFact, RawFrameworkAnchor, RawFrameworkFact, RawFrameworkOrigin, RawRouteFact, }; @@ -18,7 +19,41 @@ pub(super) fn detect( } let portable = path.to_string_lossy().replace('\\', "/"); let lower = portable.to_ascii_lowercase(); - if let Some(relative) = segment_after(&portable, "src/routes/") { + let evidence = EvidenceSet::new() + .direct_if( + segment_after(&portable, "src/routes/").is_some() + && matches!( + path.file_name().and_then(|name| name.to_str()), + Some("+page.svelte" | "+page.ts" | "+server.ts" | "+server.js") + ), + "sveltekit", + EvidenceKind::ConfigurationContract, + "SvelteKit src/routes artifact", + ) + .direct_if( + (segment_after(&portable, "pages/").is_some() && lower.ends_with(".vue")) + || segment_after(&portable, "server/api/").is_some() + || (segment_after(&portable, "middleware/").is_some() + && lower.ends_with(".ts") + && lower.contains("nuxt")), + "nuxt", + EvidenceKind::ConfigurationContract, + "Nuxt route artifact", + ) + .direct_if( + segment_after(&portable, "src/pages/").is_some() + && (lower.ends_with(".astro") + || matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("ts" | "js") + )), + "astro", + EvidenceKind::ConfigurationContract, + "Astro src/pages artifact", + ); + if evidence.activates("sveltekit") + && let Some(relative) = segment_after(&portable, "src/routes/") + { if lower.ends_with("/+page.svelte") || lower.ends_with("/+page.ts") { return page_routes( "sveltekit", @@ -37,7 +72,8 @@ pub(super) fn detect( return endpoint_routes("sveltekit", route, path, source, extraction); } } - if let Some(relative) = segment_after(&portable, "pages/") + if evidence.activates("nuxt") + && let Some(relative) = segment_after(&portable, "pages/") && lower.ends_with(".vue") { return page_routes( @@ -48,7 +84,8 @@ pub(super) fn detect( extraction, ); } - if let Some(relative) = segment_after(&portable, "server/api/") + if evidence.activates("nuxt") + && let Some(relative) = segment_after(&portable, "server/api/") && matches!( path.extension().and_then(|extension| extension.to_str()), Some("ts" | "js" | "mts" | "mjs") @@ -65,7 +102,8 @@ pub(super) fn detect( "nuxt-server-api-convention", ); } - if let Some(relative) = segment_after(&portable, "middleware/") + if evidence.activates("nuxt") + && let Some(relative) = segment_after(&portable, "middleware/") && lower.ends_with(".ts") && lower.contains("nuxt") { @@ -79,7 +117,9 @@ pub(super) fn detect( detail: Map::new(), })]; } - if let Some(relative) = segment_after(&portable, "src/pages/") { + if evidence.activates("astro") + && let Some(relative) = segment_after(&portable, "src/pages/") + { if lower.ends_with(".astro") { return page_routes( "astro", diff --git a/crates/compass-languages/src/frameworks/go.rs b/crates/compass-languages/src/frameworks/go.rs index a4167e7a..fb7ee011 100644 --- a/crates/compass-languages/src/frameworks/go.rs +++ b/crates/compass-languages/src/frameworks/go.rs @@ -5,16 +5,36 @@ use regex::Regex; use serde_json::{Map, Value}; use tree_sitter::Node; +use super::evidence::{EvidenceKind, EvidenceSet}; use super::text::{join_route_path, line_anchor, normalize_route_path, split_top_level, text}; use super::{RawFrameworkFact, RawFrameworkOrigin, RawRouteFact}; pub(super) fn detect(path: &Path, source: &[u8], _root: Node<'_>) -> Vec { let body = text(source); - let framework = if body.contains("github.com/gin-gonic/gin") { + let evidence = EvidenceSet::new() + .direct_if( + body.contains("github.com/gin-gonic/gin"), + "gin", + EvidenceKind::Import, + "github.com/gin-gonic/gin", + ) + .direct_if( + body.contains("github.com/go-chi/chi"), + "chi", + EvidenceKind::Import, + "github.com/go-chi/chi", + ) + .direct_if( + body.contains("github.com/gorilla/mux"), + "gorilla", + EvidenceKind::Import, + "github.com/gorilla/mux", + ); + let framework = if evidence.activates("gin") { "gin" - } else if body.contains("github.com/go-chi/chi") { + } else if evidence.activates("chi") { "chi" - } else if body.contains("github.com/gorilla/mux") { + } else if evidence.activates("gorilla") { "gorilla" } else { return Vec::new(); diff --git a/crates/compass-languages/src/frameworks/java.rs b/crates/compass-languages/src/frameworks/java.rs index 2826ce3a..b52041e9 100644 --- a/crates/compass-languages/src/frameworks/java.rs +++ b/crates/compass-languages/src/frameworks/java.rs @@ -4,6 +4,7 @@ use regex::Regex; use serde_json::Map; use tree_sitter::Node; +use super::evidence::{EvidenceKind, EvidenceSet}; use super::text::{join_route_path, line_anchor, normalize_route_path, text}; use super::{RawFrameworkFact, RawFrameworkOrigin, RawRouteFact}; @@ -17,9 +18,20 @@ struct Mapping { pub(super) fn detect(path: &Path, source: &[u8], _root: Node<'_>) -> Vec { let body = text(source); - if !body.contains("org.springframework.web.bind.annotation") - && !body.contains("@RestController") - { + let evidence = EvidenceSet::new() + .direct_if( + body.contains("org.springframework.web.bind.annotation"), + "spring", + EvidenceKind::Import, + "org.springframework.web.bind.annotation", + ) + .supporting_if( + body.contains("@RestController"), + "spring", + EvidenceKind::DecoratorOrAttribute, + "@RestController", + ); + if !evidence.activates("spring") { return Vec::new(); } let Ok(annotation) = Regex::new( diff --git a/crates/compass-languages/src/frameworks/mod.rs b/crates/compass-languages/src/frameworks/mod.rs index a8843ebb..c8e50f1c 100644 --- a/crates/compass-languages/src/frameworks/mod.rs +++ b/crates/compass-languages/src/frameworks/mod.rs @@ -1,5 +1,6 @@ mod csharp; mod enterprise; +mod evidence; mod file_routes; mod go; mod java; @@ -41,8 +42,7 @@ pub(crate) fn detect( "csharp" => csharp::detect(path, source, root), "swift" => swift::detect(path, source, root), "javascript" | "typescript" | "tsx" => { - let mut facts = typescript::detect(path, source, root); - typescript::attach_import_aliases(path, source, root, extraction); + let mut facts = typescript::detect(path, source, root, extraction); facts.extend(file_routes::detect(path, source, extraction)); facts } diff --git a/crates/compass-languages/src/frameworks/php.rs b/crates/compass-languages/src/frameworks/php.rs index 7c0f42e6..98f2a694 100644 --- a/crates/compass-languages/src/frameworks/php.rs +++ b/crates/compass-languages/src/frameworks/php.rs @@ -1,29 +1,76 @@ +use std::collections::HashMap; use std::path::Path; use regex::Regex; use serde_json::{Map, Value}; use tree_sitter::Node; +use super::evidence::{EvidenceKind, EvidenceSet}; use super::text::{ - anchor, calls, join_route_path, line_anchor, literal, matching_delimiter, normalize_route_path, - split_top_level, text, + anchor, join_route_path, line_anchor, literal, normalize_route_path, split_top_level, text, }; use super::{RawFrameworkFact, RawFrameworkOrigin, RawRouteFact}; -pub(super) fn detect(path: &Path, source: &[u8], _root: Node<'_>) -> Vec { +const LARAVEL_ROUTE_FACADE: &str = "Illuminate\\Support\\Facades\\Route"; + +#[derive(Clone, Debug)] +struct LaravelCall { + method: String, + arguments: Vec, + start: usize, + end: usize, + group_body: Option<(usize, usize)>, +} + +pub(super) fn detect(path: &Path, source: &[u8], root: Node<'_>) -> Vec { let body = text(source); - let mut facts = if is_laravel_route_file(path, body) { - detect_laravel(path, source, body) + let imports = php_imports(root, source); + let mut calls = Vec::new(); + collect_laravel_calls(root, source, &imports, &mut calls); + let evidence = EvidenceSet::new() + .direct_if( + !calls.is_empty(), + "laravel", + EvidenceKind::Receiver, + LARAVEL_ROUTE_FACADE, + ) + .supporting_if( + is_routes_directory(path), + "laravel", + EvidenceKind::Convention, + "routes/", + ) + .direct_if( + is_drupal_hook_file(path), + "drupal", + EvidenceKind::ConfigurationContract, + "Drupal hook extension", + ); + let mut facts = if evidence.activates("laravel") { + detect_laravel(path, source, &calls) } else { Vec::new() }; - if is_drupal_hook_file(path) { + if evidence.activates("drupal") { facts.extend(detect_drupal_hooks(path, source, body)); } facts } pub(super) fn detect_drupal_routing(path: &Path, source: &[u8]) -> Vec { + let direct_contract = path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| name.ends_with(".routing.yml") || name.ends_with(".routing.yaml")); + let evidence = EvidenceSet::new().direct_if( + direct_contract, + "drupal", + EvidenceKind::ConfigurationContract, + "Drupal routing YAML", + ); + if !evidence.activates("drupal") { + return Vec::new(); + } let body = text(source); let mut facts = Vec::new(); let mut current_name = None::; @@ -103,40 +150,29 @@ pub(super) fn detect_drupal_routing(path: &Path, source: &[u8]) -> Vec Vec { - let Ok(route_call) = Regex::new(r"Route::([A-Za-z_][A-Za-z0-9_]*)\s*\(") else { - return Vec::new(); - }; - let prefixes = laravel_prefixes(body); +fn detect_laravel(path: &Path, source: &[u8], calls: &[LaravelCall]) -> Vec { + let prefixes = laravel_prefixes(calls); let mut facts = Vec::new(); - for capture in route_call.captures_iter(body) { - let Some(method_match) = capture.get(1) else { - continue; - }; - let method = method_match.as_str().to_ascii_lowercase(); + for call in calls { + let method = call.method.as_str(); if method == "prefix" { continue; } - let Some(call_match) = capture.get(0) else { - continue; - }; - let open = call_match.end().saturating_sub(1); - let Some(close) = matching_delimiter(body.as_bytes(), open, b'(', b')') else { - continue; - }; - let arguments = split_top_level(&body[open + 1..close]); let prefix = prefixes .iter() - .filter(|(_, start, end)| *start < call_match.start() && call_match.start() < *end) + .filter(|(_, start, end)| *start < call.start && call.start < *end) .max_by_key(|(_, start, _)| *start) .map(|(prefix, _, _)| prefix.as_str()) .unwrap_or_default(); - let call_anchor = anchor(path, source, call_match.start(), close + 1); + let call_anchor = anchor(path, source, call.start, call.end); if method == "resource" { - let Some(resource) = arguments.first().and_then(|value| literal(value)) else { + let Some(resource) = call.arguments.first().and_then(|value| literal(value)) else { continue; }; - let Some(controller) = arguments.get(1).and_then(|value| laravel_controller(value)) + let Some(controller) = call + .arguments + .get(1) + .and_then(|value| laravel_controller(value)) else { continue; }; @@ -144,7 +180,7 @@ fn detect_laravel(path: &Path, source: &[u8], body: &str) -> Vec Vec Vec, source: &[u8]) -> HashMap { + let mut imports = HashMap::new(); + collect_php_imports(root, source, &mut imports); + imports +} + +fn collect_php_imports(node: Node<'_>, source: &[u8], imports: &mut HashMap) { + if node.kind() == "namespace_use_declaration" { + parse_php_import_declaration(node_text(node, source), imports); + return; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + collect_php_imports(child, source, imports); + } +} + +fn parse_php_import_declaration(declaration: &str, imports: &mut HashMap) { + let declaration = declaration.trim(); + let Some(body) = declaration + .get(3..) + .filter(|_| { + declaration + .get(..3) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("use")) + }) + .map(str::trim) + .and_then(|value| value.strip_suffix(';')) + .map(str::trim) + else { + return; + }; + if starts_with_keyword(body, "function") || starts_with_keyword(body, "const") { + return; + } + if let (Some(open), Some(close)) = (body.find('{'), body.rfind('}')) { + if open >= close { + return; + } + let prefix = body[..open].trim().trim_end_matches('\\'); + for entry in split_top_level(&body[open + 1..close]) { + add_php_import(prefix, entry, imports); + } + } else { + for entry in split_top_level(body) { + add_php_import("", entry, imports); + } + } +} + +fn add_php_import(prefix: &str, entry: &str, imports: &mut HashMap) { + let entry = entry.trim(); + if starts_with_keyword(entry, "function") || starts_with_keyword(entry, "const") { + return; + } + let parts = entry.split_whitespace().collect::>(); + let Some(imported) = parts.first().copied() else { + return; + }; + let alias = if parts.len() == 3 && parts[1].eq_ignore_ascii_case("as") { + parts[2] + } else if parts.len() == 1 { + imported.rsplit('\\').next().unwrap_or_default() + } else { + return; + }; + let target = if prefix.is_empty() { + normalize_php_name(imported) + } else { + normalize_php_name(&format!("{prefix}\\{imported}")) + }; + if is_php_qualified_name(alias) && !target.is_empty() { + imports.insert(alias.to_owned(), target); + } +} + +fn collect_laravel_calls( + node: Node<'_>, + source: &[u8], + imports: &HashMap, + calls: &mut Vec, +) { + if node.kind() == "scoped_call_expression" + && let (Some(scope), Some(name), Some(arguments)) = ( + node.child_by_field_name("scope"), + node.child_by_field_name("name"), + node.child_by_field_name("arguments"), + ) + && matches!(scope.kind(), "name" | "qualified_name") + && name.kind() == "name" + && resolves_laravel_route(node_text(scope, source), imports) + { + let arguments_text = node_text(arguments, source).trim(); + if let Some(arguments_text) = arguments_text + .strip_prefix('(') + .and_then(|value| value.strip_suffix(')')) + { + calls.push(LaravelCall { + method: node_text(name, source).to_ascii_lowercase(), + arguments: split_top_level(arguments_text) + .into_iter() + .map(str::to_owned) + .collect(), + start: node.start_byte(), + end: node.end_byte(), + group_body: scoped_group_body(node, source), + }); + } + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + collect_laravel_calls(child, source, imports, calls); + } +} + +fn resolves_laravel_route(scope: &str, imports: &HashMap) -> bool { + let scope = normalize_php_name(scope); + scope == LARAVEL_ROUTE_FACADE + || imports + .get(&scope) + .is_some_and(|target| target == LARAVEL_ROUTE_FACADE) +} + +fn scoped_group_body(call: Node<'_>, source: &[u8]) -> Option<(usize, usize)> { + let parent = call.parent()?; + if parent.kind() != "member_call_expression" { + return None; + } + let object = parent.child_by_field_name("object")?; + let name = parent.child_by_field_name("name")?; + if object.id() != call.id() || node_text(name, source) != "group" { + return None; + } + let arguments = parent.child_by_field_name("arguments")?; + let body = find_descendant(arguments, "compound_statement")?; + Some((body.start_byte(), body.end_byte())) +} + +fn find_descendant<'tree>(node: Node<'tree>, kind: &str) -> Option> { + if node.kind() == kind { + return Some(node); + } + let mut cursor = node.walk(); + node.children(&mut cursor) + .filter(|child| child.is_named()) + .find_map(|child| find_descendant(child, kind)) +} + +fn starts_with_keyword(value: &str, keyword: &str) -> bool { + value + .get(..keyword.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(keyword)) + && value + .as_bytes() + .get(keyword.len()) + .is_some_and(u8::is_ascii_whitespace) +} + +fn normalize_php_name(value: &str) -> String { + value.trim().trim_start_matches('\\').to_owned() +} + +fn node_text<'source>(node: Node<'_>, source: &'source [u8]) -> &'source str { + node.utf8_text(source).unwrap_or_default() +} + +fn is_php_qualified_name(value: &str) -> bool { + !value.is_empty() + && value.split('\\').all(|segment| { + let mut characters = segment.chars(); + characters + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()) + && characters.all(|character| character == '_' || character.is_ascii_alphanumeric()) + }) +} + fn resource_routes( resource: &str, prefix: &str, @@ -261,32 +479,16 @@ fn detect_drupal_hooks(path: &Path, source: &[u8], body: &str) -> Vec Vec<(String, usize, usize)> { - let mut prefixes = Vec::new(); - for call in calls(source, "Route::prefix") { - let Some(prefix) = split_top_level(call.arguments) - .first() - .and_then(|value| literal(value)) - else { - continue; - }; - let suffix = &source[call.end..]; - let Some(group) = suffix.find("->group") else { - continue; - }; - let group_start = call.end + group; - let Some(open) = source[group_start..] - .find('{') - .map(|value| group_start + value) - else { - continue; - }; - let Some(close) = matching_delimiter(source.as_bytes(), open, b'{', b'}') else { - continue; - }; - prefixes.push((prefix, open, close)); - } - prefixes +fn laravel_prefixes(calls: &[LaravelCall]) -> Vec<(String, usize, usize)> { + calls + .iter() + .filter(|call| call.method == "prefix") + .filter_map(|call| { + let prefix = call.arguments.first().and_then(|value| literal(value))?; + let (start, end) = call.group_body?; + Some((prefix, start, end)) + }) + .collect() } fn laravel_handler(value: &str) -> Option { @@ -299,16 +501,16 @@ fn laravel_handler(value: &str) -> Option { .strip_suffix(']') .map(split_top_level)?; let controller = parts.first().and_then(|part| laravel_controller(part))?; - let action = parts.get(1).and_then(|part| literal(part)); - Some(action.map_or(controller.clone(), |action| { - format!("{controller}.{action}") - })) + let action = parts.get(1).and_then(|part| literal(part))?; + Some(format!("{controller}.{action}")) } fn laravel_controller(value: &str) -> Option { - let value = value.trim().trim_start_matches('\\'); - let value = value.strip_suffix("::class").unwrap_or(value); - (!value.is_empty()).then(|| value.replace('\\', ".")) + let value = value + .trim() + .strip_suffix("::class")? + .trim_start_matches('\\'); + is_php_qualified_name(value).then(|| value.replace('\\', ".")) } fn array_literals(value: &str) -> Vec { @@ -330,15 +532,6 @@ fn is_http_method(method: &str) -> bool { ) } -fn is_laravel_route_file(path: &Path, source: &str) -> bool { - source.contains("Illuminate\\Support\\Facades\\Route") - || path - .parent() - .and_then(Path::file_name) - .and_then(|value| value.to_str()) - .is_some_and(|parent| parent.eq_ignore_ascii_case("routes")) -} - fn is_drupal_hook_file(path: &Path) -> bool { matches!( path.extension().and_then(|value| value.to_str()), @@ -346,6 +539,13 @@ fn is_drupal_hook_file(path: &Path) -> bool { ) } +fn is_routes_directory(path: &Path) -> bool { + path.parent() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .is_some_and(|parent| parent.eq_ignore_ascii_case("routes")) +} + fn normalize_drupal_handler(value: &str) -> String { value .trim() diff --git a/crates/compass-languages/src/frameworks/play.rs b/crates/compass-languages/src/frameworks/play.rs index 6d2fa453..dcc27c47 100644 --- a/crates/compass-languages/src/frameworks/play.rs +++ b/crates/compass-languages/src/frameworks/play.rs @@ -3,10 +3,20 @@ use std::path::Path; use regex::Regex; use serde_json::Map; +use super::evidence::{EvidenceKind, EvidenceSet}; use super::text::{line_anchor_at, normalize_route_path, text}; use super::{FrameworkLimits, RawFrameworkFact, RawFrameworkOrigin, RawRouteFact}; pub(super) fn detect(path: &Path, source: &[u8]) -> Vec { + let evidence = EvidenceSet::new().direct_if( + path.file_name().and_then(|name| name.to_str()) == Some("routes"), + "play", + EvidenceKind::ConfigurationContract, + "Play conf/routes", + ); + if !evidence.activates("play") { + return Vec::new(); + } let body = text(source); let Ok(route) = Regex::new( r"^\s*(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+(\S+)\s+(@?[A-Za-z_$][A-Za-z0-9_$.]*(?:\([^)]*\))?)", diff --git a/crates/compass-languages/src/frameworks/python.rs b/crates/compass-languages/src/frameworks/python.rs index 2b01c69f..31963715 100644 --- a/crates/compass-languages/src/frameworks/python.rs +++ b/crates/compass-languages/src/frameworks/python.rs @@ -5,6 +5,7 @@ use regex::Regex; use serde_json::{Map, Value}; use tree_sitter::Node; +use super::evidence::{EvidenceKind, EvidenceSet}; use super::{RawFrameworkAnchor, RawFrameworkFact, RawFrameworkOrigin, RawRouteFact}; #[derive(Clone, Debug)] @@ -15,13 +16,40 @@ struct Receiver { pub(super) fn detect(path: &Path, source: &[u8], root: Node<'_>) -> Vec { let text = std::str::from_utf8(source).unwrap_or_default(); - let receivers = receiver_declarations(root, source); let aliases = import_aliases(root, source); + let receivers = receiver_declarations(root, source, &aliases); + let django_import = aliases.values().any(|target| { + target.starts_with("django.urls.") || target.starts_with("django.conf.urls.") + }); + let evidence = EvidenceSet::new() + .direct_if(django_import, "django", EvidenceKind::Import, "django.urls") + .supporting_if( + is_django_url_module(path, text), + "django", + EvidenceKind::Convention, + "urls.py with urlpatterns", + ) + .direct_if( + receivers + .values() + .any(|receiver| receiver.framework == "flask"), + "flask", + EvidenceKind::Receiver, + "flask application receiver", + ) + .direct_if( + receivers + .values() + .any(|receiver| receiver.framework == "fastapi"), + "fastapi", + EvidenceKind::Receiver, + "fastapi application receiver", + ); let mut facts = Vec::new(); - if is_django_url_module(path, text) { + if evidence.activates("django") { collect_django_routes(root, source, path, &aliases, &mut facts); } - if !receivers.is_empty() { + if evidence.activates("flask") || evidence.activates("fastapi") { collect_decorated_routes(root, source, path, &receivers, &aliases, &mut facts); } facts @@ -159,9 +187,13 @@ fn collect_decorated_routes( } } -fn receiver_declarations(root: Node<'_>, source: &[u8]) -> HashMap { +fn receiver_declarations( + root: Node<'_>, + source: &[u8], + aliases: &HashMap, +) -> HashMap { let mut receivers = HashMap::new(); - collect_receivers(root, source, &mut receivers); + collect_receivers(root, source, aliases, &mut receivers); receivers } @@ -243,7 +275,12 @@ fn expand_alias(reference: &str, aliases: &HashMap) -> String { ) } -fn collect_receivers(node: Node<'_>, source: &[u8], receivers: &mut HashMap) { +fn collect_receivers( + node: Node<'_>, + source: &[u8], + aliases: &HashMap, + receivers: &mut HashMap, +) { if node.kind() == "assignment" { let left = node.child_by_field_name("left"); let right = node.child_by_field_name("right"); @@ -253,10 +290,11 @@ fn collect_receivers(node: Node<'_>, source: &[u8], receivers: &mut HashMap Some("flask"), - "FastAPI" | "APIRouter" => Some("fastapi"), + let resolved = expand_alias(constructor, aliases); + let terminal = resolved.rsplit('.').next().unwrap_or(&resolved); + let framework = match resolved.as_str() { + "flask.Flask" | "flask.Blueprint" => Some("flask"), + "fastapi.FastAPI" | "fastapi.APIRouter" => Some("fastapi"), _ => None, }; if let Some(framework) = framework { @@ -278,7 +316,7 @@ fn collect_receivers(node: Node<'_>, source: &[u8], receivers: &mut HashMap) -> Vec { let body = text(source); - if !is_rails_routes(path, body) { + let evidence = EvidenceSet::new() + .direct_if( + body.contains(".routes.draw do"), + "rails", + EvidenceKind::Receiver, + "Rails.application.routes", + ) + .supporting_if( + is_rails_routes_path(path), + "rails", + EvidenceKind::Convention, + "config/routes.rb", + ); + if !evidence.activates("rails") { return Vec::new(); } let Ok(scope) = Regex::new( @@ -160,13 +174,11 @@ fn camelize(value: &str) -> String { .collect() } -fn is_rails_routes(path: &Path, source: &str) -> bool { - source.contains(".routes.draw do") - || path - .components() - .rev() - .take(2) - .map(|component| component.as_os_str().to_string_lossy()) - .collect::>() - == ["routes.rb", "config"] +fn is_rails_routes_path(path: &Path) -> bool { + path.components() + .rev() + .take(2) + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + == ["routes.rb", "config"] } diff --git a/crates/compass-languages/src/frameworks/rust.rs b/crates/compass-languages/src/frameworks/rust.rs index 7e197ae4..468ca951 100644 --- a/crates/compass-languages/src/frameworks/rust.rs +++ b/crates/compass-languages/src/frameworks/rust.rs @@ -4,14 +4,40 @@ use regex::Regex; use serde_json::Map; use tree_sitter::Node; +use super::evidence::{EvidenceKind, EvidenceSet}; use super::text::{line_anchor, normalize_route_path, text}; use super::{RawFrameworkFact, RawFrameworkOrigin, RawRouteFact}; pub(super) fn detect(path: &Path, source: &[u8], _root: Node<'_>) -> Vec { let body = text(source); - let axum = body.contains("axum::") || body.contains("use axum"); - let actix = body.contains("actix_web"); - let rocket = body.contains("rocket::") || body.contains("#[rocket::"); + let evidence = EvidenceSet::new() + .direct_if( + body.contains("axum::") || body.contains("use axum"), + "axum", + EvidenceKind::Import, + "axum", + ) + .direct_if( + body.contains("actix_web"), + "actix", + EvidenceKind::Import, + "actix_web", + ) + .direct_if( + body.contains("rocket::"), + "rocket", + EvidenceKind::Import, + "rocket", + ) + .direct_if( + body.contains("#[rocket::"), + "rocket", + EvidenceKind::Macro, + "rocket route attribute", + ); + let axum = evidence.activates("axum"); + let actix = evidence.activates("actix"); + let rocket = evidence.activates("rocket"); if !axum && !actix && !rocket { return Vec::new(); } diff --git a/crates/compass-languages/src/frameworks/swift.rs b/crates/compass-languages/src/frameworks/swift.rs index 468b6009..0526a345 100644 --- a/crates/compass-languages/src/frameworks/swift.rs +++ b/crates/compass-languages/src/frameworks/swift.rs @@ -5,12 +5,19 @@ use regex::Regex; use serde_json::Map; use tree_sitter::Node; +use super::evidence::{EvidenceKind, EvidenceSet}; use super::text::{join_route_path, line_anchor, text}; use super::{RawFrameworkFact, RawFrameworkOrigin, RawRouteFact}; pub(super) fn detect(path: &Path, source: &[u8], _root: Node<'_>) -> Vec { let body = text(source); - if !body.contains("import Vapor") { + let evidence = EvidenceSet::new().direct_if( + body.contains("import Vapor"), + "vapor", + EvidenceKind::Import, + "Vapor", + ); + if !evidence.activates("vapor") { return Vec::new(); } let Ok(group) = diff --git a/crates/compass-languages/src/frameworks/text.rs b/crates/compass-languages/src/frameworks/text.rs index 0e10d833..d61fd2a7 100644 --- a/crates/compass-languages/src/frameworks/text.rs +++ b/crates/compass-languages/src/frameworks/text.rs @@ -2,11 +2,6 @@ use std::path::Path; use super::RawFrameworkAnchor; -pub(super) struct CallMatch<'a> { - pub end: usize, - pub arguments: &'a str, -} - pub(super) fn text(source: &[u8]) -> &str { std::str::from_utf8(source).unwrap_or_default() } @@ -64,62 +59,6 @@ pub(super) fn line_anchor_at( } } -pub(super) fn calls<'a>(source: &'a str, prefix: &str) -> Vec> { - let mut matches = Vec::new(); - let mut search_from = 0; - while let Some(relative) = source[search_from..].find(prefix) { - let start = search_from + relative; - let open = start + prefix.len(); - if open >= source.len() || source.as_bytes()[open] != b'(' { - search_from = open; - continue; - } - let Some(close) = matching_delimiter(source.as_bytes(), open, b'(', b')') else { - break; - }; - matches.push(CallMatch { - end: close + 1, - arguments: &source[open + 1..close], - }); - search_from = close + 1; - } - matches -} - -pub(super) fn matching_delimiter( - source: &[u8], - open: usize, - opening: u8, - closing: u8, -) -> Option { - let mut depth = 0_u32; - let mut quote = None; - let mut escaped = false; - for (index, byte) in source.iter().copied().enumerate().skip(open) { - if let Some(active) = quote { - if escaped { - escaped = false; - } else if byte == b'\\' { - escaped = true; - } else if byte == active { - quote = None; - } - continue; - } - if matches!(byte, b'\'' | b'"' | b'`') { - quote = Some(byte); - } else if byte == opening { - depth = depth.saturating_add(1); - } else if byte == closing { - depth = depth.saturating_sub(1); - if depth == 0 { - return Some(index); - } - } - } - None -} - pub(super) fn split_top_level(value: &str) -> Vec<&str> { let bytes = value.as_bytes(); let mut parts = Vec::new(); diff --git a/crates/compass-languages/src/frameworks/typescript.rs b/crates/compass-languages/src/frameworks/typescript.rs index 72c7420a..c99cad9d 100644 --- a/crates/compass-languages/src/frameworks/typescript.rs +++ b/crates/compass-languages/src/frameworks/typescript.rs @@ -5,6 +5,7 @@ use regex::Regex; use serde_json::{Map, Value}; use tree_sitter::Node; +use super::evidence::{EvidenceKind, EvidenceSet}; use super::{ RawDomainFact, RawFrameworkAnchor, RawFrameworkFact, RawFrameworkOrigin, RawRouteFact, }; @@ -14,37 +15,76 @@ const HTTP_METHODS: &[&str] = &[ "get", "post", "put", "patch", "delete", "options", "head", "all", ]; -pub(super) fn detect(path: &Path, source: &[u8], root: Node<'_>) -> Vec { +pub(super) fn detect( + path: &Path, + source: &[u8], + root: Node<'_>, + extraction: &mut Extraction, +) -> Vec { if source.is_empty() { return Vec::new(); } - let text = std::str::from_utf8(source).unwrap_or_default(); + let mut imports = Vec::new(); + collect_import_aliases(root, source, &mut imports); + attach_import_aliases(path, source, root, extraction, &imports); + let imports_module = |expected: &str| { + imports.iter().any(|(_, _, module, _)| { + module == expected + || (expected.ends_with('/') && module.starts_with(expected)) + || (expected == "react-router" && module.starts_with("react-router-")) + }) + }; let mut facts = Vec::new(); let receivers = express_receivers(root, source); - if !receivers.is_empty() { + let evidence = EvidenceSet::new() + .direct_if( + !receivers.is_empty() && imports_module("express"), + "express", + EvidenceKind::Receiver, + "express application/router", + ) + .direct_if( + imports_module("@nestjs/"), + "nestjs", + EvidenceKind::Import, + "@nestjs/", + ) + .direct_if( + imports_module("react-router"), + "react-router", + EvidenceKind::Import, + "react-router", + ) + .direct_if( + imports_module("vue-router"), + "vue-router", + EvidenceKind::Import, + "vue-router", + ); + if evidence.activates("express") { collect_express_routes(root, source, path, &receivers, &mut facts); } - if text.contains("@nestjs/") { + if evidence.activates("nestjs") { collect_nest_routes(root, source, path, &mut facts); } - if text.contains("react-router") { + if evidence.activates("react-router") { collect_react_router_routes(root, source, path, &mut facts); } - if text.contains("vue-router") || text.contains("createRouter") { + if evidence.activates("vue-router") { collect_vue_router_routes(root, source, path, &mut facts); } facts } -pub(super) fn attach_import_aliases( +fn attach_import_aliases( path: &Path, source: &[u8], root: Node<'_>, extraction: &mut Extraction, + aliases: &[(String, String, String, u64)], ) { attach_default_export_identities(path, source, root, extraction); - let mut aliases = Vec::new(); - collect_import_aliases(root, source, &mut aliases); + let mut aliases = aliases.to_vec(); aliases.sort(); aliases.dedup(); let source_file = path.to_string_lossy().into_owned(); diff --git a/crates/compass-resolve/tests/php_ruby_jvm_routes.rs b/crates/compass-resolve/tests/php_ruby_jvm_routes.rs index d2740b12..af9ed680 100644 --- a/crates/compass-resolve/tests/php_ruby_jvm_routes.rs +++ b/crates/compass-resolve/tests/php_ruby_jvm_routes.rs @@ -66,6 +66,76 @@ fn laravel_routes_expand_resources_prefixes_and_handler_syntaxes() -> Result<(), Ok(()) } +#[test] +fn laravel_routes_require_the_exact_facade_receiver_and_static_handler() +-> Result<(), Box> { + let mut engine = Engine::default(); + let wrong_import = engine.extract_source( + Path::new("routes/wrong.php"), + br#"group(function () { + Router::get('/unprefixed', [AliasController::class, 'show']); +}); +"#, + )?; + let routes = routes + .framework_facts + .iter() + .filter_map(|fact| match fact { + RawFrameworkFact::Route(route) => Some(route), + RawFrameworkFact::Domain(_) => None, + }) + .collect::>(); + assert_eq!(routes.len(), 4, "routes={routes:#?}"); + assert!(routes.iter().any(|route| { + route.normalized_path == "/alias" && route.handler_reference == "AliasController.show" + })); + assert!(routes.iter().any(|route| { + route.normalized_path == "/grouped-alias" + && route.handler_reference == "AliasController.update" + })); + assert!(routes.iter().any(|route| { + route.normalized_path == "/qualified" + && route.handler_reference == "QualifiedController.store" + })); + assert!(routes.iter().any(|route| { + route.normalized_path == "/unprefixed" && route.handler_reference == "AliasController.show" + })); + assert!( + routes + .iter() + .all(|route| !route.normalized_path.contains("/wrong")) + ); + Ok(()) +} + #[test] fn drupal_yaml_and_hook_files_publish_auditable_routes() -> Result<(), Box> { let mut extraction = extract("php/drupal.routing.yml")?; From f1fbd7ca753b0a14765c6438b4f036492100d333 Mon Sep 17 00:00:00 2001 From: forhappy Date: Thu, 30 Jul 2026 09:17:09 -0700 Subject: [PATCH 3/4] docs: design project framework evidence registry --- ...ework-evidence-and-pack-registry-design.md | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-project-framework-evidence-and-pack-registry-design.md diff --git a/docs/superpowers/specs/2026-07-30-project-framework-evidence-and-pack-registry-design.md b/docs/superpowers/specs/2026-07-30-project-framework-evidence-and-pack-registry-design.md new file mode 100644 index 00000000..37b19793 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-project-framework-evidence-and-pack-registry-design.md @@ -0,0 +1,259 @@ +# Project Framework Evidence and Pack Registry Design + +**Date:** 2026-07-30 + +**Status:** Approved for implementation planning + +**Implementation root:** `/Users/haipingfu/graphify/compass` + +## Purpose + +Compass framework extraction now distinguishes direct activation evidence from +supporting conventions, but activation remains file-local and framework +dispatch remains a hard-coded language match. This phase adds project-level +dependency evidence and a registry-driven dispatcher without changing the +public graph schema. + +## Goals + +- Read supported dependency manifests once per repository build. +- Resolve each source file to the nearest enclosing project root. +- Share immutable project evidence across parallel extraction workers. +- Prevent stale framework facts when a manifest changes but source files do + not. +- Replace hard-coded framework dispatch with static, testable pack + descriptors. +- Require project dependency evidence for convention-driven file routers when + repository context is available. +- Preserve isolated `Engine::default()` extraction for callers without + repository context. +- Keep activation work bounded by relevant languages, artifact types, and + module dependencies. + +## Non-goals + +- Add a public or dynamically loaded plugin ABI. +- Add dozens of new framework detectors in this phase. +- Execute package managers, resolve lockfiles, or access the network. +- Infer transitive dependencies not declared by a local manifest. +- Change `RawFrameworkFact`, graph-v1 serialization, or route normalization. +- Move framework detection out of `compass-languages`. + +## Project evidence index + +`compass-languages` will expose an immutable `ProjectEvidenceIndex`. The core +pipeline builds it before AST cache lookup and shares it with each extraction +worker through `Arc`. + +The index contains project entries keyed by normalized manifest directory: + +```text +ProjectEvidence + project_root + manifests + ecosystems + normalized_dependencies + fingerprint +``` + +Supported inputs in this phase are: + +- `package.json` +- `composer.json` +- `pyproject.toml` +- `requirements.txt` and `requirements.in` +- `Gemfile` +- `pom.xml` +- `build.gradle` and `build.gradle.kts` +- `Cargo.toml` +- `go.mod` +- `*.csproj` +- `Package.swift` + +The builder receives the repository root and detected source paths. It gathers +each source directory and its ancestors up to the root, probes every unique +directory once for recognized manifest names, and reads only regular, +non-symlink files within the repository. Manifest size and dependency-count +limits prevent unbounded input. + +Dependencies are normalized conservatively: + +- case-fold where the ecosystem is case-insensitive; +- remove version constraints without changing package identity; +- retain Maven group/artifact and Go module paths; +- retain scoped JavaScript package names; and +- deduplicate with deterministic ordering. + +Malformed or oversized manifests contribute a bounded diagnostic entry and no +dependency evidence. They never activate a framework. + +## Source-to-project resolution + +The nearest manifest directory containing a source file owns that source. +Manifests in the same directory merge into one project entry. A source without +an enclosing manifest receives an explicit repository fallback entry with no +dependencies. + +Lookup walks normalized ancestors and uses the prebuilt directory map; it does +not perform filesystem I/O during source extraction. + +## Cache correctness + +Framework facts are part of cached AST extraction, so source content alone is +not a sufficient cache key once manifests affect activation. + +Each extraction created with repository context records its owning project +fingerprint in a private extension field: + +```text +_compass_framework_project_evidence +``` + +Before accepting a cached AST extraction, the core pipeline compares the +stored fingerprint with the current index result for that source: + +- equal fingerprints reuse the cached extraction; +- absent or different fingerprints re-extract that source; and +- only sources owned by the changed project are invalidated. + +Fresh values replace their existing content-addressed cache entry. This keeps +current cache storage compatible while making project-evidence changes +correct. Cross-repository shared-cache collisions remain safe because a +fingerprint mismatch forces re-extraction. + +The fingerprint covers the evidence schema version, normalized project root +relative to the repository, recognized manifest names, and normalized +dependencies. It excludes dependency versions because framework activation +in this phase depends only on declared package identity. + +## Engine integration + +`Engine` gains an optional shared project evidence index: + +```text +Engine::default() +Engine::with_project_evidence(Arc) +``` + +Repository builds always use the second form. Isolated callers and existing +in-memory tests may continue using `Engine::default()`. + +Framework detection receives an optional `ProjectEvidence` reference in its +detection context. No-context extraction preserves current exact local +activation behavior. When repository context is present, packs may require +manifest evidence in addition to local evidence. + +## Framework pack registry + +`frameworks/mod.rs` will define static descriptors rather than a language +`match`: + +```text +FrameworkPack + id + languages + artifact_kind + dependency_markers + manifest_policy + detector +``` + +Artifact kinds are source, configuration, and template. Registry selection +first filters by artifact kind and language. Manifest policy is one of: + +- advisory for code-driven packs, where dependencies may avoid unnecessary + work but their absence cannot override an exact import or receiver; +- required for convention-driven packs such as file-system routers; or +- not applicable for framework-owned declarative configuration formats. + +Exact construct activation remains the detector's responsibility. + +Initial descriptors wrap existing language pack modules so this phase does +not duplicate or rewrite their construct parsers. A descriptor may represent +a related pack family, such as Python web frameworks or TypeScript routers. +New frameworks can later use one-framework descriptors. + +Registry tests enforce: + +- unique descriptor IDs; +- nonempty language or artifact matchers; +- deterministic declaration order; +- no duplicate detector execution for one artifact; and +- complete coverage of every detector currently invoked by + `frameworks/mod.rs`. + +## Activation policy changes + +Project manifests are a distinct evidence kind. A declared dependency can +select a candidate pack but cannot independently turn an arbitrary call into a +framework fact. + +For code-driven frameworks, exact import, receiver, decorator, attribute, or +macro evidence remains mandatory. + +For file-system routers during repository builds: + +- SvelteKit requires `@sveltejs/kit` plus the exact `src/routes` artifact + contract; +- Nuxt requires `nuxt` plus its exact page/server artifact contract; and +- Astro requires `astro` plus its exact `src/pages` artifact contract. + +Isolated extraction without a project index retains the existing exact +artifact-contract behavior for compatibility. + +Framework-owned declarative formats such as Play `conf/routes` and Drupal +routing YAML remain direct configuration evidence and do not require a +separate package manifest. + +## Performance + +- Manifest discovery probes each unique source ancestor directory once. +- Manifest files are bounded by size and dependency count. +- The immutable index performs no worker-time filesystem I/O. +- Source lookup is proportional to path depth. +- Registry selection considers only matching artifact/language descriptors. +- Cached files are invalidated per owning project rather than repository-wide. +- Existing framework resolution scale ceilings remain release gates. + +## Testing + +Unit tests cover manifest parsing, normalization, nearest-project lookup, +deterministic fingerprints, malformed inputs, size limits, and symlink +rejection. + +Pipeline tests cover: + +- a file-router fact emitted with the matching project dependency; +- the same route convention rejected when the dependency is absent; +- a nested project overriding its parent manifest; +- a manifest-only change invalidating affected cached source facts; +- an unrelated project retaining its cached facts; and +- shared evidence across parallel workers. + +Registry tests cover descriptor invariants and existing pack coverage. +Existing positive, negative, limit, serialization, and scale tests remain +green. + +## Delivery sequence + +1. Add `ProjectEvidenceIndex` and parser/lookup tests. +2. Add optional evidence context to `Engine`. +3. Add project fingerprints to extraction extensions. +4. Integrate module-scoped cache validation in `compass-core`. +5. Introduce source/config/template pack registries. +6. Make SvelteKit, Nuxt, and Astro repository builds project-aware. +7. Run focused tests, full resolver tests, strict Clippy, scale tests, and + `graphify update .`. + +## Acceptance criteria + +- Repository extraction performs one bounded project-evidence build. +- Parallel workers share the same immutable index. +- Manifest changes cannot leave stale framework facts in cached source + extractions. +- Existing framework modules are reached through registry descriptors. +- File-router conventions fail closed when repository evidence disproves the + framework. +- Isolated extraction compatibility is preserved. +- Public graph contracts remain unchanged. +- Correctness, lint, and performance gates pass. From 78cfcdd09c3c9f632a87a7042c18cf07862e9ffa Mon Sep 17 00:00:00 2001 From: forhappy Date: Thu, 30 Jul 2026 09:33:19 -0700 Subject: [PATCH 4/4] feat(frameworks): index project evidence and register packs --- crates/compass-core/src/pipeline.rs | 73 ++- .../tests/code_graph_v1_determinism.rs | 4 + crates/compass-languages/src/engine.rs | 75 ++- .../src/frameworks/file_routes.rs | 20 +- .../compass-languages/src/frameworks/mod.rs | 394 ++++++++++-- crates/compass-languages/src/lib.rs | 4 + .../compass-languages/src/project_evidence.rs | 595 ++++++++++++++++++ .../tests/typescript_routes.rs | 62 +- 8 files changed, 1157 insertions(+), 70 deletions(-) create mode 100644 crates/compass-languages/src/project_evidence.rs diff --git a/crates/compass-core/src/pipeline.rs b/crates/compass-core/src/pipeline.rs index 225c1a6c..8df43db2 100644 --- a/crates/compass-core/src/pipeline.rs +++ b/crates/compass-core/src/pipeline.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use ahash::{AHashMap, AHashSet}; @@ -18,7 +18,8 @@ use compass_graph::{ }; use compass_languages::{ EXTRACTION_QUALITY_EXTENSION, EXTRACTION_QUALITY_PARTIAL, EXTRACTION_QUALITY_REASON_EXTENSION, - Engine, Extraction, ExtractorKind, RawEdgeRecord, RawNodeRecord, Registry, file_stem, make_id, + Engine, Extraction, ExtractorKind, FRAMEWORK_PROJECT_EVIDENCE_EXTENSION, ProjectEvidenceIndex, + RawEdgeRecord, RawNodeRecord, Registry, file_stem, make_id, }; use compass_model::code_graph::{ DiagnosticSeverity, ExtractionStatus, GraphDiagnostic, GraphDocument as V1GraphDocument, @@ -638,6 +639,7 @@ fn build_graph_inner( CacheOptions::shared_history, ); let mut cache = Cache::open(&root, cache_options)?; + let project_evidence = Arc::new(ProjectEvidenceIndex::build(&root, &sources)); let mut extractions = BTreeMap::::new(); let mut missing = Vec::new(); if reuse_cached_analysis { @@ -658,7 +660,11 @@ fn build_graph_inner( path: path.clone(), source, })?; - extractions.insert(path.clone(), extraction); + if cached_framework_evidence_matches(&extraction, path, &project_evidence) { + extractions.insert(path.clone(), extraction); + } else { + missing.push(path.clone()); + } } else { missing.push(path.clone()); } @@ -776,16 +782,20 @@ fn build_graph_inner( Ok((path.clone(), combined.graph, source, prepared)) }; let fresh_outcomes = if missing.len() < 256 { - let mut engine = Engine::default(); + let mut engine = Engine::with_project_evidence(Arc::clone(&project_evidence)); missing .iter() .map(|path| extract_source(&mut engine, path)) .collect::>() } else { + let worker_evidence = Arc::clone(&project_evidence); let extract = || { missing .par_iter() - .map_init(Engine::default, extract_source) + .map_init( + || Engine::with_project_evidence(Arc::clone(&worker_evidence)), + extract_source, + ) .collect::>() }; if let Some(pool) = &worker_pool { @@ -3801,6 +3811,18 @@ fn read_source_text_with_limit(path: &Path, max_source_bytes: u64) -> Option<(St }) } +fn cached_framework_evidence_matches( + extraction: &Extraction, + path: &Path, + project_evidence: &ProjectEvidenceIndex, +) -> bool { + extraction + .extensions + .get(FRAMEWORK_PROJECT_EVIDENCE_EXTENSION) + .and_then(serde_json::Value::as_str) + == Some(project_evidence.fingerprint_for(path)) +} + #[cfg(test)] mod tests { use std::error::Error; @@ -3836,6 +3858,47 @@ mod tests { Ok(()) } + #[test] + fn framework_cache_reuse_is_scoped_to_project_evidence() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let source = directory.path().join("src/routes/+page.svelte"); + fs::create_dir_all(source.parent().ok_or("source has no parent")?)?; + fs::write(&source, "

Home

")?; + fs::write( + directory.path().join("package.json"), + r#"{"dependencies":{}}"#, + )?; + + let initial = ProjectEvidenceIndex::build(directory.path(), std::slice::from_ref(&source)); + let mut extraction = Extraction::default(); + assert!(!cached_framework_evidence_matches( + &extraction, + &source, + &initial + )); + extraction.extensions.insert( + FRAMEWORK_PROJECT_EVIDENCE_EXTENSION.to_owned(), + Value::String(initial.fingerprint_for(&source).to_owned()), + ); + assert!(cached_framework_evidence_matches( + &extraction, + &source, + &initial + )); + + fs::write( + directory.path().join("package.json"), + r#"{"dependencies":{"@sveltejs/kit":"2.0.0"}}"#, + )?; + let changed = ProjectEvidenceIndex::build(directory.path(), std::slice::from_ref(&source)); + assert!(!cached_framework_evidence_matches( + &extraction, + &source, + &changed + )); + Ok(()) + } + #[test] fn precomputed_detection_cannot_cross_repository_roots() -> Result<(), Box> { let detected_root = tempfile::tempdir()?; diff --git a/crates/compass-core/tests/code_graph_v1_determinism.rs b/crates/compass-core/tests/code_graph_v1_determinism.rs index 7e1e8ade..9e37c87a 100644 --- a/crates/compass-core/tests/code_graph_v1_determinism.rs +++ b/crates/compass-core/tests/code_graph_v1_determinism.rs @@ -127,6 +127,10 @@ fn production_pipeline_preserves_framework_domain_kinds_and_route_targets() let directory = tempfile::tempdir()?; let root = directory.path(); for (relative, source) in [ + ( + "package.json", + r#"{"dependencies":{"nuxt":"4.0.0","@nestjs/common":"11.0.0","react-router-dom":"7.0.0"}}"#, + ), ( "src/orders.ts", r#"import { Controller } from '@nestjs/common'; diff --git a/crates/compass-languages/src/engine.rs b/crates/compass-languages/src/engine.rs index 99d48c6b..4a470b67 100644 --- a/crates/compass-languages/src/engine.rs +++ b/crates/compass-languages/src/engine.rs @@ -3,6 +3,7 @@ use std::fs; use std::fs::File; use std::io::Read; use std::path::Path; +use std::sync::Arc; use crate::{RawEdgeRecord as EdgeRecord, RawNodeRecord as NodeRecord}; use serde_json::{Map, Value}; @@ -13,8 +14,9 @@ use crate::builtins::is_language_builtin_global; use crate::config::{GenericConfig, generic_config}; use crate::{ CombinedExtraction, EXTRACTION_QUALITY_EXTENSION, EXTRACTION_QUALITY_PARTIAL, - EXTRACTION_QUALITY_REASON_EXTENSION, ExtractError, Extraction, ExtractorKind, LanguageSpec, - RawCall, Registry, file_stem, make_id, + EXTRACTION_QUALITY_REASON_EXTENSION, ExtractError, Extraction, ExtractorKind, + FRAMEWORK_PROJECT_EVIDENCE_EXTENSION, LanguageSpec, ProjectEvidenceIndex, RawCall, Registry, + file_stem, make_id, }; const JSON_MAX_BYTES: u64 = 1_048_576; @@ -22,9 +24,18 @@ const JSON_MAX_BYTES: u64 = 1_048_576; #[derive(Default)] pub struct Engine { parsers: HashMap<&'static str, Parser>, + project_evidence: Option>, } impl Engine { + #[must_use] + pub fn with_project_evidence(project_evidence: Arc) -> Self { + Self { + parsers: HashMap::new(), + project_evidence: Some(project_evidence), + } + } + pub fn extract(&mut self, path: &Path) -> Result { let spec = Registry::resolve(path).ok_or_else(|| ExtractError::Unsupported(path.to_path_buf()))?; @@ -44,7 +55,12 @@ impl Engine { ExtractorKind::Template => { let mut extraction = crate::templates::extract(self, path, spec.name)?; if let Ok(source) = fs::read(path) { - crate::frameworks::detect_template_file_route(path, &source, &mut extraction); + crate::frameworks::detect_template_file_route( + path, + &source, + self.project_evidence(path), + &mut extraction, + ); } Ok(extraction) } @@ -53,9 +69,14 @@ impl Engine { path: path.to_path_buf(), source, })?; - Ok(crate::frameworks::detect_config_file(path, &source)) + Ok(crate::frameworks::detect_config_file( + path, + &source, + self.project_evidence(path), + )) } }?; + self.stamp_project_evidence(path, &mut extraction); stamp_producer_metadata(&mut extraction, spec.name); Ok(extraction) } @@ -74,11 +95,14 @@ impl Engine { ExtractorKind::Generic => self.extract_generic_source(path, spec, source), ExtractorKind::JsonConfig => self.extract_json_source(path, spec, source), ExtractorKind::Terraform => self.extract_terraform_source(path, spec, source), - ExtractorKind::FrameworkConfig => { - Ok(crate::frameworks::detect_config_file(path, source)) - } + ExtractorKind::FrameworkConfig => Ok(crate::frameworks::detect_config_file( + path, + source, + self.project_evidence(path), + )), _ => self.extract(path), }?; + self.stamp_project_evidence(path, &mut extraction); stamp_producer_metadata(&mut extraction, spec.name); Ok(extraction) } @@ -106,7 +130,8 @@ impl Engine { } let tree = self.parse(path, spec, source)?; let root = tree.root_node(); - let mut graph = Self::extract_generic_from_tree(path, spec, source, root); + let mut graph = self.extract_generic_from_tree(path, spec, source, root); + self.stamp_project_evidence(path, &mut graph); stamp_producer_metadata(&mut graph, spec.name); let program = crate::program::extract_from_tree(source_file, spec.name, source, root) .map_err(|error| ExtractError::InvalidProgramEvidence { @@ -149,6 +174,7 @@ impl Engine { &generic_config(spec), language, ); + self.stamp_project_evidence(path, &mut extraction); stamp_producer_metadata(&mut extraction, language); Ok(extraction) } @@ -203,15 +229,11 @@ impl Engine { source }; let tree = self.parse(path, spec, source)?; - Ok(Self::extract_generic_from_tree( - path, - spec, - source, - tree.root_node(), - )) + Ok(self.extract_generic_from_tree(path, spec, source, tree.root_node())) } fn extract_generic_from_tree( + &self, path: &Path, spec: LanguageSpec, source: &[u8], @@ -238,7 +260,14 @@ impl Engine { } attach_definition_metadata(&mut extraction, source, root, &config, spec.name); crate::semantic::enrich(path, source, root, spec.name, &mut extraction); - crate::frameworks::detect(path, source, root, spec.name, &mut extraction); + crate::frameworks::detect( + path, + source, + root, + spec.name, + self.project_evidence(path), + &mut extraction, + ); if root.has_error() { extraction.extensions.insert( EXTRACTION_QUALITY_EXTENSION.to_owned(), @@ -252,6 +281,22 @@ impl Engine { extraction } + fn project_evidence(&self, path: &Path) -> Option<&crate::ProjectEvidence> { + self.project_evidence + .as_deref() + .map(|index| index.evidence_for(path)) + } + + fn stamp_project_evidence(&self, path: &Path, extraction: &mut Extraction) { + let Some(evidence) = self.project_evidence(path) else { + return; + }; + extraction.extensions.insert( + FRAMEWORK_PROJECT_EVIDENCE_EXTENSION.to_owned(), + Value::String(evidence.fingerprint().to_owned()), + ); + } + fn extract_json( &mut self, path: &Path, diff --git a/crates/compass-languages/src/frameworks/file_routes.rs b/crates/compass-languages/src/frameworks/file_routes.rs index 3429ba4d..90a4ef30 100644 --- a/crates/compass-languages/src/frameworks/file_routes.rs +++ b/crates/compass-languages/src/frameworks/file_routes.rs @@ -7,11 +7,12 @@ use super::evidence::{EvidenceKind, EvidenceSet}; use super::{ RawDomainFact, RawFrameworkAnchor, RawFrameworkFact, RawFrameworkOrigin, RawRouteFact, }; -use crate::{Extraction, RawEdgeRecord, RawNodeRecord, make_id}; +use crate::{Extraction, ProjectEvidence, RawEdgeRecord, RawNodeRecord, make_id}; pub(super) fn detect( path: &Path, source: &[u8], + project: Option<&ProjectEvidence>, extraction: &mut Extraction, ) -> Vec { if source.is_empty() { @@ -21,7 +22,8 @@ pub(super) fn detect( let lower = portable.to_ascii_lowercase(); let evidence = EvidenceSet::new() .direct_if( - segment_after(&portable, "src/routes/").is_some() + project.is_none_or(|project| project.has_dependency("@sveltejs/kit")) + && segment_after(&portable, "src/routes/").is_some() && matches!( path.file_name().and_then(|name| name.to_str()), Some("+page.svelte" | "+page.ts" | "+server.ts" | "+server.js") @@ -31,17 +33,19 @@ pub(super) fn detect( "SvelteKit src/routes artifact", ) .direct_if( - (segment_after(&portable, "pages/").is_some() && lower.ends_with(".vue")) - || segment_after(&portable, "server/api/").is_some() - || (segment_after(&portable, "middleware/").is_some() - && lower.ends_with(".ts") - && lower.contains("nuxt")), + project.is_none_or(|project| project.has_dependency("nuxt")) + && ((segment_after(&portable, "pages/").is_some() && lower.ends_with(".vue")) + || segment_after(&portable, "server/api/").is_some() + || (segment_after(&portable, "middleware/").is_some() + && lower.ends_with(".ts") + && lower.contains("nuxt"))), "nuxt", EvidenceKind::ConfigurationContract, "Nuxt route artifact", ) .direct_if( - segment_after(&portable, "src/pages/").is_some() + project.is_none_or(|project| project.has_dependency("astro")) + && segment_after(&portable, "src/pages/").is_some() && (lower.ends_with(".astro") || matches!( path.extension().and_then(|extension| extension.to_str()), diff --git a/crates/compass-languages/src/frameworks/mod.rs b/crates/compass-languages/src/frameworks/mod.rs index c8e50f1c..d705571e 100644 --- a/crates/compass-languages/src/frameworks/mod.rs +++ b/crates/compass-languages/src/frameworks/mod.rs @@ -23,63 +23,235 @@ use std::path::Path; use tree_sitter::Node; -use crate::Extraction; +use crate::{Extraction, ProjectEvidence}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ManifestPolicy { + Advisory, + Required, +} + +struct DetectionContext<'source, 'tree> { + path: &'source Path, + source: &'source [u8], + root: Node<'tree>, + language: &'source str, + project: Option<&'source ProjectEvidence>, +} + +type SourceDetector = for<'source, 'tree> fn( + &DetectionContext<'source, 'tree>, + &mut Extraction, +) -> Vec; + +struct SourcePack { + id: &'static str, + languages: &'static [&'static str], + dependency_markers: &'static [&'static str], + manifest_policy: ManifestPolicy, + detector: SourceDetector, +} + +type ConfigMatcher = fn(&Path) -> bool; +type ConfigDetector = fn(&Path, &[u8]) -> Vec; + +struct ConfigPack { + id: &'static str, + matcher: ConfigMatcher, + detector: ConfigDetector, +} + +type TemplateDetector = + fn(&Path, &[u8], Option<&ProjectEvidence>, &mut Extraction) -> Vec; + +struct TemplatePack { + id: &'static str, + dependency_markers: &'static [&'static str], + manifest_policy: ManifestPolicy, + detector: TemplateDetector, +} + +const SOURCE_PACKS: &[SourcePack] = &[ + source_pack("python-web", &["python"], &[], detect_python), + source_pack( + "php-frameworks", + &["php"], + &["laravel/framework", "drupal/core"], + detect_php, + ), + source_pack("rails-routes", &["ruby"], &["rails"], detect_ruby), + source_pack( + "spring-web", + &["java", "kotlin"], + &[ + "org.springframework:spring-web", + "org.springframework.boot:spring-boot", + ], + detect_java, + ), + source_pack("go-web", &["go"], &[], detect_go), + source_pack("rust-web", &["rust"], &[], detect_rust), + source_pack( + "aspnet-web", + &["csharp"], + &["microsoft.aspnetcore.app"], + detect_csharp, + ), + source_pack("vapor-routes", &["swift"], &["vapor"], detect_swift), + source_pack( + "typescript-web", + &["javascript", "typescript", "tsx"], + &[ + "express", + "@nestjs/common", + "react-router", + "react-router-dom", + "vue-router", + ], + detect_typescript, + ), + SourcePack { + id: "filesystem-routes", + languages: &["javascript", "typescript", "tsx"], + dependency_markers: &["@sveltejs/kit", "nuxt", "astro"], + manifest_policy: ManifestPolicy::Required, + detector: detect_file_routes, + }, + SourcePack { + id: "enterprise-domain-facts", + languages: &[ + "python", + "typescript", + "tsx", + "javascript", + "java", + "csharp", + "ruby", + "php", + "go", + "rust", + ], + dependency_markers: &[], + manifest_policy: ManifestPolicy::Advisory, + detector: detect_enterprise, + }, +]; + +const CONFIG_PACKS: &[ConfigPack] = &[ + ConfigPack { + id: "drupal-routing-config", + matcher: is_drupal_routing, + detector: php::detect_drupal_routing, + }, + ConfigPack { + id: "play-routes-config", + matcher: is_play_routes, + detector: play::detect, + }, +]; + +const TEMPLATE_PACKS: &[TemplatePack] = &[TemplatePack { + id: "filesystem-template-routes", + dependency_markers: &["@sveltejs/kit", "nuxt", "astro"], + manifest_policy: ManifestPolicy::Required, + detector: file_routes::detect, +}]; + +const fn source_pack( + id: &'static str, + languages: &'static [&'static str], + dependency_markers: &'static [&'static str], + detector: SourceDetector, +) -> SourcePack { + SourcePack { + id, + languages, + dependency_markers, + manifest_policy: ManifestPolicy::Advisory, + detector, + } +} pub(crate) fn detect( path: &Path, source: &[u8], root: Node<'_>, language: &str, + project: Option<&ProjectEvidence>, extraction: &mut Extraction, ) { - let facts = match language { - "python" => python::detect(path, source, root), - "php" => php::detect(path, source, root), - "ruby" => ruby::detect(path, source, root), - "java" | "kotlin" => java::detect(path, source, root), - "go" => go::detect(path, source, root), - "rust" => rust::detect(path, source, root), - "csharp" => csharp::detect(path, source, root), - "swift" => swift::detect(path, source, root), - "javascript" | "typescript" | "tsx" => { - let mut facts = typescript::detect(path, source, root, extraction); - facts.extend(file_routes::detect(path, source, extraction)); - facts - } - _ => Vec::new(), + let context = DetectionContext { + path, + source, + root, + language, + project, }; - let mut facts = facts; - facts.extend(enterprise::detect(path, source, language)); - if let Err(error) = FrameworkLimits::default().check_facts(facts.len()) { - extraction - .error - .get_or_insert_with(|| format!("framework extraction failed: {error}")); - return; + let mut facts = Vec::new(); + for pack in SOURCE_PACKS { + debug_assert!(!pack.id.is_empty()); + if pack.languages.contains(&language) && pack_enabled(pack, project) { + facts.extend((pack.detector)(&context, extraction)); + } } - extraction.framework_facts.extend(facts); + publish_facts(facts, extraction); } -pub(crate) fn detect_config_file(path: &Path, source: &[u8]) -> Extraction { +pub(crate) fn detect_config_file( + path: &Path, + source: &[u8], + _project: Option<&ProjectEvidence>, +) -> Extraction { let mut extraction = Extraction::default(); - let facts = if path - .file_name() - .and_then(|value| value.to_str()) - .is_some_and(|name| name.ends_with(".routing.yml") || name.ends_with(".routing.yaml")) - { - php::detect_drupal_routing(path, source) - } else { - play::detect(path, source) - }; - if let Err(error) = FrameworkLimits::default().check_facts(facts.len()) { - extraction.error = Some(format!("framework extraction failed: {error}")); - } else { - extraction.framework_facts = facts; - } + let facts = CONFIG_PACKS + .iter() + .find(|pack| (pack.matcher)(path)) + .map_or_else(Vec::new, |pack| { + debug_assert!(!pack.id.is_empty()); + (pack.detector)(path, source) + }); + publish_facts(facts, &mut extraction); extraction } -pub(crate) fn detect_template_file_route(path: &Path, source: &[u8], extraction: &mut Extraction) { - let facts = file_routes::detect(path, source, extraction); +pub(crate) fn detect_template_file_route( + path: &Path, + source: &[u8], + project: Option<&ProjectEvidence>, + extraction: &mut Extraction, +) { + let mut facts = Vec::new(); + for pack in TEMPLATE_PACKS { + debug_assert!(!pack.id.is_empty()); + if template_pack_enabled(pack, project) { + facts.extend((pack.detector)(path, source, project, extraction)); + } + } + publish_facts(facts, extraction); +} + +fn pack_enabled(pack: &SourcePack, project: Option<&ProjectEvidence>) -> bool { + manifest_policy_allows(pack.manifest_policy, pack.dependency_markers, project) +} + +fn template_pack_enabled(pack: &TemplatePack, project: Option<&ProjectEvidence>) -> bool { + manifest_policy_allows(pack.manifest_policy, pack.dependency_markers, project) +} + +fn manifest_policy_allows( + policy: ManifestPolicy, + dependency_markers: &[&str], + project: Option<&ProjectEvidence>, +) -> bool { + match policy { + ManifestPolicy::Advisory => true, + ManifestPolicy::Required => { + project.is_none_or(|project| project.has_any_dependency(dependency_markers)) + } + } +} + +fn publish_facts(facts: Vec, extraction: &mut Extraction) { if let Err(error) = FrameworkLimits::default().check_facts(facts.len()) { extraction .error @@ -88,3 +260,143 @@ pub(crate) fn detect_template_file_route(path: &Path, source: &[u8], extraction: } extraction.framework_facts.extend(facts); } + +fn detect_python( + context: &DetectionContext<'_, '_>, + _extraction: &mut Extraction, +) -> Vec { + python::detect(context.path, context.source, context.root) +} + +fn detect_php( + context: &DetectionContext<'_, '_>, + _extraction: &mut Extraction, +) -> Vec { + php::detect(context.path, context.source, context.root) +} + +fn detect_ruby( + context: &DetectionContext<'_, '_>, + _extraction: &mut Extraction, +) -> Vec { + ruby::detect(context.path, context.source, context.root) +} + +fn detect_java( + context: &DetectionContext<'_, '_>, + _extraction: &mut Extraction, +) -> Vec { + java::detect(context.path, context.source, context.root) +} + +fn detect_go( + context: &DetectionContext<'_, '_>, + _extraction: &mut Extraction, +) -> Vec { + go::detect(context.path, context.source, context.root) +} + +fn detect_rust( + context: &DetectionContext<'_, '_>, + _extraction: &mut Extraction, +) -> Vec { + rust::detect(context.path, context.source, context.root) +} + +fn detect_csharp( + context: &DetectionContext<'_, '_>, + _extraction: &mut Extraction, +) -> Vec { + csharp::detect(context.path, context.source, context.root) +} + +fn detect_swift( + context: &DetectionContext<'_, '_>, + _extraction: &mut Extraction, +) -> Vec { + swift::detect(context.path, context.source, context.root) +} + +fn detect_typescript( + context: &DetectionContext<'_, '_>, + extraction: &mut Extraction, +) -> Vec { + typescript::detect(context.path, context.source, context.root, extraction) +} + +fn detect_file_routes( + context: &DetectionContext<'_, '_>, + extraction: &mut Extraction, +) -> Vec { + file_routes::detect(context.path, context.source, context.project, extraction) +} + +fn detect_enterprise( + context: &DetectionContext<'_, '_>, + _extraction: &mut Extraction, +) -> Vec { + enterprise::detect(context.path, context.source, context.language) +} + +fn is_drupal_routing(path: &Path) -> bool { + path.file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| name.ends_with(".routing.yml") || name.ends_with(".routing.yaml")) +} + +fn is_play_routes(path: &Path) -> bool { + path.file_name().and_then(|name| name.to_str()) == Some("routes") + && path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + == Some("conf") +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::{CONFIG_PACKS, SOURCE_PACKS, TEMPLATE_PACKS}; + + #[test] + fn framework_pack_registry_ids_are_unique_and_well_formed() { + let mut ids = HashSet::new(); + for pack in SOURCE_PACKS { + assert!(!pack.id.is_empty()); + assert!(!pack.languages.is_empty()); + assert!(ids.insert(pack.id)); + } + for pack in CONFIG_PACKS { + assert!(!pack.id.is_empty()); + assert!(ids.insert(pack.id)); + } + for pack in TEMPLATE_PACKS { + assert!(!pack.id.is_empty()); + assert!(ids.insert(pack.id)); + } + } + + #[test] + fn source_registry_covers_every_existing_framework_module() { + let ids = SOURCE_PACKS + .iter() + .map(|pack| pack.id) + .collect::>(); + for expected in [ + "python-web", + "php-frameworks", + "rails-routes", + "spring-web", + "go-web", + "rust-web", + "aspnet-web", + "vapor-routes", + "typescript-web", + "filesystem-routes", + "enterprise-domain-facts", + ] { + assert!(ids.contains(expected), "missing framework pack {expected}"); + } + } +} diff --git a/crates/compass-languages/src/lib.rs b/crates/compass-languages/src/lib.rs index b0c1ed14..84946e98 100644 --- a/crates/compass-languages/src/lib.rs +++ b/crates/compass-languages/src/lib.rs @@ -31,6 +31,7 @@ mod pascal_forms; mod php; mod powershell; mod program; +mod project_evidence; mod r; mod registry; mod rust_lang; @@ -53,6 +54,9 @@ pub use frameworks::{ }; pub use ids::{file_stem, make_id, normalize_id}; pub use program::{TREE_SITTER_PROGRAM_PROVIDER_VERSION, TreeSitterSyntaxProvider}; +pub use project_evidence::{ + FRAMEWORK_PROJECT_EVIDENCE_EXTENSION, ProjectEvidence, ProjectEvidenceIndex, +}; pub use registry::{ExtractorKind, LanguageSpec, Registry}; pub use scip::{ScipExtraction, ingest_scip_json}; diff --git a/crates/compass-languages/src/project_evidence.rs b/crates/compass-languages/src/project_evidence.rs new file mode 100644 index 00000000..7faafa29 --- /dev/null +++ b/crates/compass-languages/src/project_evidence.rs @@ -0,0 +1,595 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use sha2::{Digest, Sha256}; + +pub const FRAMEWORK_PROJECT_EVIDENCE_EXTENSION: &str = "_compass_framework_project_evidence"; + +const EVIDENCE_SCHEMA: &str = "compass.framework-project-evidence/1"; +const MAX_MANIFEST_BYTES: u64 = 2 * 1024 * 1024; +const MAX_DEPENDENCIES_PER_PROJECT: usize = 10_000; +const FIXED_MANIFEST_NAMES: &[&str] = &[ + "package.json", + "composer.json", + "pyproject.toml", + "requirements.txt", + "requirements.in", + "Gemfile", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "Cargo.toml", + "go.mod", + "Package.swift", +]; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectEvidence { + project_root: PathBuf, + manifests: Vec, + ecosystems: Vec, + dependencies: BTreeSet, + fingerprint: String, +} + +impl ProjectEvidence { + #[must_use] + pub fn project_root(&self) -> &Path { + &self.project_root + } + + #[must_use] + pub fn manifests(&self) -> &[String] { + &self.manifests + } + + #[must_use] + pub fn ecosystems(&self) -> &[String] { + &self.ecosystems + } + + #[must_use] + pub fn dependencies(&self) -> &BTreeSet { + &self.dependencies + } + + #[must_use] + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + + #[must_use] + pub fn has_dependency(&self, dependency: &str) -> bool { + let dependency = normalize_dependency(dependency); + self.dependencies.contains(&dependency) + } + + #[must_use] + pub fn has_any_dependency(&self, dependencies: &[&str]) -> bool { + dependencies + .iter() + .any(|dependency| self.has_dependency(dependency)) + } +} + +#[derive(Clone, Debug)] +pub struct ProjectEvidenceIndex { + repository_root: PathBuf, + projects: BTreeMap, + fallback: ProjectEvidence, +} + +impl ProjectEvidenceIndex { + #[must_use] + pub fn build(repository_root: &Path, sources: &[PathBuf]) -> Self { + let repository_root = absolute_path(repository_root, repository_root); + let mut directories = BTreeSet::new(); + let mut manifests = BTreeSet::new(); + directories.insert(repository_root.clone()); + + for source in sources { + let source = absolute_path(&repository_root, source); + if is_recognized_manifest(&source) { + manifests.insert(source.clone()); + } + let directory = source.parent().unwrap_or(&repository_root); + for ancestor in directory.ancestors() { + if !ancestor.starts_with(&repository_root) { + break; + } + directories.insert(ancestor.to_path_buf()); + if ancestor == repository_root { + break; + } + } + } + + for directory in &directories { + for name in FIXED_MANIFEST_NAMES { + let candidate = directory.join(name); + if regular_manifest(&candidate) { + manifests.insert(candidate); + } + } + } + + let mut builders = BTreeMap::::new(); + for manifest in manifests { + let project_root = manifest.parent().unwrap_or(&repository_root).to_path_buf(); + let builder = builders.entry(project_root).or_default(); + builder.manifests.insert( + manifest + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_owned(), + ); + let Some(parsed) = parse_manifest(&manifest) else { + continue; + }; + builder.ecosystems.insert(parsed.ecosystem.to_owned()); + let remaining = MAX_DEPENDENCIES_PER_PROJECT.saturating_sub(builder.dependencies.len()); + builder + .dependencies + .extend(parsed.dependencies.into_iter().take(remaining)); + } + + let projects = builders + .into_iter() + .map(|(project_root, builder)| { + let evidence = finish_project(&repository_root, project_root.clone(), builder); + (project_root, evidence) + }) + .collect(); + let fallback = finish_project( + &repository_root, + repository_root.clone(), + ProjectBuilder::default(), + ); + Self { + repository_root, + projects, + fallback, + } + } + + #[must_use] + pub fn evidence_for(&self, path: &Path) -> &ProjectEvidence { + let path = absolute_path(&self.repository_root, path); + let directory = if path.is_dir() { + path.as_path() + } else { + path.parent().unwrap_or(&self.repository_root) + }; + directory + .ancestors() + .take_while(|ancestor| ancestor.starts_with(&self.repository_root)) + .find_map(|ancestor| self.projects.get(ancestor)) + .unwrap_or(&self.fallback) + } + + #[must_use] + pub fn fingerprint_for(&self, path: &Path) -> &str { + self.evidence_for(path).fingerprint() + } + + #[must_use] + pub fn project_count(&self) -> usize { + self.projects.len() + } +} + +#[derive(Default)] +struct ProjectBuilder { + manifests: BTreeSet, + ecosystems: BTreeSet, + dependencies: BTreeSet, +} + +struct ParsedManifest { + ecosystem: &'static str, + dependencies: BTreeSet, +} + +fn finish_project( + repository_root: &Path, + project_root: PathBuf, + builder: ProjectBuilder, +) -> ProjectEvidence { + let manifests = builder.manifests.into_iter().collect::>(); + let ecosystems = builder.ecosystems.into_iter().collect::>(); + let dependencies = builder.dependencies; + let relative_root = project_root + .strip_prefix(repository_root) + .unwrap_or(&project_root) + .to_string_lossy() + .replace('\\', "/"); + let mut digest = Sha256::new(); + digest.update(EVIDENCE_SCHEMA.as_bytes()); + digest.update([0]); + digest.update(relative_root.as_bytes()); + digest.update([0]); + for manifest in &manifests { + digest.update(manifest.as_bytes()); + digest.update([0]); + } + for ecosystem in &ecosystems { + digest.update(ecosystem.as_bytes()); + digest.update([0]); + } + for dependency in &dependencies { + digest.update(dependency.as_bytes()); + digest.update([0]); + } + ProjectEvidence { + project_root, + manifests, + ecosystems, + dependencies, + fingerprint: format!("sha256:{:x}", digest.finalize()), + } +} + +fn parse_manifest(path: &Path) -> Option { + let metadata = fs::symlink_metadata(path).ok()?; + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + return None; + } + if metadata.len() > MAX_MANIFEST_BYTES { + return None; + } + let source = fs::read_to_string(path).ok()?; + let name = path.file_name()?.to_str()?; + let lower = name.to_ascii_lowercase(); + let (ecosystem, dependencies) = match lower.as_str() { + "package.json" => ("npm", json_dependencies(&source, NPM_DEPENDENCY_KEYS)?), + "composer.json" => ( + "composer", + json_dependencies(&source, &["require", "require-dev"])?, + ), + "pyproject.toml" => ("python", pyproject_dependencies(&source)?), + "requirements.txt" | "requirements.in" => ("python", requirements_dependencies(&source)), + "gemfile" => ("ruby", gemfile_dependencies(&source)), + "pom.xml" => ("maven", pom_dependencies(&source)?), + "build.gradle" | "build.gradle.kts" => ("gradle", gradle_dependencies(&source)), + "cargo.toml" => ("cargo", cargo_dependencies(&source)?), + "go.mod" => ("go", go_mod_dependencies(&source)), + "package.swift" => ("swift", swift_package_dependencies(&source)), + _ if lower.ends_with(".csproj") => ("dotnet", csproj_dependencies(&source)?), + _ => return None, + }; + Some(ParsedManifest { + ecosystem, + dependencies: dependencies + .into_iter() + .map(|dependency| normalize_dependency(&dependency)) + .filter(|dependency| !dependency.is_empty()) + .take(MAX_DEPENDENCIES_PER_PROJECT) + .collect(), + }) +} + +const NPM_DEPENDENCY_KEYS: &[&str] = &[ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +]; + +fn json_dependencies(source: &str, keys: &[&str]) -> Option> { + let root = serde_json::from_str::(source).ok()?; + let object = root.as_object()?; + Some( + keys.iter() + .filter_map(|key| object.get(*key).and_then(Value::as_object)) + .flat_map(|dependencies| dependencies.keys().cloned()) + .collect(), + ) +} + +fn pyproject_dependencies(source: &str) -> Option> { + let root = toml::from_str::(source).ok()?; + let mut dependencies = root + .get("project") + .and_then(toml::Value::as_table) + .and_then(|project| project.get("dependencies")) + .and_then(toml::Value::as_array) + .into_iter() + .flatten() + .filter_map(toml::Value::as_str) + .map(python_requirement_name) + .collect::>(); + if let Some(poetry) = root + .get("tool") + .and_then(toml::Value::as_table) + .and_then(|tool| tool.get("poetry")) + .and_then(toml::Value::as_table) + .and_then(|poetry| poetry.get("dependencies")) + .and_then(toml::Value::as_table) + { + dependencies.extend( + poetry + .keys() + .filter(|dependency| !dependency.eq_ignore_ascii_case("python")) + .cloned(), + ); + } + Some(dependencies) +} + +fn requirements_dependencies(source: &str) -> Vec { + source + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with(['#', '-'])) + .map(python_requirement_name) + .collect() +} + +fn python_requirement_name(value: &str) -> String { + value + .split(|character: char| { + character.is_whitespace() + || matches!(character, '<' | '>' | '=' | '!' | '~' | ';' | '[' | '(') + }) + .next() + .unwrap_or_default() + .to_owned() +} + +fn gemfile_dependencies(source: &str) -> Vec { + source + .lines() + .map(str::trim) + .filter_map(|line| line.strip_prefix("gem").map(str::trim_start)) + .filter_map(first_quoted) + .collect() +} + +fn pom_dependencies(source: &str) -> Option> { + let document = roxmltree::Document::parse(source).ok()?; + Some( + document + .descendants() + .filter(|node| node.is_element() && node.tag_name().name() == "dependency") + .filter_map(|dependency| { + let child = |name: &str| { + dependency + .children() + .find(|node| node.is_element() && node.tag_name().name() == name) + .and_then(|node| node.text()) + }; + let artifact = child("artifactId")?; + Some(child("groupId").map_or_else( + || artifact.to_owned(), + |group| format!("{group}:{artifact}"), + )) + }) + .collect(), + ) +} + +fn gradle_dependencies(source: &str) -> Vec { + quoted_values(source) + .filter(|value| value.matches(':').count() >= 1) + .filter_map(|value| { + let mut parts = value.split(':'); + let group = parts.next()?; + let artifact = parts.next()?; + (!group.is_empty() && !artifact.is_empty()).then(|| format!("{group}:{artifact}")) + }) + .collect() +} + +fn cargo_dependencies(source: &str) -> Option> { + let root = toml::from_str::(source).ok()?; + let mut dependencies = Vec::new(); + collect_cargo_dependency_table(&root, &mut dependencies); + if let Some(targets) = root.get("target").and_then(toml::Value::as_table) { + for target in targets.values().filter_map(toml::Value::as_table) { + collect_cargo_dependency_table(target, &mut dependencies); + } + } + Some(dependencies) +} + +fn collect_cargo_dependency_table(table: &toml::Table, dependencies: &mut Vec) { + for key in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(values) = table.get(key).and_then(toml::Value::as_table) { + dependencies.extend(values.keys().cloned()); + } + } +} + +fn go_mod_dependencies(source: &str) -> Vec { + let mut dependencies = Vec::new(); + let mut in_require = false; + for line in source.lines().map(str::trim) { + if line == "require (" { + in_require = true; + continue; + } + if in_require && line == ")" { + in_require = false; + continue; + } + let requirement = if in_require { + line + } else if let Some(requirement) = line.strip_prefix("require ") { + requirement + } else { + continue; + }; + if let Some(dependency) = requirement.split_whitespace().next() { + dependencies.push(dependency.to_owned()); + } + } + dependencies +} + +fn csproj_dependencies(source: &str) -> Option> { + let document = roxmltree::Document::parse(source).ok()?; + Some( + document + .descendants() + .filter(|node| node.is_element() && node.tag_name().name() == "PackageReference") + .filter_map(|node| { + node.attribute("Include") + .or_else(|| node.attribute("Update")) + }) + .map(str::to_owned) + .collect(), + ) +} + +fn swift_package_dependencies(source: &str) -> Vec { + quoted_values(source) + .filter(|value| value.contains("://") || value.ends_with(".git")) + .filter_map(|value| { + value + .trim_end_matches('/') + .rsplit('/') + .next() + .map(|name| name.trim_end_matches(".git").to_owned()) + }) + .collect() +} + +fn first_quoted(value: &str) -> Option { + quoted_values(value).next() +} + +fn quoted_values(value: &str) -> impl Iterator + '_ { + let mut rest = value; + std::iter::from_fn(move || { + let start = rest.find(['\'', '"'])?; + let quote = rest.as_bytes()[start]; + let after = &rest[start + 1..]; + let end = after.as_bytes().iter().position(|byte| *byte == quote)?; + let result = after[..end].to_owned(); + rest = &after[end + 1..]; + Some(result) + }) +} + +fn normalize_dependency(value: &str) -> String { + value.trim().to_ascii_lowercase().replace('_', "-") +} + +fn is_recognized_manifest(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + FIXED_MANIFEST_NAMES + .iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) + || name.to_ascii_lowercase().ends_with(".csproj") +} + +fn regular_manifest(path: &Path) -> bool { + is_recognized_manifest(path) + && fs::symlink_metadata(path).is_ok_and(|metadata| { + metadata.file_type().is_file() && !metadata.file_type().is_symlink() + }) +} + +fn absolute_path(root: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + } +} + +#[cfg(test)] +mod tests { + use std::error::Error; + use std::fs; + + use tempfile::tempdir; + + use super::ProjectEvidenceIndex; + + #[test] + fn nearest_project_merges_manifests_and_is_deterministic() -> Result<(), Box> { + let directory = tempdir()?; + let root = directory.path(); + fs::create_dir_all(root.join("apps/web/src"))?; + fs::write( + root.join("package.json"), + r#"{"dependencies":{"astro":"5.0.0"}}"#, + )?; + fs::write( + root.join("apps/web/package.json"), + r#"{"dependencies":{"nuxt":"4.0.0","@scope/example":"1.0.0"}}"#, + )?; + fs::write( + root.join("apps/web/composer.json"), + r#"{"require":{"laravel/framework":"^12"}}"#, + )?; + let source = root.join("apps/web/src/page.ts"); + fs::write(&source, "export default {}")?; + + let first = ProjectEvidenceIndex::build(root, std::slice::from_ref(&source)); + let second = ProjectEvidenceIndex::build(root, std::slice::from_ref(&source)); + let evidence = first.evidence_for(&source); + + assert_eq!(first.project_count(), 2); + assert_eq!(evidence.project_root(), root.join("apps/web")); + assert!(evidence.has_dependency("nuxt")); + assert!(evidence.has_dependency("@scope/example")); + assert!(evidence.has_dependency("laravel/framework")); + assert!(!evidence.has_dependency("astro")); + assert_eq!( + evidence.fingerprint(), + second.evidence_for(&source).fingerprint() + ); + Ok(()) + } + + #[test] + fn fallback_fingerprint_changes_when_a_project_manifest_appears() -> Result<(), Box> + { + let directory = tempdir()?; + let root = directory.path(); + fs::create_dir_all(root.join("src"))?; + let source = root.join("src/app.ts"); + fs::write(&source, "")?; + let without = ProjectEvidenceIndex::build(root, std::slice::from_ref(&source)); + let old = without.fingerprint_for(&source).to_owned(); + + fs::write( + root.join("package.json"), + r#"{"dependencies":{"@sveltejs/kit":"2.0.0"}}"#, + )?; + let with = ProjectEvidenceIndex::build(root, std::slice::from_ref(&source)); + + assert_ne!(old, with.fingerprint_for(&source)); + assert!(with.evidence_for(&source).has_dependency("@sveltejs/kit")); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn symlinked_manifests_do_not_contribute_evidence() -> Result<(), Box> { + use std::os::unix::fs::symlink; + + let directory = tempdir()?; + let root = directory.path(); + fs::create_dir_all(root.join("src"))?; + fs::write( + root.join("outside.json"), + r#"{"dependencies":{"nuxt":"4"}}"#, + )?; + symlink(root.join("outside.json"), root.join("package.json"))?; + let source = root.join("src/app.ts"); + fs::write(&source, "")?; + + let index = ProjectEvidenceIndex::build(root, std::slice::from_ref(&source)); + + assert!(!index.evidence_for(&source).has_dependency("nuxt")); + Ok(()) + } +} diff --git a/crates/compass-resolve/tests/typescript_routes.rs b/crates/compass-resolve/tests/typescript_routes.rs index 7b6b774f..d2f42d50 100644 --- a/crates/compass-resolve/tests/typescript_routes.rs +++ b/crates/compass-resolve/tests/typescript_routes.rs @@ -1,9 +1,11 @@ use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Arc; use compass_languages::{ - Engine, Extraction, FrameworkLimits, RawFrameworkFact, RawNodeRecord, make_id, + Engine, Extraction, FrameworkLimits, ProjectEvidenceIndex, RawFrameworkFact, RawNodeRecord, + make_id, }; use compass_model::provenance::ResolutionState; use compass_resolve::frameworks::{RouteStageRole, resolve_and_publish_framework_routes}; @@ -342,3 +344,61 @@ fn nuxt_route_middleware_is_a_separate_domain_fact() -> Result<(), Box Result<(), Box> { + for (dependency, relative_path, source, expected_framework) in [ + ( + "@sveltejs/kit", + "src/routes/users/[id]/+page.svelte", + "

User

", + "sveltekit", + ), + ( + "nuxt", + "pages/users/[id].vue", + "", + "nuxt", + ), + ( + "astro", + "src/pages/users/[id].astro", + "

User

", + "astro", + ), + ] { + let directory = tempfile::tempdir()?; + let route = directory.path().join(relative_path); + fs::create_dir_all(route.parent().ok_or("route has no parent")?)?; + fs::write(&route, source)?; + fs::write( + directory.path().join("package.json"), + format!(r#"{{"dependencies":{{"{dependency}":"1.0.0"}}}}"#), + )?; + + let evidence = ProjectEvidenceIndex::build(directory.path(), std::slice::from_ref(&route)); + let extraction = Engine::with_project_evidence(Arc::new(evidence)).extract(&route)?; + assert!( + routes(&extraction).any(|route| route.framework == expected_framework), + "{relative_path} should activate {expected_framework}" + ); + } + + let directory = tempfile::tempdir()?; + let route = directory.path().join("src/routes/users/[id]/+page.svelte"); + fs::create_dir_all(route.parent().ok_or("route has no parent")?)?; + fs::write(&route, "

User

")?; + fs::write( + directory.path().join("package.json"), + r#"{"dependencies":{"nuxt":"1.0.0"}}"#, + )?; + let evidence = ProjectEvidenceIndex::build(directory.path(), std::slice::from_ref(&route)); + let extraction = Engine::with_project_evidence(Arc::new(evidence)).extract(&route)?; + assert_eq!( + routes(&extraction).count(), + 0, + "an unrelated framework dependency must not activate a SvelteKit file route" + ); + Ok(()) +}