forge-doctor-api 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forge_doctor_api/__init__.py +69 -0
- forge_doctor_api/__main__.py +3 -0
- forge_doctor_api/_graphstudio.py +376 -0
- forge_doctor_api/_graphview.py +181 -0
- forge_doctor_api/analyzers/__init__.py +0 -0
- forge_doctor_api/analyzers/asyncapi/__init__.py +39 -0
- forge_doctor_api/analyzers/asyncapi/compat.py +274 -0
- forge_doctor_api/analyzers/asyncapi/graph.py +116 -0
- forge_doctor_api/analyzers/asyncapi/knowledge.py +60 -0
- forge_doctor_api/analyzers/asyncapi/model.py +143 -0
- forge_doctor_api/analyzers/asyncapi/parser.py +512 -0
- forge_doctor_api/analyzers/cache/__init__.py +17 -0
- forge_doctor_api/analyzers/cache/graph.py +256 -0
- forge_doctor_api/analyzers/cache/model.py +66 -0
- forge_doctor_api/analyzers/cache/scan.py +192 -0
- forge_doctor_api/analyzers/clients/__init__.py +19 -0
- forge_doctor_api/analyzers/clients/graph.py +57 -0
- forge_doctor_api/analyzers/clients/graphql_doc.py +105 -0
- forge_doctor_api/analyzers/clients/java.py +139 -0
- forge_doctor_api/analyzers/clients/javascript.py +214 -0
- forge_doctor_api/analyzers/clients/model.py +83 -0
- forge_doctor_api/analyzers/clients/python.py +337 -0
- forge_doctor_api/analyzers/clients/scan.py +72 -0
- forge_doctor_api/analyzers/gateway/__init__.py +25 -0
- forge_doctor_api/analyzers/gateway/model.py +128 -0
- forge_doctor_api/analyzers/gateway/parser.py +572 -0
- forge_doctor_api/analyzers/graphql/__init__.py +49 -0
- forge_doctor_api/analyzers/graphql/client.py +164 -0
- forge_doctor_api/analyzers/graphql/compat.py +228 -0
- forge_doctor_api/analyzers/graphql/graph.py +105 -0
- forge_doctor_api/analyzers/graphql/model.py +180 -0
- forge_doctor_api/analyzers/graphql/parser.py +423 -0
- forge_doctor_api/analyzers/graphql/shape.py +149 -0
- forge_doctor_api/analyzers/grpc/__init__.py +51 -0
- forge_doctor_api/analyzers/grpc/compat.py +288 -0
- forge_doctor_api/analyzers/grpc/graph.py +84 -0
- forge_doctor_api/analyzers/grpc/model.py +150 -0
- forge_doctor_api/analyzers/grpc/parser.py +406 -0
- forge_doctor_api/analyzers/grpc/reliability.py +130 -0
- forge_doctor_api/analyzers/iac/__init__.py +21 -0
- forge_doctor_api/analyzers/iac/cloudformation.py +152 -0
- forge_doctor_api/analyzers/iac/graph.py +65 -0
- forge_doctor_api/analyzers/iac/hcl.py +184 -0
- forge_doctor_api/analyzers/iac/model.py +81 -0
- forge_doctor_api/analyzers/iac/parser.py +216 -0
- forge_doctor_api/analyzers/openapi/__init__.py +76 -0
- forge_doctor_api/analyzers/openapi/detection.py +63 -0
- forge_doctor_api/analyzers/openapi/graph.py +171 -0
- forge_doctor_api/analyzers/openapi/identity.py +44 -0
- forge_doctor_api/analyzers/openapi/knowledge.py +59 -0
- forge_doctor_api/analyzers/openapi/loader.py +290 -0
- forge_doctor_api/analyzers/openapi/model.py +355 -0
- forge_doctor_api/analyzers/openapi/parser.py +952 -0
- forge_doctor_api/analyzers/openapi/refs.py +151 -0
- forge_doctor_api/analyzers/openapi/shape.py +81 -0
- forge_doctor_api/analyzers/routes/__init__.py +33 -0
- forge_doctor_api/analyzers/routes/adapter.py +291 -0
- forge_doctor_api/analyzers/routes/fastapi.py +971 -0
- forge_doctor_api/analyzers/routes/graph.py +101 -0
- forge_doctor_api/analyzers/routes/javascript.py +977 -0
- forge_doctor_api/analyzers/routes/model.py +122 -0
- forge_doctor_api/analyzers/routes/spring.py +541 -0
- forge_doctor_api/analyzers/routes/textscan.py +102 -0
- forge_doctor_api/analyzers/runtime/__init__.py +53 -0
- forge_doctor_api/analyzers/runtime/accesslog.py +68 -0
- forge_doctor_api/analyzers/runtime/adapter.py +32 -0
- forge_doctor_api/analyzers/runtime/aggregate.py +224 -0
- forge_doctor_api/analyzers/runtime/execution.py +408 -0
- forge_doctor_api/analyzers/runtime/graph.py +93 -0
- forge_doctor_api/analyzers/runtime/history.py +103 -0
- forge_doctor_api/analyzers/runtime/loader.py +283 -0
- forge_doctor_api/analyzers/runtime/model.py +100 -0
- forge_doctor_api/analyzers/runtime/otlp.py +211 -0
- forge_doctor_api/analyzers/runtime/stream.py +301 -0
- forge_doctor_api/analyzers/version/__init__.py +19 -0
- forge_doctor_api/analyzers/version/detect.py +242 -0
- forge_doctor_api/analyzers/version/model.py +71 -0
- forge_doctor_api/change/__init__.py +41 -0
- forge_doctor_api/change/baseline.py +51 -0
- forge_doctor_api/change/blast.py +178 -0
- forge_doctor_api/change/events.py +372 -0
- forge_doctor_api/change/model.py +125 -0
- forge_doctor_api/change/pr.py +56 -0
- forge_doctor_api/checks/__init__.py +1 -0
- forge_doctor_api/checks/apisec/__init__.py +5 -0
- forge_doctor_api/checks/apisec/catalog.py +172 -0
- forge_doctor_api/checks/apisec/engine.py +549 -0
- forge_doctor_api/checks/asyncapi/__init__.py +14 -0
- forge_doctor_api/checks/asyncapi/catalog.py +87 -0
- forge_doctor_api/checks/asyncapi/engine.py +374 -0
- forge_doctor_api/checks/client/__init__.py +16 -0
- forge_doctor_api/checks/client/catalog.py +41 -0
- forge_doctor_api/checks/client/engine.py +215 -0
- forge_doctor_api/checks/compat/__init__.py +27 -0
- forge_doctor_api/checks/compat/catalog.py +125 -0
- forge_doctor_api/checks/compat/engine.py +982 -0
- forge_doctor_api/checks/compat/fingerprint.py +116 -0
- forge_doctor_api/checks/drift/__init__.py +38 -0
- forge_doctor_api/checks/drift/catalog.py +150 -0
- forge_doctor_api/checks/drift/engine.py +608 -0
- forge_doctor_api/checks/drift/error_contract.py +127 -0
- forge_doctor_api/checks/graphql/__init__.py +15 -0
- forge_doctor_api/checks/graphql/catalog.py +150 -0
- forge_doctor_api/checks/graphql/engine.py +300 -0
- forge_doctor_api/checks/grpc/__init__.py +15 -0
- forge_doctor_api/checks/grpc/catalog.py +115 -0
- forge_doctor_api/checks/grpc/engine.py +217 -0
- forge_doctor_api/checks/oas/__init__.py +6 -0
- forge_doctor_api/checks/oas/catalog.py +271 -0
- forge_doctor_api/checks/oas/engine.py +766 -0
- forge_doctor_api/checks/observability/__init__.py +6 -0
- forge_doctor_api/checks/observability/catalog.py +75 -0
- forge_doctor_api/checks/observability/engine.py +159 -0
- forge_doctor_api/checks/perf/__init__.py +5 -0
- forge_doctor_api/checks/perf/catalog.py +104 -0
- forge_doctor_api/checks/perf/engine.py +299 -0
- forge_doctor_api/checks/relapi/__init__.py +5 -0
- forge_doctor_api/checks/relapi/catalog.py +124 -0
- forge_doctor_api/checks/relapi/engine.py +418 -0
- forge_doctor_api/cli/__init__.py +1596 -0
- forge_doctor_api/cli/catalog.py +131 -0
- forge_doctor_api/contracts/__init__.py +90 -0
- forge_doctor_api/contracts/adapters.py +336 -0
- forge_doctor_api/contracts/models.py +658 -0
- forge_doctor_api/contracts/schemas.py +244 -0
- forge_doctor_api/contracts/validate.py +114 -0
- forge_doctor_api/contracts/version.py +73 -0
- forge_doctor_api/core/__init__.py +1 -0
- forge_doctor_api/core/cache.py +115 -0
- forge_doctor_api/core/context.py +105 -0
- forge_doctor_api/core/discovery.py +317 -0
- forge_doctor_api/core/evidence_store.py +138 -0
- forge_doctor_api/core/export.py +53 -0
- forge_doctor_api/core/gate.py +101 -0
- forge_doctor_api/core/graph.py +387 -0
- forge_doctor_api/core/models.py +682 -0
- forge_doctor_api/core/plan.py +186 -0
- forge_doctor_api/core/redaction.py +106 -0
- forge_doctor_api/core/report.py +26 -0
- forge_doctor_api/core/stats.py +94 -0
- forge_doctor_api/diagnose/__init__.py +23 -0
- forge_doctor_api/diagnose/engine.py +328 -0
- forge_doctor_api/diagnose/model.py +87 -0
- forge_doctor_api/fleet/__init__.py +32 -0
- forge_doctor_api/fleet/collect.py +156 -0
- forge_doctor_api/fleet/model.py +130 -0
- forge_doctor_api/fleet/report.py +503 -0
- forge_doctor_api/graphview.py +84 -0
- forge_doctor_api/handoff/__init__.py +31 -0
- forge_doctor_api/handoff/boundary.py +215 -0
- forge_doctor_api/handoff/bundle.py +202 -0
- forge_doctor_api/handoff/context.py +281 -0
- forge_doctor_api/handoff/decision.py +72 -0
- forge_doctor_api/handoff/delta.py +197 -0
- forge_doctor_api/handoff/mcp.py +259 -0
- forge_doctor_api/handoff/mcp_server.py +274 -0
- forge_doctor_api/handoff/model.py +217 -0
- forge_doctor_api/handoff/protocol.py +336 -0
- forge_doctor_api/handoff/routing.py +84 -0
- forge_doctor_api/integrations/__init__.py +0 -0
- forge_doctor_api/knowledge/__init__.py +77 -0
- forge_doctor_api/knowledge/asyncapi/__init__.py +1 -0
- forge_doctor_api/knowledge/asyncapi/versions.yaml +38 -0
- forge_doctor_api/knowledge/capability.py +314 -0
- forge_doctor_api/knowledge/frameworks/__init__.py +1 -0
- forge_doctor_api/knowledge/frameworks/express.yaml +17 -0
- forge_doctor_api/knowledge/frameworks/fastapi.yaml +17 -0
- forge_doctor_api/knowledge/frameworks/spring.yaml +17 -0
- forge_doctor_api/knowledge/gateways/__init__.py +1 -0
- forge_doctor_api/knowledge/gateways/aws-api-gateway.yaml +16 -0
- forge_doctor_api/knowledge/gateways/capability-map.yaml +41 -0
- forge_doctor_api/knowledge/gateways/envoy.yaml +17 -0
- forge_doctor_api/knowledge/gateways/kong.yaml +15 -0
- forge_doctor_api/knowledge/gateways/nginx.yaml +15 -0
- forge_doctor_api/knowledge/graphql/__init__.py +1 -0
- forge_doctor_api/knowledge/graphql/spec.yaml +30 -0
- forge_doctor_api/knowledge/grpc/__init__.py +1 -0
- forge_doctor_api/knowledge/grpc/behaviors.yaml +69 -0
- forge_doctor_api/knowledge/http/__init__.py +1 -0
- forge_doctor_api/knowledge/http/semantics.yaml +27 -0
- forge_doctor_api/knowledge/jsonschema/__init__.py +1 -0
- forge_doctor_api/knowledge/jsonschema/dialects.yaml +30 -0
- forge_doctor_api/knowledge/loader.py +131 -0
- forge_doctor_api/knowledge/manifest.py +229 -0
- forge_doctor_api/knowledge/model.py +122 -0
- forge_doctor_api/knowledge/openapi/__init__.py +1 -0
- forge_doctor_api/knowledge/openapi/versions.yaml +54 -0
- forge_doctor_api/knowledge/security/__init__.py +0 -0
- forge_doctor_api/knowledge/security/owasp_api_top10.yaml +98 -0
- forge_doctor_api/lab/__init__.py +42 -0
- forge_doctor_api/lab/capabilities.py +81 -0
- forge_doctor_api/lab/loader.py +114 -0
- forge_doctor_api/lab/model.py +246 -0
- forge_doctor_api/lab/report.py +283 -0
- forge_doctor_api/lab/runner.py +299 -0
- forge_doctor_api/migrate/__init__.py +41 -0
- forge_doctor_api/migrate/engine.py +885 -0
- forge_doctor_api/migrate/graph.py +263 -0
- forge_doctor_api/migrate/model.py +124 -0
- forge_doctor_api/output/__init__.py +17 -0
- forge_doctor_api/output/writers.py +140 -0
- forge_doctor_api/perf/__init__.py +100 -0
- forge_doctor_api/perf/baseline.py +186 -0
- forge_doctor_api/perf/budget.py +100 -0
- forge_doctor_api/perf/capacity.py +244 -0
- forge_doctor_api/perf/correlation.py +91 -0
- forge_doctor_api/perf/criticalpath.py +140 -0
- forge_doctor_api/perf/experiments.py +215 -0
- forge_doctor_api/perf/fanout.py +144 -0
- forge_doctor_api/perf/payload.py +72 -0
- forge_doctor_api/perf/signals.py +152 -0
- forge_doctor_api/perf/stats.py +70 -0
- forge_doctor_api/plugins/__init__.py +51 -0
- forge_doctor_api/plugins/conformance.py +221 -0
- forge_doctor_api/plugins/manifest.py +181 -0
- forge_doctor_api/plugins/registry.py +129 -0
- forge_doctor_api/plugins/sdk.py +96 -0
- forge_doctor_api/plugins/trust.py +92 -0
- forge_doctor_api/plugins/validation.py +197 -0
- forge_doctor_api/policy/__init__.py +37 -0
- forge_doctor_api/policy/engine.py +112 -0
- forge_doctor_api/policy/loader.py +208 -0
- forge_doctor_api/policy/model.py +116 -0
- forge_doctor_api/policy/ownership.py +140 -0
- forge_doctor_api/policy/rules.py +255 -0
- forge_doctor_api/py.typed +1 -0
- forge_doctor_api/reliability/__init__.py +50 -0
- forge_doctor_api/reliability/config.py +661 -0
- forge_doctor_api/reliability/idempotency.py +46 -0
- forge_doctor_api/reliability/model.py +180 -0
- forge_doctor_api/reliability/retry.py +93 -0
- forge_doctor_api/reliability/slo.py +94 -0
- forge_doctor_api/reliability/timeout.py +84 -0
- forge_doctor_api/report.py +83 -0
- forge_doctor_api/safefix/__init__.py +27 -0
- forge_doctor_api/safefix/classify.py +52 -0
- forge_doctor_api/safefix/model.py +74 -0
- forge_doctor_api/safefix/rules.py +123 -0
- forge_doctor_api/scan.py +854 -0
- forge_doctor_api/sdk.py +205 -0
- forge_doctor_api/security/__init__.py +43 -0
- forge_doctor_api/security/knowledge.py +29 -0
- forge_doctor_api/security/model.py +257 -0
- forge_doctor_api/security/scan.py +1012 -0
- forge_doctor_api/service_graph.py +48 -0
- forge_doctor_api/temporal.py +247 -0
- forge_doctor_api/twin/__init__.py +25 -0
- forge_doctor_api/twin/assemble.py +266 -0
- forge_doctor_api/twin/drift.py +364 -0
- forge_doctor_api/twin/model.py +117 -0
- forge_doctor_api/ui/__init__.py +28 -0
- forge_doctor_api/ui/app.py +529 -0
- forge_doctor_api/ui/home.py +175 -0
- forge_doctor_api/ui/i18n.py +143 -0
- forge_doctor_api/ui/kit.py +689 -0
- forge_doctor_api/ui/screen.py +648 -0
- forge_doctor_api/ui/tui.py +124 -0
- forge_doctor_api/ui/wizard.py +309 -0
- forge_doctor_api/workspace/__init__.py +36 -0
- forge_doctor_api/workspace/impact.py +134 -0
- forge_doctor_api/workspace/links.py +319 -0
- forge_doctor_api/workspace/loader.py +123 -0
- forge_doctor_api/workspace/model.py +105 -0
- forge_doctor_api-0.2.0.dist-info/METADATA +209 -0
- forge_doctor_api-0.2.0.dist-info/RECORD +268 -0
- forge_doctor_api-0.2.0.dist-info/WHEEL +4 -0
- forge_doctor_api-0.2.0.dist-info/entry_points.txt +3 -0
- forge_doctor_api-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Forge Doctor API: deterministic API architecture intelligence.
|
|
2
|
+
|
|
3
|
+
Public surface (stable): `Doctor` facade, `DoctorReport`, core models
|
|
4
|
+
and the handoff bundle. Everything else is internal and may move —
|
|
5
|
+
import from here, not from deep module paths.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from forge_doctor_api.core.context import ProjectContext
|
|
9
|
+
from forge_doctor_api.core.discovery import ArtifactInventory
|
|
10
|
+
from forge_doctor_api.core.models import (
|
|
11
|
+
Confidence,
|
|
12
|
+
Evidence,
|
|
13
|
+
EvidenceKind,
|
|
14
|
+
Finding,
|
|
15
|
+
Model,
|
|
16
|
+
Severity,
|
|
17
|
+
SourceLocation,
|
|
18
|
+
UnknownFact,
|
|
19
|
+
)
|
|
20
|
+
from forge_doctor_api.core.plan import AnalysisPlan
|
|
21
|
+
from forge_doctor_api.core.report import DomainSummary
|
|
22
|
+
from forge_doctor_api.handoff.bundle import assemble_bundle
|
|
23
|
+
from forge_doctor_api.handoff.model import ApiHandoffBundle
|
|
24
|
+
from forge_doctor_api.handoff.protocol import (
|
|
25
|
+
ForgeHandoff,
|
|
26
|
+
ForgeReceipt,
|
|
27
|
+
ForgeRef,
|
|
28
|
+
ForgeRequest,
|
|
29
|
+
ForgeResult,
|
|
30
|
+
)
|
|
31
|
+
from forge_doctor_api.report import DoctorReport
|
|
32
|
+
from forge_doctor_api.sdk import (
|
|
33
|
+
SDK_VERSION,
|
|
34
|
+
Doctor,
|
|
35
|
+
DoctorError,
|
|
36
|
+
FindingNotFoundError,
|
|
37
|
+
ProjectUnreadableError,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__version__ = "0.2.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"SDK_VERSION",
|
|
44
|
+
"AnalysisPlan",
|
|
45
|
+
"ApiHandoffBundle",
|
|
46
|
+
"ArtifactInventory",
|
|
47
|
+
"Confidence",
|
|
48
|
+
"Doctor",
|
|
49
|
+
"DoctorError",
|
|
50
|
+
"DoctorReport",
|
|
51
|
+
"DomainSummary",
|
|
52
|
+
"Evidence",
|
|
53
|
+
"EvidenceKind",
|
|
54
|
+
"Finding",
|
|
55
|
+
"FindingNotFoundError",
|
|
56
|
+
"ForgeHandoff",
|
|
57
|
+
"ForgeReceipt",
|
|
58
|
+
"ForgeRef",
|
|
59
|
+
"ForgeRequest",
|
|
60
|
+
"ForgeResult",
|
|
61
|
+
"Model",
|
|
62
|
+
"ProjectContext",
|
|
63
|
+
"ProjectUnreadableError",
|
|
64
|
+
"Severity",
|
|
65
|
+
"SourceLocation",
|
|
66
|
+
"UnknownFact",
|
|
67
|
+
"__version__",
|
|
68
|
+
"assemble_bundle",
|
|
69
|
+
]
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Forge Graph Studio — local-first, read-only graph explorer.
|
|
2
|
+
|
|
3
|
+
A zero-dependency stdlib server (``http.server`` on 127.0.0.1) plus a
|
|
4
|
+
single-file HTML/canvas frontend embedded below. No Node, no CDN, no
|
|
5
|
+
build step — the whole app ships as Python package source.
|
|
6
|
+
|
|
7
|
+
GET-only by design: exploring a graph can never modify the project
|
|
8
|
+
(§4.4). ``serve()`` never opens a browser unless asked; ``--no-browser``
|
|
9
|
+
prints the URL for SSH port-forwarding.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import threading
|
|
16
|
+
import webbrowser
|
|
17
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
STUDIO_HTML = r"""<!doctype html>
|
|
22
|
+
<html lang="en">
|
|
23
|
+
<head>
|
|
24
|
+
<meta charset="utf-8">
|
|
25
|
+
<title>Forge Graph Studio</title>
|
|
26
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
27
|
+
<style>
|
|
28
|
+
:root{--bg:#0d1117;--panel:#161b22;--fg:#e6edf3;--dim:#8b949e;--line:#30363d;--acc:#58a6ff}
|
|
29
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);
|
|
30
|
+
font:13px/1.4 ui-monospace,Consolas,monospace;display:grid;
|
|
31
|
+
grid-template-columns:220px 1fr 280px;grid-template-rows:44px 1fr 26px;height:100vh}
|
|
32
|
+
header{grid-column:1/4;display:flex;gap:12px;align-items:center;padding:0 12px;
|
|
33
|
+
background:var(--panel);border-bottom:1px solid var(--line)}
|
|
34
|
+
header b{color:var(--acc)}select,input{background:var(--bg);color:var(--fg);
|
|
35
|
+
border:1px solid var(--line);border-radius:4px;padding:4px 6px}
|
|
36
|
+
#side,#insp{background:var(--panel);padding:10px;overflow:auto}
|
|
37
|
+
#side{border-right:1px solid var(--line)}#insp{border-left:1px solid var(--line)}
|
|
38
|
+
#wrap{position:relative;overflow:hidden}canvas{display:block;width:100%;height:100%;cursor:grab}
|
|
39
|
+
footer{grid-column:1/4;background:var(--panel);border-top:1px solid var(--line);
|
|
40
|
+
display:flex;gap:16px;padding:4px 12px;color:var(--dim)}
|
|
41
|
+
.k{word-break:break-all}.dim{color:var(--dim)}h4{margin:10px 0 4px;color:var(--dim);
|
|
42
|
+
text-transform:uppercase;font-size:11px;letter-spacing:.05em}
|
|
43
|
+
label.f{display:block;margin:2px 0;cursor:pointer}
|
|
44
|
+
#insp .pill{display:inline-block;border:1px solid var(--line);border-radius:10px;
|
|
45
|
+
padding:0 8px;margin:1px 2px;font-size:11px}
|
|
46
|
+
.ev{color:var(--acc);font-size:11px;word-break:break-all}
|
|
47
|
+
</style></head>
|
|
48
|
+
<body>
|
|
49
|
+
<header><b>FORGE GRAPH STUDIO</b>
|
|
50
|
+
<select id="gsel"></select>
|
|
51
|
+
<input id="q" placeholder="search…" size="20">
|
|
52
|
+
<span id="meta" class="dim"></span></header>
|
|
53
|
+
<div id="side"><h4>Layers</h4><div id="layers"></div>
|
|
54
|
+
<h4>Edge kinds</h4><div id="ekinds"></div>
|
|
55
|
+
<h4>Legend</h4><div id="legend" class="dim"></div></div>
|
|
56
|
+
<div id="wrap"><canvas id="cv"></canvas></div>
|
|
57
|
+
<div id="insp"><h4>Inspector</h4><div id="ibody" class="dim">select a node or edge</div></div>
|
|
58
|
+
<footer><span id="stats"></span><span id="prov"></span>
|
|
59
|
+
<span class="dim">scroll=zoom · drag=pan · click=inspect · n=neighbors ·
|
|
60
|
+
e=export · d=diff · s=snapshot · esc=reset</span></footer>
|
|
61
|
+
<script>
|
|
62
|
+
let G={nodes:[],edges:[]},L={},view={x:0,y:0,s:1},sel=null,hover=null,
|
|
63
|
+
kindsOn={},ekindsOn={},PAL={},running=true;
|
|
64
|
+
const cv=document.getElementById('cv'),cx=cv.getContext('2d');
|
|
65
|
+
const KCOLORS=['#58a6ff','#f78166','#7ee787','#ffa657','#d2a8ff','#76e3ea','#ff9bce','#e3b341'];
|
|
66
|
+
const ESTYLE={observed:[],declared:[6,3],inferred:[3,4],planned:[2,4],desired:[8,3],unknown:[1,5]};
|
|
67
|
+
function pal(k){if(!(k in PAL))PAL[k]=KCOLORS[Object.keys(PAL).length%KCOLORS.length];return PAL[k]}
|
|
68
|
+
async function loadGraphs(){const r=await fetch('/api/graphs');const d=await r.json();
|
|
69
|
+
const s=document.getElementById('gsel');s.innerHTML='';
|
|
70
|
+
d.graphs.forEach(g=>{const o=document.createElement('option');o.value=g.graph_id;
|
|
71
|
+
o.textContent=`${g.provider_id} · ${g.graph_id}`+
|
|
72
|
+
` (${g.node_count}/${g.edge_count})`;s.appendChild(o)});
|
|
73
|
+
if(d.graphs.length)loadGraph(d.graphs[0].graph_id);
|
|
74
|
+
s.onchange=e=>loadGraph(e.target.value)}
|
|
75
|
+
async function loadGraph(id){const r=await fetch('/api/graph/'+encodeURIComponent(id));
|
|
76
|
+
const d=await r.json();sel=null;L={};
|
|
77
|
+
G.nodes=(d.nodes||[]).map(n=>({...n,x:Math.random()*600-300,y:Math.random()*600-300,vx:0,vy:0,deg:0}));
|
|
78
|
+
G.edges=(d.edges||[]);
|
|
79
|
+
const byId={};G.nodes.forEach(n=>{byId[n.id]=n});
|
|
80
|
+
G.edges.forEach(e=>{if(byId[e.source])byId[e.source].deg++;if(byId[e.target])byId[e.target].deg++});
|
|
81
|
+
G.edges=G.edges.filter(e=>byId[e.source]&&byId[e.target]);
|
|
82
|
+
kindsOn={};G.nodes.forEach(n=>kindsOn[n.kind]=true);
|
|
83
|
+
ekindsOn={};G.edges.forEach(e=>ekindsOn[e.kind]=true);
|
|
84
|
+
sidePanel();document.getElementById('meta').textContent=
|
|
85
|
+
`${d.descriptor.provider_id} · ${d.descriptor.domain} · ${d.descriptor.generated_at||''}`;
|
|
86
|
+
document.getElementById('prov').textContent=
|
|
87
|
+
(d.descriptor.limitations||[]).join(' | ').slice(0,120);
|
|
88
|
+
running=true;tick()}
|
|
89
|
+
function sidePanel(){const L=document.getElementById('layers'),E=document.getElementById('ekinds');
|
|
90
|
+
L.innerHTML='';E.innerHTML='';
|
|
91
|
+
Object.keys(kindsOn).sort().forEach(k=>{L.innerHTML+=
|
|
92
|
+
`<label class="f"><input type="checkbox" checked data-k="${k}">`+
|
|
93
|
+
` <span style="color:${pal(k)}">●</span> ${k}</label>`});
|
|
94
|
+
Object.keys(ekindsOn).sort().forEach(k=>{E.innerHTML+=
|
|
95
|
+
`<label class="f"><input type="checkbox" checked data-ek="${k}"> ${k}</label>`});
|
|
96
|
+
L.querySelectorAll('input').forEach(i=>i.onchange=e=>{kindsOn[e.target.dataset.k]=e.target.checked});
|
|
97
|
+
E.querySelectorAll('input').forEach(i=>i.onchange=e=>{ekindsOn[e.target.dataset.ek]=e.target.checked});
|
|
98
|
+
document.getElementById('legend').innerHTML=
|
|
99
|
+
Object.keys(ESTYLE).map(s=>`<div>─ ${s}</div>`).join('')}
|
|
100
|
+
function tick(){let n=0;const byId={};G.nodes.forEach(x=>byId[x.id]=x);
|
|
101
|
+
for(let i=0;i<G.nodes.length;i++)for(let j=i+1;j<G.nodes.length;j++){
|
|
102
|
+
const a=G.nodes[i],b=G.nodes[j];let dx=a.x-b.x,dy=a.y-b.y,d=dx*dx+dy*dy+40;
|
|
103
|
+
const f=Math.min(2000/d,40);a.vx+=dx/d*f;a.vy+=dy/d*f;b.vx-=dx/d*f;b.vy-=dy/d*f}
|
|
104
|
+
G.edges.forEach(e=>{const a=byId[e.source],b=byId[e.target];
|
|
105
|
+
const dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1,f=(d-120)*0.02;
|
|
106
|
+
a.vx+=dx/d*f*d/120;a.vy+=dy/d*f*d/120;b.vx-=dx/d*f*d/120;b.vy-=dy/d*f*d/120});
|
|
107
|
+
G.nodes.forEach(a=>{a.vx-=a.x*0.001;a.vy-=a.y*0.001;a.vx*=0.85;a.vy*=0.85;
|
|
108
|
+
a.x+=a.vx;a.y+=a.vy;if(Math.abs(a.vx)+Math.abs(a.vy)>0.1)n++});
|
|
109
|
+
draw();if(n>0&&running)requestAnimationFrame(tick)}
|
|
110
|
+
function toS(x,y){const r=cv.getBoundingClientRect();
|
|
111
|
+
return[(x*view.s)+r.width/2+view.x,(y*view.s)+r.height/2+view.y]}
|
|
112
|
+
function draw(){const r=cv.getBoundingClientRect();
|
|
113
|
+
if(cv.width!==r.width*devicePixelRatio){cv.width=r.width*devicePixelRatio;
|
|
114
|
+
cv.height=r.height*devicePixelRatio}
|
|
115
|
+
cx.setTransform(devicePixelRatio,0,0,devicePixelRatio,0,0);
|
|
116
|
+
cx.clearRect(0,0,r.width,r.height);
|
|
117
|
+
const q=(document.getElementById('q').value||'').toLowerCase();
|
|
118
|
+
const vis={};let vc=0,ec=0;
|
|
119
|
+
G.nodes.forEach(n=>{const s=(n.label||'')+' '+n.id;
|
|
120
|
+
vis[n.id]=kindsOn[n.kind]&&(!q||s.toLowerCase().includes(q))&&
|
|
121
|
+
(!focusSet||focusSet.has(n.id));
|
|
122
|
+
if(vis[n.id])vc++});
|
|
123
|
+
G.edges.forEach(e=>{if(!vis[e.source]||!vis[e.target]||!ekindsOn[e.kind])return;ec++;
|
|
124
|
+
const a=G.nodes.find(n=>n.id===e.source),b=G.nodes.find(n=>n.id===e.target);
|
|
125
|
+
const[x1,y1]=toS(a.x,a.y),[x2,y2]=toS(b.x,b.y);
|
|
126
|
+
cx.strokeStyle=e===sel?'#f0f6fc':'#444c56';cx.lineWidth=e===sel?2:1;
|
|
127
|
+
cx.setLineDash(ESTYLE[e.epistemic_state]||ESTYLE.unknown);
|
|
128
|
+
cx.beginPath();cx.moveTo(x1,y1);cx.lineTo(x2,y2);cx.stroke();
|
|
129
|
+
// arrowhead
|
|
130
|
+
const an=Math.atan2(y2-y1,x2-x1);cx.setLineDash([]);cx.beginPath();
|
|
131
|
+
cx.moveTo(x2,y2);cx.lineTo(x2-8*Math.cos(an-0.5),y2-8*Math.sin(an-0.5));
|
|
132
|
+
cx.lineTo(x2-8*Math.cos(an+0.5),y2-8*Math.sin(an+0.5));cx.fillStyle=cx.strokeStyle;cx.fill()});
|
|
133
|
+
cx.setLineDash([]);
|
|
134
|
+
G.nodes.forEach(n=>{if(!vis[n.id])return;const[x,y]=toS(n.x,n.y);
|
|
135
|
+
const rad=4+Math.min(n.deg,12);
|
|
136
|
+
cx.beginPath();cx.arc(x,y,rad,0,7);
|
|
137
|
+
const dcol=drawDiffColors&&DC[drawDiffColors[n.id]];
|
|
138
|
+
cx.fillStyle=n===sel?'#f0f6fc':(n===hover?'#fff':(dcol||pal(n.kind)));cx.fill();
|
|
139
|
+
if(n===sel){cx.strokeStyle='#58a6ff';cx.lineWidth=2;cx.stroke()}
|
|
140
|
+
if(view.s>0.5||n.deg>4){cx.fillStyle='#8b949e';cx.font='10px monospace';
|
|
141
|
+
cx.fillText((n.label||n.id).slice(0,24),x+rad+2,y+3)}});
|
|
142
|
+
document.getElementById('stats').textContent=`${vc} nodes · ${ec} edges visible`}
|
|
143
|
+
cv.onwheel=e=>{e.preventDefault();const r=cv.getBoundingClientRect();
|
|
144
|
+
const mx=e.clientX-r.left-r.width/2,my=e.clientY-r.top-r.height/2;
|
|
145
|
+
const f=e.deltaY<0?1.15:0.87;view.x=(view.x-mx)*f+mx;view.y=(view.y-my)*f+my;
|
|
146
|
+
view.s*=f;draw()};
|
|
147
|
+
let drag=null;cv.onmousedown=e=>{const p=pick(e);drag={e,node:p.node,mx:view.x,my:view.y}};
|
|
148
|
+
cv.onmousemove=e=>{if(drag){const r=cv.getBoundingClientRect();
|
|
149
|
+
if(drag.node){drag.node.x=(e.clientX-r.left-r.width/2-view.x)/view.s;
|
|
150
|
+
drag.node.y=(e.clientY-r.top-r.height/2-view.y)/view.s}else{
|
|
151
|
+
view.x=drag.mx+e.clientX-drag.e.clientX;view.y=drag.my+e.clientY-drag.e.clientY}
|
|
152
|
+
draw()}else{const p=pick(e);hover=p.node;draw()}};
|
|
153
|
+
cv.onmouseup=e=>{const p=pick(e);
|
|
154
|
+
if(!drag||(Math.abs(e.clientX-drag.e.clientX)<4&&Math.abs(e.clientY-drag.e.clientY)<4)){
|
|
155
|
+
sel=p.node||p.edge||null;inspect()}
|
|
156
|
+
drag=null;draw()};
|
|
157
|
+
function pick(e){const r=cv.getBoundingClientRect();
|
|
158
|
+
const wx=(e.clientX-r.left-r.width/2-view.x)/view.s,
|
|
159
|
+
wy=(e.clientY-r.top-r.height/2-view.y)/view.s;
|
|
160
|
+
for(const n of G.nodes)if(Math.hypot(n.x-wx,n.y-wy)<12/view.s+4)return{node:n};
|
|
161
|
+
for(const ed of G.edges){const a=G.nodes.find(n=>n.id===ed.source),
|
|
162
|
+
b=G.nodes.find(n=>n.id===ed.target);if(!a||!b)continue;
|
|
163
|
+
const L2=(b.x-a.x)**2+(b.y-a.y)**2||1;
|
|
164
|
+
const t=Math.max(0,Math.min(1,((wx-a.x)*(b.x-a.x)+(wy-a.y)*(b.y-a.y))/L2));
|
|
165
|
+
if(Math.hypot(wx-(a.x+t*(b.x-a.x)),wy-(a.y+t*(b.y-a.y)))<6/view.s)return{edge:ed}}
|
|
166
|
+
return{}}
|
|
167
|
+
function inspect(){const b=document.getElementById('ibody');if(!sel){
|
|
168
|
+
b.innerHTML='<span class="dim">select a node or edge</span>';return}
|
|
169
|
+
let h='';
|
|
170
|
+
if(sel.kind&&sel.label!==undefined&&!sel.source){
|
|
171
|
+
h=`<div class="k"><b>${sel.label||sel.id}</b></div>
|
|
172
|
+
<span class="pill">${sel.kind}</span><span class="pill">${sel.epistemic_state}</span>
|
|
173
|
+
<span class="pill">${sel.source_provider||''}</span>
|
|
174
|
+
<h4>Attributes</h4><div class="k">${JSON.stringify(sel.attributes||{},null,1)}</div>
|
|
175
|
+
<h4>Evidence</h4>${(sel.evidence_refs||[]).map(e=>`<div class="ev">${e}</div>`)
|
|
176
|
+
.join('')||'<span class="dim">none</span>'}
|
|
177
|
+
<h4>Edges (${G.edges.filter(e=>e.source===sel.id||e.target===sel.id).length})</h4>`;
|
|
178
|
+
}else{h=`<div class="k"><b>${sel.kind}</b></div>
|
|
179
|
+
<div class="k dim">${sel.source} → ${sel.target}</div>
|
|
180
|
+
<span class="pill">${sel.epistemic_state}</span><span class="pill">${sel.provenance||''}</span>
|
|
181
|
+
${sel.confidence!=null?`<span class="pill">conf ${sel.confidence}</span>`:''}
|
|
182
|
+
<h4>Evidence</h4>${(sel.evidence_refs||[]).map(e=>`<div class="ev">${e}</div>`)
|
|
183
|
+
.join('')||'<span class="dim">none</span>'}
|
|
184
|
+
<h4>Temporal</h4><div class="k">${Object.keys(sel.temporal||{}).length?
|
|
185
|
+
JSON.stringify(sel.temporal):'none'}</div>`}
|
|
186
|
+
b.innerHTML=h}
|
|
187
|
+
document.getElementById('q').oninput=draw;
|
|
188
|
+
// --- premium interactions (Cycle 4): neighbors / export / diff / snapshot ---
|
|
189
|
+
let focusSet=null; // BFS subgraph when 'n' pressed
|
|
190
|
+
function neighbors(seed){const adj={};G.edges.forEach(e=>{
|
|
191
|
+
(adj[e.source]=adj[e.source]||new Set()).add(e.target);
|
|
192
|
+
(adj[e.target]=adj[e.target]||new Set()).add(e.source)});
|
|
193
|
+
const seen=new Set([seed]);let fr=[seed];
|
|
194
|
+
for(let d=0;d<2&&fr.length;d++){const nx=[];fr.forEach(id=>{
|
|
195
|
+
(adj[id]||[]).forEach(m=>{if(!seen.has(m)){seen.add(m);nx.push(m)}})});fr=nx}
|
|
196
|
+
return seen}
|
|
197
|
+
async function exportVisible(){const q=(document.getElementById('q').value||'').toLowerCase();
|
|
198
|
+
const vis={};G.nodes.forEach(n=>{const s=(n.label||'')+' '+n.id;
|
|
199
|
+
vis[n.id]=kindsOn[n.kind]&&(!q||s.toLowerCase().includes(q))&&(!focusSet||focusSet.has(n.id))});
|
|
200
|
+
const doc={exported_at:new Date().toISOString(),
|
|
201
|
+
nodes:G.nodes.filter(n=>vis[n.id]).map(n=>({id:n.id,kind:n.kind,label:n.label,
|
|
202
|
+
epistemic_state:n.epistemic_state,attributes:n.attributes,evidence_refs:n.evidence_refs})),
|
|
203
|
+
edges:G.edges.filter(e=>vis[e.source]&&vis[e.target]&&ekindsOn[e.kind])};
|
|
204
|
+
const a=document.createElement('a');a.href=URL.createObjectURL(
|
|
205
|
+
new Blob([JSON.stringify(doc,null,1)],{type:'application/json'}));
|
|
206
|
+
a.download='forge-graph-subgraph.json';a.click()}
|
|
207
|
+
let diffRef=null;
|
|
208
|
+
async function diffMode(){const r=await fetch('/api/graphs');const d=await r.json();
|
|
209
|
+
if(d.graphs.length<2){document.getElementById('prov').textContent=
|
|
210
|
+
'diff needs ≥2 graphs loaded';return}
|
|
211
|
+
const other=d.graphs.map(g=>g.graph_id).find(id=>id!==curId)||d.graphs[0].graph_id;
|
|
212
|
+
const rr=await fetch('/api/graph/'+encodeURIComponent(other));const o=await rr.json();
|
|
213
|
+
const on={};(o.nodes||[]).forEach(n=>on[n.id]=n);
|
|
214
|
+
const oe={};(o.edges||[]).forEach(e=>oe[e.source+'>'+e.target+':'+e.kind]=1);
|
|
215
|
+
const eadded=G.edges.filter(e=>!oe[e.source+'>'+e.target+':'+e.kind]).length;
|
|
216
|
+
let added=0,removed=0,changed=0;const cls={};
|
|
217
|
+
G.nodes.forEach(n=>{if(!on[n.id]){cls[n.id]='added';added++}else if(
|
|
218
|
+
JSON.stringify(on[n.id].attributes)!==JSON.stringify(n.attributes)||
|
|
219
|
+
on[n.id].epistemic_state!==n.epistemic_state){cls[n.id]='changed';changed++}else cls[n.id]='same'});
|
|
220
|
+
removed=(o.nodes||[]).filter(n=>!G.nodes.find(x=>x.id===n.id)).length;
|
|
221
|
+
diffRef={other,cls};
|
|
222
|
+
drawDiffColors=cls;
|
|
223
|
+
document.getElementById('prov').textContent=
|
|
224
|
+
`diff vs ${other}: +${added} ~${changed} −${removed} nodes · +${eadded} edges (esc clears)`;
|
|
225
|
+
draw()}
|
|
226
|
+
let drawDiffColors=null;
|
|
227
|
+
const DC={added:'#7ee787',removed:'#f78166',changed:'#ffa657',same:null};
|
|
228
|
+
let curId=null;
|
|
229
|
+
const _loadGraph=loadGraph;
|
|
230
|
+
loadGraph=async function(id){curId=id;diffRef=null;drawDiffColors=null;
|
|
231
|
+
await _loadGraph(id)}
|
|
232
|
+
function snapshotSave(){try{localStorage.setItem('forge-graph-snap',
|
|
233
|
+
JSON.stringify({graph:curId,view,q:document.getElementById('q').value,
|
|
234
|
+
kinds:kindsOn,ekinds:ekindsOn,when:Date.now()}));
|
|
235
|
+
document.getElementById('prov').textContent='snapshot saved (s restores)'}catch(e){}}
|
|
236
|
+
function snapshotLoad(){try{const s=JSON.parse(localStorage.getItem('forge-graph-snap')||'null');
|
|
237
|
+
if(!s)return;view=s.view;document.getElementById('q').value=s.q||'';
|
|
238
|
+
Object.assign(kindsOn,s.kinds||{});Object.assign(ekindsOn,s.ekinds||{});draw()}catch(e){}}
|
|
239
|
+
document.onkeydown=e=>{
|
|
240
|
+
if(e.key==='Escape'){view={x:0,y:0,s:1};sel=null;focusSet=null;diffRef=null;
|
|
241
|
+
drawDiffColors=null;inspect();draw()}
|
|
242
|
+
else if(e.key==='n'&&sel&&sel.id!==undefined){focusSet=focusSet?null:neighbors(sel.id);draw()}
|
|
243
|
+
else if(e.key==='e')exportVisible()
|
|
244
|
+
else if(e.key==='d')diffMode()
|
|
245
|
+
else if(e.key==='s'){localStorage.getItem('forge-graph-snap')?snapshotLoad():snapshotSave()}};
|
|
246
|
+
loadGraphs();
|
|
247
|
+
</script></body></html>
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _handler(views: dict[str, dict[str, Any]]) -> type[BaseHTTPRequestHandler]:
|
|
252
|
+
class StudioHandler(BaseHTTPRequestHandler):
|
|
253
|
+
def log_message(self, fmt: str, *args: object) -> None: # quiet
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
def _send(self, code: int, body: bytes, ctype: str) -> None:
|
|
257
|
+
self.send_response(code)
|
|
258
|
+
self.send_header("Content-Type", ctype)
|
|
259
|
+
self.send_header("Content-Length", str(len(body)))
|
|
260
|
+
self.send_header("Cache-Control", "no-store")
|
|
261
|
+
self.end_headers()
|
|
262
|
+
self.wfile.write(body)
|
|
263
|
+
|
|
264
|
+
def _json(self, code: int, doc: object) -> None:
|
|
265
|
+
self._send(code, json.dumps(doc, default=str).encode(), "application/json")
|
|
266
|
+
|
|
267
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib naming
|
|
268
|
+
path = self.path.split("?")[0].rstrip("/") or "/"
|
|
269
|
+
if path == "/":
|
|
270
|
+
self._send(200, STUDIO_HTML.encode(), "text/html; charset=utf-8")
|
|
271
|
+
return
|
|
272
|
+
if path == "/api/health":
|
|
273
|
+
self._json(200, {"status": "ok", "graphs": len(views)})
|
|
274
|
+
return
|
|
275
|
+
if path == "/api/graphs":
|
|
276
|
+
self._json(200, {"graphs": [v["descriptor"] for v in views.values()]})
|
|
277
|
+
return
|
|
278
|
+
if path.startswith("/api/graph/"):
|
|
279
|
+
gid = path[len("/api/graph/"):]
|
|
280
|
+
view = views.get(gid)
|
|
281
|
+
if view is None:
|
|
282
|
+
self._json(404, {"error": f"unknown graph {gid!r}"})
|
|
283
|
+
return
|
|
284
|
+
self._json(200, view)
|
|
285
|
+
return
|
|
286
|
+
self._json(404, {"error": "not found"})
|
|
287
|
+
|
|
288
|
+
def do_POST(self) -> None: # read-only by design (§4.4)
|
|
289
|
+
self._json(405, {"error": "Graph Studio is read-only"})
|
|
290
|
+
|
|
291
|
+
do_PUT = do_DELETE = do_PATCH = do_POST
|
|
292
|
+
|
|
293
|
+
return StudioHandler
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def serve(
|
|
297
|
+
views: list[dict[str, Any]],
|
|
298
|
+
*,
|
|
299
|
+
host: str = "127.0.0.1",
|
|
300
|
+
port: int = 0,
|
|
301
|
+
open_browser: bool = True,
|
|
302
|
+
out: Any = None,
|
|
303
|
+
) -> int:
|
|
304
|
+
"""Serve the studio until Ctrl+C. ``views`` are ForgeGraphView dicts.
|
|
305
|
+
|
|
306
|
+
Binds loopback only — never exposes graphs on the network. With
|
|
307
|
+
``open_browser=False`` (``--no-browser``, remote/SSH) the URL is
|
|
308
|
+
printed for port-forwarding instead.
|
|
309
|
+
"""
|
|
310
|
+
out = out or __import__("sys").stdout
|
|
311
|
+
by_id = {v["descriptor"]["graph_id"]: v for v in views}
|
|
312
|
+
httpd = ThreadingHTTPServer((host, port), _handler(by_id))
|
|
313
|
+
bound = httpd.server_address[1]
|
|
314
|
+
url = f"http://{host}:{bound}/"
|
|
315
|
+
if open_browser:
|
|
316
|
+
threading.Timer(0.3, lambda: webbrowser.open(url)).start()
|
|
317
|
+
print(f"Graph Studio → {url} (Ctrl+C to stop)", file=out)
|
|
318
|
+
else:
|
|
319
|
+
print(f"Graph Studio listening on {url}", file=out)
|
|
320
|
+
print(f" remote? forward with: ssh -L {bound}:127.0.0.1:{bound} <host>", file=out)
|
|
321
|
+
try:
|
|
322
|
+
httpd.serve_forever()
|
|
323
|
+
except KeyboardInterrupt:
|
|
324
|
+
pass
|
|
325
|
+
finally:
|
|
326
|
+
httpd.server_close()
|
|
327
|
+
return 0
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def graph_studio_enabled(
|
|
331
|
+
root: Path | str | None = None,
|
|
332
|
+
*,
|
|
333
|
+
state_rel: str = ".forge/install",
|
|
334
|
+
user_state_rel: str = "~/.forge/install",
|
|
335
|
+
) -> bool:
|
|
336
|
+
"""Was Graph Studio declined at install time?
|
|
337
|
+
|
|
338
|
+
``components.json`` under the forge's state dir records the optional-
|
|
339
|
+
component selection (``graph_studio: false``). Absence means the
|
|
340
|
+
default — enabled — so checkouts and pre-component installs keep
|
|
341
|
+
working.
|
|
342
|
+
"""
|
|
343
|
+
import json
|
|
344
|
+
|
|
345
|
+
candidates: list[Path] = []
|
|
346
|
+
if root:
|
|
347
|
+
candidates.append(Path(root) / state_rel / "components.json")
|
|
348
|
+
candidates.append(Path(user_state_rel).expanduser() / "components.json")
|
|
349
|
+
for p in candidates:
|
|
350
|
+
if not p.is_file():
|
|
351
|
+
continue
|
|
352
|
+
try:
|
|
353
|
+
doc = json.loads(p.read_text(encoding="utf-8"))
|
|
354
|
+
except Exception:
|
|
355
|
+
continue
|
|
356
|
+
if "graph_studio" in doc:
|
|
357
|
+
return bool(doc["graph_studio"])
|
|
358
|
+
return True
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def open_studio(
|
|
362
|
+
views: list[Any],
|
|
363
|
+
*,
|
|
364
|
+
open_browser: bool = True,
|
|
365
|
+
port: int = 0,
|
|
366
|
+
out: Any = None,
|
|
367
|
+
) -> int:
|
|
368
|
+
"""Entry for ``graph ui``: accept views or view objects, then serve."""
|
|
369
|
+
docs = [v.to_dict() if hasattr(v, "to_dict") else v for v in views]
|
|
370
|
+
if not docs:
|
|
371
|
+
print(
|
|
372
|
+
"no graphs available — produce one first (see `graph build`/`graph` docs)",
|
|
373
|
+
file=out,
|
|
374
|
+
)
|
|
375
|
+
return 2
|
|
376
|
+
return serve(docs, open_browser=open_browser, port=port, out=out)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""ForgeGraphView/v1 — the visualization contract for Graph Studio.
|
|
2
|
+
|
|
3
|
+
A read-only, rendering-ready projection of a forge's real graph. The
|
|
4
|
+
graph engines stay authoritative; this contract only *carries* their
|
|
5
|
+
data to a viewer without losing provenance, epistemic state or evidence
|
|
6
|
+
references. Layout coordinates are deliberately absent — they belong to
|
|
7
|
+
the visualization layer, not the model.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
GRAPHVIEW_SCHEMA = "forge/ForgeGraphView/v1"
|
|
18
|
+
GRAPH_DESCRIPTOR_SCHEMA = "forge/GraphDescriptor/v1"
|
|
19
|
+
|
|
20
|
+
# Epistemic states a view may carry. Engines map their own vocabulary
|
|
21
|
+
# onto these explicitly; unmapped states stay "unknown", never upgraded.
|
|
22
|
+
EPISTEMIC_STATES = (
|
|
23
|
+
"observed",
|
|
24
|
+
"declared",
|
|
25
|
+
"planned",
|
|
26
|
+
"desired",
|
|
27
|
+
"inferred",
|
|
28
|
+
"unknown",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Capabilities an adapter may declare — only real ones.
|
|
32
|
+
GRAPH_CAPABILITIES = (
|
|
33
|
+
"snapshots",
|
|
34
|
+
"node_inspect",
|
|
35
|
+
"edge_inspect",
|
|
36
|
+
"neighbors",
|
|
37
|
+
"paths",
|
|
38
|
+
"dependency_traversal",
|
|
39
|
+
"impact_analysis",
|
|
40
|
+
"snapshot_diff",
|
|
41
|
+
"temporal",
|
|
42
|
+
"search",
|
|
43
|
+
"filter",
|
|
44
|
+
"export",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Engine vocabulary → view vocabulary (explicit, reversible): a state an
|
|
49
|
+
# engine uses that has a view equivalent is translated; anything else
|
|
50
|
+
# stays "unknown" — never silently upgraded to observed/declared.
|
|
51
|
+
EPISTEMIC_TRANSLATION = {
|
|
52
|
+
"explicit": "declared",
|
|
53
|
+
"asserted": "declared",
|
|
54
|
+
"actual": "observed",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _check(state: str) -> str:
|
|
59
|
+
if state in EPISTEMIC_STATES:
|
|
60
|
+
return state
|
|
61
|
+
return EPISTEMIC_TRANSLATION.get(state, "unknown")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, kw_only=True)
|
|
65
|
+
class GraphDescriptor:
|
|
66
|
+
"""Summary of one graph a provider can expose (§1.2)."""
|
|
67
|
+
|
|
68
|
+
graph_id: str
|
|
69
|
+
provider_id: str
|
|
70
|
+
domain: str
|
|
71
|
+
graph_schema: str = GRAPH_DESCRIPTOR_SCHEMA
|
|
72
|
+
snapshot_id: str = ""
|
|
73
|
+
generated_at: str = ""
|
|
74
|
+
node_count: int = 0
|
|
75
|
+
edge_count: int = 0
|
|
76
|
+
available_layers: tuple[str, ...] = ()
|
|
77
|
+
available_queries: tuple[str, ...] = ()
|
|
78
|
+
capabilities: tuple[str, ...] = ()
|
|
79
|
+
limitations: tuple[str, ...] = ()
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict[str, Any]:
|
|
82
|
+
return asdict(self)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, kw_only=True)
|
|
86
|
+
class GraphNodeView:
|
|
87
|
+
"""One node, with engine provenance preserved (§1.3)."""
|
|
88
|
+
|
|
89
|
+
id: str
|
|
90
|
+
kind: str
|
|
91
|
+
label: str
|
|
92
|
+
domain: str = ""
|
|
93
|
+
source_provider: str = ""
|
|
94
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
95
|
+
epistemic_state: str = "unknown"
|
|
96
|
+
evidence_refs: tuple[str, ...] = ()
|
|
97
|
+
snapshot_ref: str = ""
|
|
98
|
+
|
|
99
|
+
def __post_init__(self) -> None:
|
|
100
|
+
object.__setattr__(self, "epistemic_state", _check(self.epistemic_state))
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict[str, Any]:
|
|
103
|
+
return asdict(self)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, kw_only=True)
|
|
107
|
+
class GraphEdgeView:
|
|
108
|
+
"""One directed edge; direction and semantics preserved (§1.4)."""
|
|
109
|
+
|
|
110
|
+
id: str
|
|
111
|
+
source: str
|
|
112
|
+
target: str
|
|
113
|
+
kind: str
|
|
114
|
+
direction: str = "directed"
|
|
115
|
+
provenance: str = "unknown"
|
|
116
|
+
epistemic_state: str = "unknown"
|
|
117
|
+
evidence_refs: tuple[str, ...] = ()
|
|
118
|
+
confidence: float | None = None
|
|
119
|
+
temporal: dict[str, Any] = field(default_factory=dict)
|
|
120
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
121
|
+
|
|
122
|
+
def __post_init__(self) -> None:
|
|
123
|
+
object.__setattr__(self, "epistemic_state", _check(self.epistemic_state))
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> dict[str, Any]:
|
|
126
|
+
d = asdict(self)
|
|
127
|
+
if self.confidence is None:
|
|
128
|
+
d.pop("confidence")
|
|
129
|
+
return d
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, kw_only=True)
|
|
133
|
+
class ForgeGraphView:
|
|
134
|
+
"""A complete view document: descriptor + nodes + edges."""
|
|
135
|
+
|
|
136
|
+
descriptor: GraphDescriptor
|
|
137
|
+
nodes: tuple[GraphNodeView, ...] = ()
|
|
138
|
+
edges: tuple[GraphEdgeView, ...] = ()
|
|
139
|
+
schema: str = GRAPHVIEW_SCHEMA
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> dict[str, Any]:
|
|
142
|
+
return {
|
|
143
|
+
"schema": self.schema,
|
|
144
|
+
"descriptor": self.descriptor.to_dict(),
|
|
145
|
+
"nodes": [n.to_dict() for n in self.nodes],
|
|
146
|
+
"edges": [e.to_dict() for e in self.edges],
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def from_dict(doc: dict[str, Any]) -> ForgeGraphView:
|
|
151
|
+
d = doc["descriptor"]
|
|
152
|
+
descriptor = GraphDescriptor(
|
|
153
|
+
**{k: tuple(v) if isinstance(v, list) else v for k, v in d.items()}
|
|
154
|
+
)
|
|
155
|
+
nodes = tuple(
|
|
156
|
+
GraphNodeView(
|
|
157
|
+
**{k: tuple(v) if isinstance(v, list) else v for k, v in n.items()}
|
|
158
|
+
)
|
|
159
|
+
for n in doc.get("nodes", [])
|
|
160
|
+
)
|
|
161
|
+
edges = tuple(
|
|
162
|
+
GraphEdgeView(
|
|
163
|
+
**{k: tuple(v) if isinstance(v, list) else v for k, v in e.items()}
|
|
164
|
+
)
|
|
165
|
+
for e in doc.get("edges", [])
|
|
166
|
+
)
|
|
167
|
+
return ForgeGraphView(descriptor=descriptor, nodes=nodes, edges=edges)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def new_descriptor(*, provider_id: str, domain: str, graph_id: str | None = None,
|
|
171
|
+
capabilities: tuple[str, ...] = (),
|
|
172
|
+
limitations: tuple[str, ...] = ()) -> GraphDescriptor:
|
|
173
|
+
"""A descriptor with real provenance stamps."""
|
|
174
|
+
return GraphDescriptor(
|
|
175
|
+
graph_id=graph_id or f"graph-{uuid.uuid4().hex[:8]}",
|
|
176
|
+
provider_id=provider_id,
|
|
177
|
+
domain=domain,
|
|
178
|
+
generated_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
179
|
+
capabilities=tuple(c for c in capabilities if c in GRAPH_CAPABILITIES),
|
|
180
|
+
limitations=limitations,
|
|
181
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""AsyncAPI semantic model (§20)."""
|
|
2
|
+
|
|
3
|
+
from forge_doctor_api.analyzers.asyncapi.compat import diff_asyncapi_models
|
|
4
|
+
from forge_doctor_api.analyzers.asyncapi.graph import async_graph
|
|
5
|
+
from forge_doctor_api.analyzers.asyncapi.model import (
|
|
6
|
+
ASYNCAPI_MODEL_SCHEMA_VERSION,
|
|
7
|
+
AsyncAction,
|
|
8
|
+
AsyncApiChannel,
|
|
9
|
+
AsyncApiDocument,
|
|
10
|
+
AsyncApiIssue,
|
|
11
|
+
AsyncApiMessage,
|
|
12
|
+
AsyncApiOperation,
|
|
13
|
+
AsyncApiProjectModel,
|
|
14
|
+
AsyncApiSchema,
|
|
15
|
+
AsyncApiSecurityRequirement,
|
|
16
|
+
AsyncApiServer,
|
|
17
|
+
AsyncDocumentStatus,
|
|
18
|
+
AsyncIssueCode,
|
|
19
|
+
)
|
|
20
|
+
from forge_doctor_api.analyzers.asyncapi.parser import load_asyncapi_project
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ASYNCAPI_MODEL_SCHEMA_VERSION",
|
|
24
|
+
"AsyncAction",
|
|
25
|
+
"AsyncApiChannel",
|
|
26
|
+
"AsyncApiDocument",
|
|
27
|
+
"AsyncApiIssue",
|
|
28
|
+
"AsyncApiMessage",
|
|
29
|
+
"AsyncApiOperation",
|
|
30
|
+
"AsyncApiProjectModel",
|
|
31
|
+
"AsyncApiSchema",
|
|
32
|
+
"AsyncApiSecurityRequirement",
|
|
33
|
+
"AsyncApiServer",
|
|
34
|
+
"AsyncDocumentStatus",
|
|
35
|
+
"AsyncIssueCode",
|
|
36
|
+
"async_graph",
|
|
37
|
+
"diff_asyncapi_models",
|
|
38
|
+
"load_asyncapi_project",
|
|
39
|
+
]
|