diff --git a/docs/repo-reality-scanner.md b/docs/repo-reality-scanner.md index 1932206..847f1fa 100644 --- a/docs/repo-reality-scanner.md +++ b/docs/repo-reality-scanner.md @@ -43,6 +43,8 @@ The deterministic extractor framework currently covers: - repository metadata from local git/path evidence - README, INTENT, and SCOPE document presence and headings +- repository-level UI status and supporting indicators (`yes`, `candidate`, or + `not_detected`) - repo-owned Fabric declarations under `fabric/` - Python `pyproject.toml` package metadata and dependencies - Node `package.json` package metadata and dependencies @@ -54,6 +56,12 @@ The deterministic extractor framework currently covers: - common service config files such as `application.yaml` and `appsettings.json` +The UI status is a first-level repository attribute. `yes` requires an explicit +`web-ui` interface declaration; `candidate` records deterministic frontend +markers such as a UI package, entrypoint, or build configuration; and +`not_detected` means no such marker was found. The status is filterable through +the discovery attributes and retains `ui_evidence` source paths for review. + Each extractor emits candidates through the same accumulator so stable-key duplicates merge inside a scan before the snapshot is returned. diff --git a/railiance_fabric/scanner.py b/railiance_fabric/scanner.py index 7c834d8..022e8eb 100644 --- a/railiance_fabric/scanner.py +++ b/railiance_fabric/scanner.py @@ -395,6 +395,7 @@ def _deterministic_extractors() -> list: return [ _extract_repo_metadata, _extract_text_metadata, + _extract_ui_metadata, _extract_fabric_declarations, _extract_python_package, _extract_node_package, @@ -478,6 +479,100 @@ def _extract_text_metadata(context: ScanContext) -> None: ) +def _extract_ui_metadata(context: ScanContext) -> None: + """Record first-level evidence about whether a repository provides a UI.""" + + scope = context.accumulator.add_scope( + extractor_id="repo-ui-metadata", + source_kind="file", + source_path=".", + description="Repository-level user-interface indicators.", + ) + evidence: list[str] = [] + declared = False + + for path in declaration_files(context.repo_path): + try: + data = load_yaml(path) + except Exception: + continue + if not isinstance(data, dict): + continue + spec = data.get("spec") if isinstance(data.get("spec"), dict) else {} + if spec.get("interface_type") == "web-ui": + declared = True + evidence.append(context.relpath(path)) + + marker_paths = ( + "index.html", + "src/main.ts", + "src/main.tsx", + "src/main.js", + "src/main.jsx", + "src/App.tsx", + "src/App.jsx", + "vite.config.ts", + "vite.config.js", + "next.config.js", + "next.config.mjs", + "angular.json", + ) + for marker in marker_paths: + if (context.repo_path / marker).is_file(): + evidence.append(marker) + + for package_name in ("package.json",): + package_path = context.repo_path / package_name + if not package_path.is_file(): + continue + try: + package = json.loads(package_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + scripts = package.get("scripts") if isinstance(package, dict) else {} + dependencies = {} + if isinstance(package, dict): + dependencies.update(package.get("dependencies") or {}) + dependencies.update(package.get("devDependencies") or {}) + ui_packages = {"react", "react-dom", "vue", "@angular/core", "svelte", "next", "nuxt"} + if isinstance(scripts, dict) and any("dev" in str(value).lower() or "vite" in str(value).lower() for value in scripts.values()): + evidence.append(package_name) + if ui_packages.intersection(dependencies): + evidence.append(package_name) + + evidence = sorted(set(evidence)) + if declared: + status = "yes" + confidence = 1.0 + elif evidence: + status = "candidate" + confidence = 0.65 + else: + status = "not_detected" + confidence = 0.4 + + anchor = _source_anchor("file", evidence[0] if evidence else ".") + provenance = _provenance("repo-ui-metadata") + context.accumulator.add_attribute( + entity_key=context.repository_key, + name="ui_status", + value=status, + replacement_scope=scope, + provenance=provenance, + source_anchor=anchor, + confidence=confidence, + ) + context.accumulator.add_attribute( + entity_key=context.repository_key, + name="ui_evidence", + value=evidence, + replacement_scope=scope, + provenance=provenance, + source_anchor=anchor, + confidence=confidence, + ) + + def _extract_fabric_declarations(context: ScanContext) -> None: declarations: list[tuple[Path, dict[str, Any]]] = [] for path in declaration_files(context.repo_path): diff --git a/railiance_fabric/schema_validation.py b/railiance_fabric/schema_validation.py index ddf3799..f1fd3cb 100644 --- a/railiance_fabric/schema_validation.py +++ b/railiance_fabric/schema_validation.py @@ -39,10 +39,17 @@ def draft202012_validator(schema_path: Path) -> jsonschema.Draft202012Validator: registry = schema_registry(schema_path.parent) if registry is None: return _legacy_validator(schema_path, schema) - return jsonschema.Draft202012Validator( - schema, - registry=registry, - ) + try: + return jsonschema.Draft202012Validator( + schema, + registry=registry, + ) + except TypeError as exc: + # jsonschema < 4.17 does not accept referencing.Registry. Keep the + # same schema set usable on older operator workstations. + if "unexpected keyword argument 'registry'" not in str(exc): + raise + return _legacy_validator(schema_path, schema) def _legacy_validator( diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index 4ff70f4..30d00d8 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -159,6 +159,25 @@ def test_graph_explorer_collapses_discovered_repository_nodes() -> None: assert declares_package["data"]["displayOnly"] is False +def test_graph_explorer_materializes_unknown_edge_endpoints() -> None: + graph = { + "apiVersion": "railiance.fabric/v1alpha1", + "kind": "FabricGraphExport", + "nodes": [{"id": "repo:reef-railiance", "kind": "Repository", "name": "reef-railiance", "repo": "reef-railiance"}], + "edges": [{"from": "repo:reef-railiance", "to": "repo:rail-knative", "type": "hosts_rail"}], + } + + payload = fabric_graph_explorer_payload(graph, [{"slug": "reef-railiance", "name": "reef-railiance"}], {"reef-railiance"}) + nodes = [element for element in payload["elements"] if "source" not in element["data"]] + edges = [element for element in payload["elements"] if "source" in element["data"]] + unknown = next(node for node in nodes if node["data"]["id"] == "repo:rail-knative") + edge = next(edge for edge in edges if edge["data"]["target"] == "repo:rail-knative") + + assert unknown["data"]["kind"] == "Unknown" + assert unknown["data"]["unresolved"] is True + assert edge["data"]["source"] == "repo:reef-railiance" + + def test_graph_explorer_surfaces_repository_source_references() -> None: graph = { "apiVersion": "railiance.fabric/v1alpha1", diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 64051df..0d0d545 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -104,7 +104,12 @@ def test_scan_repo_emits_schema_valid_deterministic_snapshot(tmp_path: Path) -> "readme_title", "intent_present", "scope_present", + "ui_status", + "ui_evidence", } + attributes_by_name = {attribute["name"]: attribute["value"] for attribute in candidates["attributes"]} + assert attributes_by_name["ui_status"] == "candidate" + assert "package.json" in attributes_by_name["ui_evidence"] for collection_name in ("nodes", "edges", "attributes"): stable_keys = [item["stable_key"] for item in candidates[collection_name]]