diff --git a/crates/compass-languages/src/evidence/build.rs b/crates/compass-languages/src/evidence/build.rs index 0adaee1b..14abbc98 100644 --- a/crates/compass-languages/src/evidence/build.rs +++ b/crates/compass-languages/src/evidence/build.rs @@ -8867,7 +8867,11 @@ fn python_call_argument_shape(call: Node<'_>) -> (Option, Vec>(); if arguments .iter() diff --git a/crates/compass-languages/src/frameworks/python.rs b/crates/compass-languages/src/frameworks/python.rs index 1c2e5d6a..102809fb 100644 --- a/crates/compass-languages/src/frameworks/python.rs +++ b/crates/compass-languages/src/frameworks/python.rs @@ -2888,7 +2888,14 @@ fn collect_decorated_routes( handler, u32::try_from(stages.len()).unwrap_or(u32::MAX), )); - append_dependency_graph_facts(facts, receiver.framework, handler, &stages, "route"); + append_dependency_graph_facts( + facts, + receiver.framework, + handler, + &stages, + "route", + evidence, + ); append_handler_schema_relations( facts, handler, decorator, &arguments, source, evidence, path, ); @@ -3392,7 +3399,14 @@ fn push_receiver_route_facts( |handler| handler_stage(handler, u32::try_from(stages.len()).unwrap_or(u32::MAX)), )); if let Some(handler) = handler { - append_dependency_graph_facts(facts, receiver.framework, handler, &stages, "route"); + append_dependency_graph_facts( + facts, + receiver.framework, + handler, + &stages, + "route", + evidence, + ); append_handler_schema_relations( facts, handler, route_node, arguments, source, evidence, path, ); @@ -3871,12 +3885,20 @@ fn operations(framework: &str, method: &str, arguments: &[String]) -> Vec { Some(method.to_ascii_uppercase()) } + "websocket" | "websocket_route" => Some("WEBSOCKET".to_owned()), "api_route" | "route" => None, _ => return Vec::new(), }; return operation .map(|operation| vec![operation]) - .unwrap_or_else(|| keyword_string_list(arguments, "methods")); + .unwrap_or_else(|| { + let methods = keyword_string_list(arguments, "methods"); + if methods.is_empty() { + vec!["GET".to_owned()] + } else { + methods + } + }); } if framework == "starlette" { let operation = match method { @@ -3953,6 +3975,22 @@ fn collect_dependency_stages( evidence: &SemanticEvidenceBatch, stages: &mut Vec, ) { + if let Some(mut stage) = direct_dependency_stage(node, source, path, evidence) { + stage.position = u32::try_from(stages.len()).unwrap_or(u32::MAX); + stages.push(stage); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + collect_dependency_stages(child, source, path, evidence, stages); + } +} + +fn direct_dependency_stage( + node: Node<'_>, + source: &[u8], + path: &Path, + evidence: &SemanticEvidenceBatch, +) -> Option { if node.kind() == "call" && let Some(function) = node.child_by_field_name("function") && let Some(target) = exact_call_target(evidence, function) @@ -3979,23 +4017,20 @@ fn collect_dependency_stages( { detail.insert("scopes".into(), Value::String(scopes)); } - stages.push(RawRouteStageFact { + return Some(RawRouteStageFact { role: if target == "fastapi.Security" { RawRouteStageRole::Security } else { RawRouteStageRole::Dependency }, - position: u32::try_from(stages.len()).unwrap_or(u32::MAX), + position: 0, reference, anchor: anchor(path, node), origin: RawFrameworkOrigin::Ast, detail, }); } - let mut cursor = node.walk(); - for child in node.children(&mut cursor).filter(|child| child.is_named()) { - collect_dependency_stages(child, source, path, evidence, stages); - } + None } fn ordered_stages(groups: Vec>) -> Vec { @@ -4023,7 +4058,10 @@ fn handler_stage(handler: &DeclarationFact, position: u32) -> RawRouteStageFact RawRouteStageFact { role: RawRouteStageRole::Handler, position, - reference: handler.name.clone(), + // Decorator ownership already selected one exact declaration. Keep + // that stable graph identity through route resolution instead of + // discarding it and performing a second project-wide name lookup. + reference: handler.graph_node_id.clone(), anchor: evidence_anchor(&handler.range), origin: RawFrameworkOrigin::Ast, detail: Map::from_iter([("declaration_id".into(), Value::String(handler.id.clone()))]), @@ -4036,6 +4074,7 @@ fn append_dependency_graph_facts( source: &DeclarationFact, stages: &[RawRouteStageFact], context: &str, + evidence: &SemanticEvidenceBatch, ) { if framework != "fastapi" { return; @@ -4070,7 +4109,8 @@ fn append_dependency_graph_facts( target_hint: Some(stage.reference.clone()), context: Some(context.to_owned()), anchor: stage.anchor.clone(), - target_anchor: Some(stage.anchor.clone()), + target_anchor: unique_declaration_for_reference(evidence, &stage.reference) + .map(|declaration| evidence_anchor(&declaration.range)), origin: RawFrameworkOrigin::Ast, evidence_class: "exact".to_owned(), ambiguity_policy: "require_exact".to_owned(), @@ -4083,20 +4123,39 @@ fn collect_fastapi_provider_facts( context: &UniversalDetectionContext<'_, '_>, facts: &mut Vec, ) { - for declaration in context - .evidence - .declarations - .iter() - .filter(|declaration| matches!(declaration.kind.as_str(), "function" | "method")) + collect_fastapi_dependency_expression_facts(context.root, context, facts); +} + +fn collect_fastapi_dependency_expression_facts( + node: Node<'_>, + context: &UniversalDetectionContext<'_, '_>, + facts: &mut Vec, +) { + if let Some(stage) = + direct_dependency_stage(node, context.source, context.path, context.evidence) + && let Some(function) = node.child_by_field_name("function") + && let Some(owner) = call_owner_declaration(function, context.evidence) { - let Some(definition) = declaration_node(context.root, declaration) else { - continue; - }; - let Some(parameters) = definition.child_by_field_name("parameters") else { - continue; + let relation_context = if ancestors_before_definition(node) + .iter() + .any(|ancestor| ancestor.kind() == "parameters") + { + "subdependency" + } else { + "dependency_expression" }; - let stages = dependency_stages(parameters, context.source, context.path, context.evidence); - append_dependency_graph_facts(facts, "fastapi", declaration, &stages, "subdependency"); + append_dependency_graph_facts( + facts, + "fastapi", + owner, + std::slice::from_ref(&stage), + relation_context, + context.evidence, + ); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + collect_fastapi_dependency_expression_facts(child, context, facts); } } @@ -4824,60 +4883,119 @@ fn exact_static_reference_hint( let in_signature = ancestors_before_definition(node) .iter() .any(|ancestor| ancestor.kind() == "parameters"); - let reference_scope = reference_evaluation_scope(evidence, node, in_signature); + let mut reference_scope = reference_evaluation_scope(evidence, node, in_signature); if is_identifier(reference) { + while let Some(scope_id) = reference_scope { + let declarations = evidence + .declarations + .iter() + .filter(|declaration| { + declaration.name == reference + && declaration.range.start_byte < node.start_byte() as u64 + && declaration.scope_id.as_deref() == Some(scope_id) + && !(in_signature && declaration.kind == "parameter") + }) + .collect::>(); + let bindings = evidence + .bindings + .iter() + .filter(|binding| { + matches!(binding.kind, BindingKind::Import | BindingKind::ImportAlias) + && binding.spelling == reference + && binding.range.end_byte <= node.start_byte() as u64 + && binding.scope_id.as_deref() == Some(scope_id) + }) + .collect::>(); + if !declarations.is_empty() || !bindings.is_empty() { + return match (declarations.as_slice(), bindings.as_slice()) { + ([declaration], []) + if (matches!( + declaration.kind.as_str(), + "function" | "method" | "class" + ) || exact_dependency_instance_declaration(evidence, declaration)) + && !has_intervening_python_rebinding( + context_root(node), + declaration, + reference, + node.start_byte() as u64, + source, + ) => + { + Some(declaration.graph_node_id.clone()) + } + ([], [binding]) => Some(binding.qualified_target.clone()), + _ => None, + }; + } + reference_scope = evidence + .scopes + .iter() + .find(|scope| scope.id == scope_id) + .and_then(|scope| scope.parent_scope_id.as_deref()); + } + return None; + } + let (head, suffix) = reference.split_once('.')?; + while let Some(scope_id) = reference_scope { let declarations = evidence .declarations .iter() .filter(|declaration| { - declaration.name == reference + declaration.name == head && declaration.range.start_byte < node.start_byte() as u64 - && declaration.scope_id.as_deref() == reference_scope - && !(in_signature && declaration.kind == "parameter") + && declaration.scope_id.as_deref() == Some(scope_id) }) - .collect::>(); + .count(); let bindings = evidence .bindings .iter() .filter(|binding| { matches!(binding.kind, BindingKind::Import | BindingKind::ImportAlias) - && binding.spelling == reference + && binding.spelling == head && binding.range.end_byte <= node.start_byte() as u64 - && binding.scope_id.as_deref() == reference_scope + && binding.scope_id.as_deref() == Some(scope_id) }) - .collect::>(); - return match (declarations.as_slice(), bindings.as_slice()) { - ([declaration], []) - if matches!(declaration.kind.as_str(), "function" | "method" | "class") - && !has_intervening_python_rebinding( - context_root(node), - declaration, - reference, - node.start_byte() as u64, - source, - ) => - { - Some(declaration.graph_node_id.clone()) - } - ([], [binding]) => Some(binding.qualified_target.clone()), - _ => None, - }; + .map(|binding| format!("{}.{}", binding.qualified_target, suffix)) + .collect::>(); + if declarations > 0 || !bindings.is_empty() { + return (declarations == 0 && bindings.len() == 1) + .then(|| bindings.first().cloned()) + .flatten(); + } + reference_scope = evidence + .scopes + .iter() + .find(|scope| scope.id == scope_id) + .and_then(|scope| scope.parent_scope_id.as_deref()); } - let (head, suffix) = reference.split_once('.')?; - let bindings = evidence - .bindings + None +} + +fn exact_dependency_instance_declaration( + evidence: &SemanticEvidenceBatch, + declaration: &DeclarationFact, +) -> bool { + if declaration.kind != "variable" { + return false; + } + let types = evidence + .candidates .iter() - .filter(|binding| { - matches!(binding.kind, BindingKind::Import | BindingKind::ImportAlias) - && binding.spelling == head - && binding.range.end_byte <= node.start_byte() as u64 - && binding.scope_id.as_deref() == reference_scope + .filter(|candidate| { + candidate.relation == CandidateRelation::TypeOf + && candidate.source_declaration_id == declaration.id + }) + .map(|candidate| { + ( + candidate.constraints.exact_target_declaration_id.as_deref(), + candidate.constraints.qualified_name.as_deref(), + ) }) - .map(|binding| format!("{}.{}", binding.qualified_target, suffix)) .collect::>(); - (bindings.len() == 1) - .then(|| bindings.first().cloned()) - .flatten() + types.len() == 1 + && types + .first() + .is_some_and(|(declaration, qualified)| declaration.is_some() || qualified.is_some()) } fn exact_import_reference_hint( diff --git a/crates/compass-languages/tests/python_framework_universal_packs.rs b/crates/compass-languages/tests/python_framework_universal_packs.rs index b17a0cf0..8b4f5a8c 100644 --- a/crates/compass-languages/tests/python_framework_universal_packs.rs +++ b/crates/compass-languages/tests/python_framework_universal_packs.rs @@ -755,6 +755,57 @@ app.register_blueprint(api, url_prefix="/api") Ok(()) } +#[test] +fn fastapi_websocket_and_api_route_decorators_preserve_exact_handlers() -> Result<(), Box> +{ + let extraction = extract( + "api/routes.py", + br#"from fastapi import FastAPI + +app = FastAPI() + +@app.websocket("/events") +async def events(): + return None + +@app.api_route("/default") +def default_route(): + return None + +@app.api_route("/explicit", methods=["POST", "PATCH"]) +def explicit_route(): + return None +"#, + )?; + let routes = routes(&extraction); + assert!(routes.iter().any(|route| { + route.operation == "WEBSOCKET" + && route.normalized_path == "/events" + && route.handler_reference == "events" + })); + assert!(routes.iter().any(|route| { + route.operation == "GET" + && route.normalized_path == "/default" + && route.handler_reference == "default_route" + })); + let explicit = routes + .iter() + .filter(|route| route.normalized_path == "/explicit") + .map(|route| route.operation.as_str()) + .collect::>(); + assert_eq!( + explicit, + std::collections::BTreeSet::from(["PATCH", "POST"]) + ); + assert!(routes.iter().all(|route| { + route + .stages + .last() + .is_some_and(|stage| stage.detail.contains_key("declaration_id")) + })); + Ok(()) +} + #[test] fn flask_factories_shortcuts_url_rules_method_views_nested_blueprints_and_hooks_are_exact() -> Result<(), Box> { diff --git a/crates/compass-model/src/validation.rs b/crates/compass-model/src/validation.rs index 40baff7d..20806728 100644 --- a/crates/compass-model/src/validation.rs +++ b/crates/compass-model/src/validation.rs @@ -996,16 +996,20 @@ fn endpoint_kinds_are_valid( | NodeKind::Class | NodeKind::Component ) - // JavaScript/TypeScript frameworks commonly expose a route - // handler through an alias (`export const GET = handler`, - // `const Page = withData(...)`). Preserve the structural - // `variable` kind while accepting it only after the route - // resolver has explicitly promoted the node to the typed - // route-handler role; arbitrary variables must remain - // invalid route targets. + // Frameworks commonly expose a route handler, dependency, or + // security component through a source-backed variable. Keep + // the structural `variable` kind while accepting it only + // after the route resolver has explicitly promoted the node + // to the corresponding typed role; arbitrary variables must + // remain invalid route targets. || (source.kind == NodeKind::Route && target.kind == NodeKind::Variable - && target.roles.contains(&NodeRole::RouteHandler)) + && target.roles.iter().any(|role| { + matches!( + role, + NodeRole::RouteHandler | NodeRole::Service | NodeRole::Middleware + ) + })) } EdgeKind::MapsTo => { matches!( @@ -1541,6 +1545,10 @@ const fn is_dependency_endpoint(kind: NodeKind) -> bool { NodeKind::File | NodeKind::Import | NodeKind::Export + // A dependency-injection marker can name a source-backed + // callable instance directly. The variable remains the + // truthful graph identity of that configured instance. + | NodeKind::Variable | NodeKind::TypeAlias | NodeKind::Resource | NodeKind::Schema diff --git a/crates/compass-model/tests/code_graph_validation.rs b/crates/compass-model/tests/code_graph_validation.rs index ff832ad7..6f140f96 100644 --- a/crates/compass-model/tests/code_graph_validation.rs +++ b/crates/compass-model/tests/code_graph_validation.rs @@ -132,6 +132,19 @@ fn route_validation_accepts_role_promoted_javascript_alias_targets() { assert!(validate_code_graph(&graph).is_err()); } +#[test] +fn route_validation_accepts_role_promoted_dependency_instance_targets() { + for role in [NodeRole::Service, NodeRole::Middleware] { + let mut graph = document(); + graph.nodes[1].kind = NodeKind::Variable; + graph.nodes[1].roles = vec![role]; + assert!( + validate_code_graph(&graph).is_ok(), + "role-promoted {role:?} variable was rejected" + ); + } +} + #[test] fn structured_validation_classifies_document_node_and_edge_failures() -> Result<(), Box> { @@ -574,6 +587,25 @@ fn endpoint_matrix_rejects_invalid_pairs_across_relationship_families() { } } +#[test] +fn dependency_edges_accept_source_backed_callable_instance_variables() { + let mut graph = document(); + graph.nodes[0].kind = NodeKind::Function; + graph.nodes[1].kind = NodeKind::Variable; + graph.links[0].kind = EdgeKind::DependsOn; + let id = edge_id( + "route", + EdgeKind::DependsOn, + "handler", + Some(&anchor()), + None, + ); + graph.links[0].id.clone_from(&id); + graph.links[0].key = id; + + assert!(validate_code_graph(&graph).is_ok()); +} + #[test] fn endpoint_matrix_closes_relationships_that_require_both_endpoint_shapes() { for (kind, source_kind, source_roles, target_kind) in [ diff --git a/crates/compass-resolve/src/frameworks/relations.rs b/crates/compass-resolve/src/frameworks/relations.rs index 7e1157c8..77721586 100644 --- a/crates/compass-resolve/src/frameworks/relations.rs +++ b/crates/compass-resolve/src/frameworks/relations.rs @@ -73,7 +73,7 @@ pub fn resolve_and_publish( })); continue; }; - let (source_candidates, source_truncated) = resolve_hint( + let (source_candidates, source_truncated) = resolve_source_hint( fact.source_reference.as_deref(), &fact.anchor, &targets, @@ -459,6 +459,28 @@ fn normalize_relation(value: &str) -> Option<&'static str> { } } +fn resolve_source_hint( + hint: Option<&str>, + anchor: &RawFrameworkAnchor, + targets: &FrameworkTargetIndex<'_>, + limit: usize, + root: Option<&Path>, +) -> (Vec, bool) { + if let Some(reference) = hint.map(str::trim).filter(|hint| !hint.is_empty()) { + let (nodes, truncated) = targets.exact_node(reference, limit); + if !nodes.is_empty() || truncated { + return ( + nodes + .into_iter() + .map(|node| candidate(node.id.clone(), node, "exact relation source id")) + .collect(), + truncated, + ); + } + } + resolve_hint(hint, anchor, targets, limit, root) +} + fn resolve_hint( hint: Option<&str>, anchor: &RawFrameworkAnchor, @@ -469,6 +491,7 @@ fn resolve_hint( let families = [ TargetFamily::Route, TargetFamily::Callable, + TargetFamily::Dependency, TargetFamily::Type, TargetFamily::DatabaseTable, ]; diff --git a/crates/compass-resolve/src/frameworks/target_index.rs b/crates/compass-resolve/src/frameworks/target_index.rs index 27dfb6e7..043c0258 100644 --- a/crates/compass-resolve/src/frameworks/target_index.rs +++ b/crates/compass-resolve/src/frameworks/target_index.rs @@ -8,6 +8,7 @@ use serde_json::Value; pub(super) enum TargetFamily { Route, Callable, + Dependency, Type, DatabaseTable, } @@ -471,6 +472,7 @@ fn bounded_union_measured<'a>( fn target_families(node: &RawNodeRecord) -> &'static [TargetFamily] { const ROUTE_CALLABLE: &[TargetFamily] = &[TargetFamily::Route, TargetFamily::Callable]; + const ROUTE_DEPENDENCY: &[TargetFamily] = &[TargetFamily::Route, TargetFamily::Dependency]; const ROUTE_TYPE: &[TargetFamily] = &[TargetFamily::Route, TargetFamily::Type]; const ROUTE: &[TargetFamily] = &[TargetFamily::Route]; const TYPE: &[TargetFamily] = &[TargetFamily::Type]; @@ -520,7 +522,7 @@ fn target_families(node: &RawNodeRecord) -> &'static [TargetFamily] { // variable (`const Page = withData(...)`). A same-source route // reference is bounded and deterministic, so variables belong to the // route target family as well. - Some("variable") => ROUTE, + Some("variable") => ROUTE_DEPENDENCY, Some("class") => ROUTE_TYPE, Some("component") => ROUTE, Some("struct" | "interface" | "trait" | "protocol" | "enum") => TYPE, diff --git a/crates/compass-resolve/tests/python_frameworks_universal.rs b/crates/compass-resolve/tests/python_frameworks_universal.rs index 98e88997..e08418ce 100644 --- a/crates/compass-resolve/tests/python_frameworks_universal.rs +++ b/crates/compass-resolve/tests/python_frameworks_universal.rs @@ -73,6 +73,124 @@ app.include_router(middle, prefix="/v2") Ok(()) } +#[test] +fn nested_fastapi_routes_and_dependencies_use_exact_lexical_identities() +-> Result<(), Box> { + let dependencies = br#"def dependency_c(): + return "c" +"#; + let routes = br#"from fastapi import Depends, FastAPI +import pkg.dependencies as module + +class Checker: + def __call__(self): + return True + +checker = Checker() + +def first_app(): + app = FastAPI() + + @app.websocket("/first") + async def endpoint(value=Depends(checker)): + return value + + return app + +def second_app(): + app = FastAPI() + + @app.api_route("/second") + def endpoint(value=Depends(module.dependency_c)): + return value + + return app +"#; + let extraction = resolved_project(&[ + ("pkg/dependencies.py", dependencies), + ("pkg/routes.py", routes), + ])?; + let resolved = resolve_routes(&extraction, FrameworkLimits::default())?; + assert_eq!(resolved.len(), 2, "routes={resolved:#?}"); + assert!( + resolved + .iter() + .all(|route| route.state == ResolutionState::Exact) + ); + let handler_targets = resolved + .iter() + .filter_map(|route| { + route + .stages + .iter() + .find(|stage| stage.role == RouteStageRole::Handler) + .and_then(|stage| stage.target.as_deref()) + }) + .collect::>(); + assert_eq!(handler_targets.len(), 2); + + let checker = extraction + .nodes + .iter() + .find(|node| node.string("qualified_name") == "pkg.routes.checker") + .ok_or("missing checker instance")?; + let dependency_c = extraction + .nodes + .iter() + .find(|node| node.string("qualified_name") == "pkg.dependencies.dependency_c") + .ok_or("missing qualified dependency")?; + assert_eq!(checker.string("symbol_kind"), "variable"); + assert!( + extraction + .edges + .iter() + .any(|edge| { edge.string("relation") == "depends_on" && edge.target == checker.id }) + ); + assert!( + extraction.edges.iter().any(|edge| { + edge.string("relation") == "depends_on" && edge.target == dependency_c.id + }) + ); + Ok(()) +} + +#[test] +fn module_scoped_fastapi_dependencies_preserve_exact_file_identity() -> Result<(), Box> { + let source = br#"from typing import Annotated +from fastapi import Depends + +def module_dependency(): + return True + +DependencyAlias = Annotated[str, Depends(module_dependency)] +"#; + let extraction = resolved_project(&[("pkg/dependencies.py", source)])?; + let file = extraction + .nodes + .iter() + .find(|node| { + node.string("symbol_kind") == "file" + && node.string("source_file") == "pkg/dependencies.py" + }) + .ok_or("missing file node")?; + let dependency = extraction + .nodes + .iter() + .find(|node| node.string("qualified_name") == "pkg.dependencies.module_dependency") + .ok_or("missing module dependency")?; + assert!( + extraction.edges.iter().any(|edge| { + edge.string("relation") == "depends_on" + && edge.source == file.id + && edge.target == dependency.id + }), + "edges={:#?} diagnostics={:#?}", + extraction.edges, + extraction.extensions + ); + Ok(()) +} + #[test] fn receiver_mount_cycles_emit_no_fabricated_route_and_depth_overflow_is_explicit() -> Result<(), Box> { diff --git a/crates/compass-resolve/tests/python_import_provenance.rs b/crates/compass-resolve/tests/python_import_provenance.rs index 70a02ecd..14ef07b3 100644 --- a/crates/compass-resolve/tests/python_import_provenance.rs +++ b/crates/compass-resolve/tests/python_import_provenance.rs @@ -1393,6 +1393,30 @@ fn low_python_local_initializer_calls_the_exact_class_method() -> Result<(), Box Ok(()) } +#[test] +fn multiline_awaited_call_under_tuple_assignment_keeps_the_exact_target() +-> Result<(), Box> { + let files = [( + "dependencies.py", + "async def solve_dependencies():\n if True:\n (\n body_values,\n body_errors,\n ) = await request_body_to_args( # body_params checked above\n body_fields=[],\n received_body=None,\n embed_body_fields=False,\n )\n return body_values, body_errors\n\nasync def request_body_to_args(body_fields, received_body, embed_body_fields):\n return {}, []\n", + )]; + let (_, resolved, _) = resolve_fixture_at_low_inference(&files)?; + let caller = resolved + .nodes + .iter() + .find(|node| node.string("qualified_name") == "dependencies.solve_dependencies") + .ok_or("missing solve_dependencies")?; + let target = resolved + .nodes + .iter() + .find(|node| node.string("qualified_name") == "dependencies.request_body_to_args") + .ok_or("missing request_body_to_args")?; + assert!(resolved.edges.iter().any(|edge| { + edge.source == caller.id && edge.target == target.id && edge.string("relation") == "calls" + })); + Ok(()) +} + #[test] fn low_python_local_initializer_dispatch_fails_closed_for_non_dominating_or_rebound_values() -> Result<(), Box> { diff --git a/docs/design/code-graph-v1-qualification.md b/docs/design/code-graph-v1-qualification.md index cbb69722..5a9a9c82 100644 --- a/docs/design/code-graph-v1-qualification.md +++ b/docs/design/code-graph-v1-qualification.md @@ -21,7 +21,10 @@ The checked-in manifests require: language/extractor family in the corpus manifest; - distinct relationship sites for the repeated-occurrence fixture; - truthful partial/recovery coverage, empty-file handling, and a surfaced - missing-reference diagnostic. + missing-reference diagnostic; and +- evidence-backed topology floors for unique typed endpoint pairs, connected + nodes, cross-file edges, and their per-node ratios, plus fragmentation caps + for components and isolates. The oracle also validates durable identities, typed details, endpoint-kind compatibility, source bounds, known producers, direct and heuristic @@ -66,16 +69,20 @@ The script: edit-then-restore updates; 5. verifies that the checked-in source fixtures were not changed; 6. executes every semantic assertion over the restored production graph; -7. derives Markdown table and reference expectations independently from the +7. runs one clustered production update and enforces the separate + `compass.code-graph-topology-policy/1` policy over both complete topology + and the subset supported by exact evidence; +8. derives Markdown table and reference expectations independently from the fixture source, then checks graph-v1 roles, labels, hierarchy, and exact anchors; and -8. prints canonical machine summaries to standard output. +9. prints canonical machine summaries to standard output. Oracle self-tests and manifest-only validation can be run independently: ```bash python3 -m unittest discover -s scripts/tests -p '*code_graph_v1*' python3 scripts/check_code_graph_v1_coverage.py +python3 scripts/check_code_graph_topology.py --graph /path/to/graph.json ``` ## Evidence interpretation @@ -101,3 +108,28 @@ Digests and counts are deliberately generated by the command instead of copied into this document. They describe the exact revision, manifests, and binary that produced them and prevent stale prose from being presented as current release evidence. + +## Topology interpretation + +Raw node and edge totals are coverage signals, not connectivity proof. The +topology report has the separate schema +`compass.code-graph-topology-report/1` and records: + +- edge occurrences and unique `(source, target, relationship kind)` pairs; +- edge-bearing and isolated nodes; +- weakly connected components and largest-component size; +- cross-file and cross-community edges; +- self-loops, total communities, and singleton communities; and +- the same connectivity measurements for edges with at least one exact + evidence record. + +Integer per-thousand-node measurements prevent a producer from satisfying the +gate merely by adding nodes or repeated edge occurrences. Relationship-level +floors protect exact calls, containment, imports, references, route bindings, +render bindings, documentation links, and explicit mappings independently. +The clustered profile also caps community and singleton-community counts while +requiring exact cross-community bridges, so additional communities cannot hide +fragmentation. +The checked-in fixture gate does not invoke Graphify and does not accept its +output as truth. A policy change requires reviewable source fixtures and exact +Compass evidence; a larger third-party graph alone is not sufficient. diff --git a/scripts/check_code_graph_topology.py b/scripts/check_code_graph_topology.py new file mode 100755 index 00000000..bd30cd96 --- /dev/null +++ b/scripts/check_code_graph_topology.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Validate evidence-backed code-graph topology against a v1 regression policy.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from code_graph_v1_oracle import ( + QualificationError, + canonical_bytes, + digest_bytes, + load_topology_policy, + topology_report, +) + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_POLICY = ROOT / "tests/qualification/code-graph-v1-topology.json" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--graph", type=Path, required=True) + parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY) + args = parser.parse_args() + + graph_bytes = args.graph.read_bytes() + graph = json.loads(graph_bytes) + policy = load_topology_policy(args.policy) + report = topology_report( + graph, + policy, + graph_digest=digest_bytes(graph_bytes), + ) + print(canonical_bytes(report).decode(), end="") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, json.JSONDecodeError, QualificationError, ValueError) as error: + print(f"code-graph topology qualification failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/code_graph_v1_oracle.py b/scripts/code_graph_v1_oracle.py index 3564a9be..6c0b36b8 100755 --- a/scripts/code_graph_v1_oracle.py +++ b/scripts/code_graph_v1_oracle.py @@ -15,6 +15,8 @@ # oracle silently accept a newer manifest. SCHEMA = "compass.code-graph-qualification/2" GRAPH_SCHEMA = "compass.graph/1" +TOPOLOGY_POLICY_SCHEMA = "compass.code-graph-topology-policy/1" +TOPOLOGY_REPORT_SCHEMA = "compass.code-graph-topology-report/1" NODE_KINDS = ( "file", "module", "package", "namespace", "class", "struct", "interface", "trait", "protocol", "enum", "enum_member", "type_alias", "function", @@ -53,6 +55,24 @@ ALL_ORIGINS = TRUSTED_ORIGINS | {"heuristic"} CONFIDENCES = {"exact", "inferred", "ambiguous"} RESOLUTIONS = {"exact", "ambiguous", "unresolved"} +TOPOLOGY_METRICS = { + "communities", "connectedComponents", "crossCommunityEdges", + "crossFileEdges", "crossFileEdgesPerThousandNodes", "edgeBearingNodes", + "edgeBearingNodePermille", "edges", "exactConnectedComponents", + "exactCrossCommunityEdges", "exactCrossFileEdges", + "exactCrossFileEdgesPerThousandNodes", "exactEdgeBearingNodes", + "exactEdgeBearingNodePermille", "exactEdges", "exactIsolatedNodes", + "exactLargestComponentNodes", "exactSelfLoops", + "exactUniqueTypedEndpointPairs", + "exactUniqueTypedEndpointPairsPerThousandNodes", "isolatedNodes", + "largestComponentNodes", "nodes", "selfLoops", "singletonCommunities", + "uniqueTypedEndpointPairs", "uniqueTypedEndpointPairsPerThousandNodes", +} +RELATION_TOPOLOGY_METRICS = { + "crossFileEdges", "edgeBearingNodes", "edges", "exactCrossFileEdges", + "exactEdgeBearingNodes", "exactEdges", "exactUniqueEndpointPairs", + "uniqueEndpointPairs", +} STAGES = { "handler", "middleware", "layout", "template", "loading", "default", "error_boundary", "not_found", "boundary", "loader", "action", @@ -256,6 +276,42 @@ def load_manifest( return manifest +def _validate_topology_policy(policy: Any, identity: str) -> None: + fields = {"minimums", "maximums", "relationshipMinimums"} + if not isinstance(policy, dict) or set(policy) != fields: + fail("manifest_topology", identity, f"topology must contain {sorted(fields)}") + for bound in ("minimums", "maximums"): + values = policy[bound] + if not isinstance(values, dict) or not values: + fail("manifest_topology", identity, f"{bound} must be a non-empty object") + unknown = sorted(set(values) - TOPOLOGY_METRICS) + if unknown: + fail("manifest_topology", identity, f"unknown {bound} metrics {unknown}") + if any(not isinstance(value, int) or isinstance(value, bool) or value < 0 for value in values.values()): + fail("manifest_topology", identity, f"{bound} values must be non-negative integers") + relationships = policy["relationshipMinimums"] + if not isinstance(relationships, dict) or not relationships: + fail("manifest_topology", identity, "relationshipMinimums must be a non-empty object") + for relation, values in relationships.items(): + if relation not in EDGE_KINDS or not isinstance(values, dict) or not values: + fail("manifest_topology", identity, f"invalid relationship minimum {relation!r}") + unknown = sorted(set(values) - RELATION_TOPOLOGY_METRICS) + if unknown: + fail("manifest_topology", identity, f"unknown {relation} metrics {unknown}") + if any(not isinstance(value, int) or isinstance(value, bool) or value < 0 for value in values.values()): + fail("manifest_topology", identity, f"{relation} values must be non-negative integers") + + +def load_topology_policy(path: Path) -> dict[str, Any]: + document = load_json(path) + if not isinstance(document, dict) or set(document) != {"schema", "topology"}: + fail("topology_policy_shape", str(path), "expected schema and topology") + if document.get("schema") != TOPOLOGY_POLICY_SCHEMA: + fail("topology_policy_schema", str(path), f"expected {TOPOLOGY_POLICY_SCHEMA}") + _validate_topology_policy(document["topology"], str(path)) + return document + + def _unique_id(ids: set[str], identity: str) -> None: if not identity or identity == "" or identity in ids: fail("manifest_duplicate_id", identity, "ID is empty or duplicated") @@ -433,7 +489,13 @@ def endpoint_allowed(source: dict[str, Any], edge: dict[str, Any], target: dict[ if kind == "routes_to": return s == "route" and ( t in {"file", "function", "method", "class", "component"} - or (t == "variable" and "route_handler" in target.get("roles", [])) + or ( + t == "variable" + and bool( + {"route_handler", "service", "middleware"} + & set(target.get("roles", [])) + ) + ) ) if kind == "renders": # Top-level JSX/createElement has no callable owner; production uses @@ -574,6 +636,219 @@ def validate_graph(graph: dict[str, Any], manifest: dict[str, Any]) -> dict[str, } +def topology_metrics(graph: dict[str, Any]) -> dict[str, Any]: + """Measure useful topology without treating repeated occurrences as new connections.""" + nodes = graph.get("nodes", []) + edges = graph.get("links", []) + index = {node["id"]: node for node in nodes} + parent = {identity: identity for identity in index} + component_sizes = {identity: 1 for identity in index} + exact_parent = dict(parent) + exact_component_sizes = dict(component_sizes) + + def find(identity: str, parents: dict[str, str]) -> str: + root = identity + while parents[root] != root: + root = parents[root] + while parents[identity] != identity: + next_identity = parents[identity] + parents[identity] = root + identity = next_identity + return root + + def union( + left: str, + right: str, + parents: dict[str, str], + sizes: dict[str, int], + ) -> None: + left_root = find(left, parents) + right_root = find(right, parents) + if left_root == right_root: + return + if sizes[left_root] < sizes[right_root]: + left_root, right_root = right_root, left_root + parents[right_root] = left_root + sizes[left_root] += sizes[right_root] + + def community_identity(node: dict[str, Any]) -> Any: + community = node.get("community") + if isinstance(community, dict): + return community.get("id") + return community + + incident: set[str] = set() + typed_pairs: set[tuple[str, str, str]] = set() + cross_file_edges = 0 + cross_community_edges = 0 + self_loops = 0 + relation_edges: Counter[str] = Counter() + relation_pairs: dict[str, set[tuple[str, str]]] = defaultdict(set) + relation_incident: dict[str, set[str]] = defaultdict(set) + relation_cross_file: Counter[str] = Counter() + exact_incident: set[str] = set() + exact_typed_pairs: set[tuple[str, str, str]] = set() + exact_cross_file_edges = 0 + exact_cross_community_edges = 0 + exact_self_loops = 0 + exact_edges = 0 + relation_exact_edges: Counter[str] = Counter() + relation_exact_pairs: dict[str, set[tuple[str, str]]] = defaultdict(set) + relation_exact_incident: dict[str, set[str]] = defaultdict(set) + relation_exact_cross_file: Counter[str] = Counter() + for edge in edges: + source_id = edge["source"] + target_id = edge["target"] + relation = edge["kind"] + incident.update((source_id, target_id)) + typed_pairs.add((source_id, target_id, relation)) + relation_edges[relation] += 1 + relation_pairs[relation].add((source_id, target_id)) + relation_incident[relation].update((source_id, target_id)) + if source_id == target_id: + self_loops += 1 + else: + union(source_id, target_id, parent, component_sizes) + source_file = (index[source_id].get("source") or {}).get("file") + target_file = (index[target_id].get("source") or {}).get("file") + cross_file = ( + source_file is not None + and target_file is not None + and source_file != target_file + ) + if cross_file: + cross_file_edges += 1 + relation_cross_file[relation] += 1 + source_community = community_identity(index[source_id]) + target_community = community_identity(index[target_id]) + cross_community = ( + source_community is not None + and target_community is not None + and source_community != target_community + ) + if cross_community: + cross_community_edges += 1 + + is_exact = any( + evidence.get("confidence") == "exact" + for evidence in edge.get("evidence", []) + if isinstance(evidence, dict) + ) + if not is_exact: + continue + exact_edges += 1 + exact_incident.update((source_id, target_id)) + exact_typed_pairs.add((source_id, target_id, relation)) + relation_exact_edges[relation] += 1 + relation_exact_pairs[relation].add((source_id, target_id)) + relation_exact_incident[relation].update((source_id, target_id)) + if source_id == target_id: + exact_self_loops += 1 + else: + union(source_id, target_id, exact_parent, exact_component_sizes) + if cross_file: + exact_cross_file_edges += 1 + relation_exact_cross_file[relation] += 1 + if cross_community: + exact_cross_community_edges += 1 + + roots = Counter(find(identity, parent) for identity in index) + exact_roots = Counter(find(identity, exact_parent) for identity in index) + communities = Counter( + community + for node in nodes + if (community := community_identity(node)) is not None + ) + by_relation = { + relation: { + "crossFileEdges": relation_cross_file[relation], + "edgeBearingNodes": len(relation_incident[relation]), + "edges": relation_edges[relation], + "exactCrossFileEdges": relation_exact_cross_file[relation], + "exactEdgeBearingNodes": len(relation_exact_incident[relation]), + "exactEdges": relation_exact_edges[relation], + "exactUniqueEndpointPairs": len(relation_exact_pairs[relation]), + "uniqueEndpointPairs": len(relation_pairs[relation]), + } + for relation in sorted(relation_edges) + } + node_count = len(nodes) + + def per_thousand(value: int) -> int: + return value * 1_000 // node_count if node_count else 0 + + return { + "communities": len(communities), + "connectedComponents": len(roots), + "crossCommunityEdges": cross_community_edges, + "crossFileEdges": cross_file_edges, + "crossFileEdgesPerThousandNodes": per_thousand(cross_file_edges), + "edgeBearingNodes": len(incident), + "edgeBearingNodePermille": per_thousand(len(incident)), + "edges": len(edges), + "exactConnectedComponents": len(exact_roots), + "exactCrossCommunityEdges": exact_cross_community_edges, + "exactCrossFileEdges": exact_cross_file_edges, + "exactCrossFileEdgesPerThousandNodes": per_thousand(exact_cross_file_edges), + "exactEdgeBearingNodes": len(exact_incident), + "exactEdgeBearingNodePermille": per_thousand(len(exact_incident)), + "exactEdges": exact_edges, + "exactIsolatedNodes": len(index) - len(exact_incident), + "exactLargestComponentNodes": max(exact_roots.values(), default=0), + "exactSelfLoops": exact_self_loops, + "exactUniqueTypedEndpointPairs": len(exact_typed_pairs), + "exactUniqueTypedEndpointPairsPerThousandNodes": per_thousand( + len(exact_typed_pairs) + ), + "isolatedNodes": len(index) - len(incident), + "largestComponentNodes": max(roots.values(), default=0), + "nodes": len(nodes), + "selfLoops": self_loops, + "singletonCommunities": sum(size == 1 for size in communities.values()), + "uniqueTypedEndpointPairs": len(typed_pairs), + "uniqueTypedEndpointPairsPerThousandNodes": per_thousand(len(typed_pairs)), + "byRelation": by_relation, + } + + +def assert_topology(metrics: dict[str, Any], policy: dict[str, Any]) -> None: + for metric, minimum in policy["minimums"].items(): + actual = metrics.get(metric) + if not isinstance(actual, int) or actual < minimum: + fail("topology_minimum", metric, f"{actual} < {minimum}") + for metric, maximum in policy["maximums"].items(): + actual = metrics.get(metric) + if not isinstance(actual, int) or actual > maximum: + fail("topology_maximum", metric, f"{actual} > {maximum}") + by_relation = metrics.get("byRelation", {}) + for relation, minimums in policy["relationshipMinimums"].items(): + actuals = by_relation.get(relation, {}) + for metric, minimum in minimums.items(): + actual = actuals.get(metric, 0) + if not isinstance(actual, int) or actual < minimum: + fail( + "topology_relationship_minimum", + f"{relation}.{metric}", + f"{actual} < {minimum}", + ) + + +def topology_report( + graph: dict[str, Any], + policy_document: dict[str, Any], + *, + graph_digest: str, +) -> dict[str, Any]: + metrics = topology_metrics(graph) + assert_topology(metrics, policy_document["topology"]) + return { + "schema": TOPOLOGY_REPORT_SCHEMA, + "graphDigest": graph_digest, + "metrics": metrics, + "policy": policy_document["topology"], + } + + def _validate_coverage(metadata: dict[str, Any], files: dict[str, dict[str, Any]], manifest: dict[str, Any]) -> None: diagnostics = metadata.get("diagnostics", []) limit = manifest["limits"]["maxDiagnostics"] diff --git a/scripts/qualify_code_graph_v1.sh b/scripts/qualify_code_graph_v1.sh index 54d439d3..d7692d56 100755 --- a/scripts/qualify_code_graph_v1.sh +++ b/scripts/qualify_code_graph_v1.sh @@ -378,6 +378,19 @@ python3 scripts/check_code_graph_v1_coverage.py \ --compass-revision "$(git rev-parse HEAD)" \ --comparisons "$QUALIFY_TMP/comparisons.json" +echo "[code-graph-v1] clustered production update for topology qualification" +CLUSTERED_OUTPUT="$QUALIFY_TMP/clustered-output" +"$COMPASS_BIN" update "$CORPUS" \ + --out "$CLUSTERED_OUTPUT" --no-viz --no-gitignore \ + --inference-level max \ + >"$QUALIFY_TMP/clustered.log" +clustered_graph="$(active_graph "$CLUSTERED_OUTPUT")" + +echo "[code-graph-v1] enforce evidence-backed topology regression policy" +python3 scripts/check_code_graph_topology.py \ + --graph "$clustered_graph" \ + --policy tests/qualification/code-graph-v1-topology.json + echo "[code-graph-v1] execute independent Markdown graph-quality assertions" python3 scripts/markdown_graph_quality_oracle.py \ --manifest tests/qualification/markdown-intelligence.json \ diff --git a/scripts/tests/test_code_graph_v1_oracle.py b/scripts/tests/test_code_graph_v1_oracle.py index 94062c48..54401a32 100644 --- a/scripts/tests/test_code_graph_v1_oracle.py +++ b/scripts/tests/test_code_graph_v1_oracle.py @@ -18,11 +18,15 @@ assert_coverage, assert_flows, assert_negatives, + assert_topology, canonical_bytes, endpoint_allowed, load_json, load_manifest, + load_topology_policy, qualification_summary, + topology_metrics, + topology_report, validate_graph, ) @@ -94,12 +98,13 @@ def test_canonical_bytes_are_order_independent(self) -> None: def test_endpoint_matrix_rejects_inheritance_to_variable(self) -> None: self.assertFalse(endpoint_allowed({"kind": "class"}, {"kind": "extends"}, {"kind": "variable"})) - def test_endpoint_matrix_accepts_explicit_route_handler_variables(self) -> None: - self.assertTrue(endpoint_allowed( - {"kind": "route"}, - {"kind": "routes_to"}, - {"kind": "variable", "roles": ["route_handler"]}, - )) + def test_endpoint_matrix_accepts_explicit_route_stage_variables(self) -> None: + for role in ("route_handler", "service", "middleware"): + self.assertTrue(endpoint_allowed( + {"kind": "route"}, + {"kind": "routes_to"}, + {"kind": "variable", "roles": [role]}, + )) self.assertFalse(endpoint_allowed( {"kind": "route"}, {"kind": "routes_to"}, @@ -243,6 +248,180 @@ def test_coverage_rejects_false_complete(self) -> None: with self.assertRaisesRegex(QualificationError, "false_coverage"): assert_coverage(graph, manifest) + def test_topology_separates_occurrences_from_unique_typed_connections(self) -> None: + first = node("function:first", "function") + second = node("function:second", "function") + third = node("function:third", "function") + fourth = node("function:isolated", "function") + first["community"] = {"id": 1, "label": "first"} + second["community"] = {"id": 1, "label": "renamed without identity change"} + third["community"] = {"id": 2, "label": "second"} + third["source"] = anchor("other.py") + third["evidence"] = evidence("other.py") + graph = self.graph([first, second, third, fourth]) + graph["graph"]["files"].append({ + "id": "file:other", + "path": "other.py", + "byteSize": 8, + "extractionStatus": "extracted", + }) + graph["links"] = [ + { + "id": "edge:1", + "key": "edge:1", + "kind": "calls", + "source": first["id"], + "target": second["id"], + "relationshipSite": anchor(), + "evidence": evidence(), + }, + { + "id": "edge:2", + "key": "edge:2", + "kind": "calls", + "source": first["id"], + "target": second["id"], + "relationshipSite": anchor(start=2, end=3), + "evidence": evidence(), + }, + { + "id": "edge:3", + "key": "edge:3", + "kind": "references", + "source": second["id"], + "target": third["id"], + "relationshipSite": anchor(), + "evidence": evidence(), + }, + ] + + self.assertEqual(topology_metrics(graph), { + "communities": 2, + "connectedComponents": 2, + "crossCommunityEdges": 1, + "crossFileEdges": 1, + "crossFileEdgesPerThousandNodes": 250, + "edgeBearingNodes": 3, + "edgeBearingNodePermille": 750, + "edges": 3, + "exactConnectedComponents": 2, + "exactCrossCommunityEdges": 1, + "exactCrossFileEdges": 1, + "exactCrossFileEdgesPerThousandNodes": 250, + "exactEdgeBearingNodes": 3, + "exactEdgeBearingNodePermille": 750, + "exactEdges": 3, + "exactIsolatedNodes": 1, + "exactLargestComponentNodes": 3, + "exactSelfLoops": 0, + "exactUniqueTypedEndpointPairs": 2, + "exactUniqueTypedEndpointPairsPerThousandNodes": 500, + "isolatedNodes": 1, + "largestComponentNodes": 3, + "nodes": 4, + "selfLoops": 0, + "singletonCommunities": 1, + "uniqueTypedEndpointPairs": 2, + "uniqueTypedEndpointPairsPerThousandNodes": 500, + "byRelation": { + "calls": { + "crossFileEdges": 0, + "edgeBearingNodes": 2, + "edges": 2, + "exactCrossFileEdges": 0, + "exactEdgeBearingNodes": 2, + "exactEdges": 2, + "exactUniqueEndpointPairs": 1, + "uniqueEndpointPairs": 1, + }, + "references": { + "crossFileEdges": 1, + "edgeBearingNodes": 2, + "edges": 1, + "exactCrossFileEdges": 1, + "exactEdgeBearingNodes": 2, + "exactEdges": 1, + "exactUniqueEndpointPairs": 1, + "uniqueEndpointPairs": 1, + }, + }, + }) + + def test_topology_policy_rejects_global_and_relationship_regressions(self) -> None: + metrics = { + "connectedComponents": 3, + "crossFileEdges": 4, + "edgeBearingNodes": 8, + "isolatedNodes": 2, + "uniqueTypedEndpointPairs": 7, + "byRelation": { + "calls": { + "crossFileEdges": 1, + "edgeBearingNodes": 3, + "edges": 4, + "uniqueEndpointPairs": 2, + }, + }, + } + policy = { + "minimums": { + "crossFileEdges": 4, + "edgeBearingNodes": 8, + "uniqueTypedEndpointPairs": 7, + }, + "maximums": {"connectedComponents": 3, "isolatedNodes": 2}, + "relationshipMinimums": { + "calls": {"crossFileEdges": 1, "uniqueEndpointPairs": 2}, + }, + } + assert_topology(metrics, policy) + + disconnected = copy.deepcopy(metrics) + disconnected["connectedComponents"] = 4 + with self.assertRaisesRegex(QualificationError, "topology_maximum"): + assert_topology(disconnected, policy) + + missing_call = copy.deepcopy(metrics) + missing_call["byRelation"]["calls"]["crossFileEdges"] = 0 + with self.assertRaisesRegex(QualificationError, "topology_relationship_minimum"): + assert_topology(missing_call, policy) + + def test_topology_policy_and_report_are_strict_v1_contracts(self) -> None: + policy = { + "schema": "compass.code-graph-topology-policy/1", + "topology": { + "minimums": {"exactEdgeBearingNodePermille": 500}, + "maximums": {"exactIsolatedNodes": 1}, + "relationshipMinimums": { + "calls": {"exactUniqueEndpointPairs": 1}, + }, + }, + } + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "topology.json" + path.write_text(json.dumps(policy)) + self.assertEqual(load_topology_policy(path), policy) + policy["extra"] = True + path.write_text(json.dumps(policy)) + with self.assertRaisesRegex(QualificationError, "topology_policy_shape"): + load_topology_policy(path) + + graph = self.graph([node("function:only", "function")]) + permissive = { + "schema": "compass.code-graph-topology-policy/1", + "topology": { + "minimums": {"nodes": 1}, + "maximums": {"isolatedNodes": 1}, + "relationshipMinimums": { + "calls": {"exactUniqueEndpointPairs": 0}, + }, + }, + } + report = topology_report(graph, permissive, graph_digest="sha256:test") + self.assertEqual(report["schema"], "compass.code-graph-topology-report/1") + self.assertEqual(report["graphDigest"], "sha256:test") + self.assertEqual(report["metrics"]["exactIsolatedNodes"], 1) + def test_flow_checks_exact_handler_identity_kind_and_language(self) -> None: route = node("route:1", "route") route.update({ diff --git a/tests/qualification/code-graph-v1-topology.json b/tests/qualification/code-graph-v1-topology.json new file mode 100644 index 00000000..8af0850b --- /dev/null +++ b/tests/qualification/code-graph-v1-topology.json @@ -0,0 +1,62 @@ +{ + "schema": "compass.code-graph-topology-policy/1", + "topology": { + "minimums": { + "edges": 1284, + "exactCrossCommunityEdges": 21, + "exactCrossFileEdges": 81, + "exactCrossFileEdgesPerThousandNodes": 63, + "exactEdgeBearingNodePermille": 711, + "exactEdgeBearingNodes": 908, + "exactEdges": 965, + "exactLargestComponentNodes": 54, + "exactUniqueTypedEndpointPairs": 956, + "exactUniqueTypedEndpointPairsPerThousandNodes": 749, + "nodes": 1276, + "uniqueTypedEndpointPairs": 1261 + }, + "maximums": { + "communities": 242, + "connectedComponents": 222, + "exactConnectedComponents": 496, + "exactIsolatedNodes": 368, + "exactSelfLoops": 0, + "isolatedNodes": 96, + "selfLoops": 0, + "singletonCommunities": 108 + }, + "relationshipMinimums": { + "calls": { + "exactUniqueEndpointPairs": 13 + }, + "contains": { + "exactCrossFileEdges": 31, + "exactUniqueEndpointPairs": 624 + }, + "documents": { + "exactCrossFileEdges": 3, + "exactUniqueEndpointPairs": 3 + }, + "imports": { + "exactCrossFileEdges": 12, + "exactUniqueEndpointPairs": 29 + }, + "maps_to": { + "exactCrossFileEdges": 10, + "exactUniqueEndpointPairs": 10 + }, + "references": { + "exactCrossFileEdges": 7, + "exactUniqueEndpointPairs": 49 + }, + "renders": { + "exactCrossFileEdges": 4, + "exactUniqueEndpointPairs": 5 + }, + "routes_to": { + "exactCrossFileEdges": 14, + "exactUniqueEndpointPairs": 111 + } + } + } +}