arch-design-agent-skill-das.../docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md
openclaw c97f201a9c docs: fix spec issues from review — path normalization, field names, dedup
- Fix doc.source_file → doc.file_path (actual DesignDocument field name)
- Replace lstrip("./") with proper path resolution helpers
- Add deduplication for doc→doc edges (process only downstream)
- Add frontend/src/shared/types/api.ts to files changed
- Add fallback for unknown group_id in forceX/forceY
- Note build_panorama needs design_dir parameter
- Clarify runtime_component/document detail panel behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 07:39:33 +00:00

17 KiB
Raw Blame History

V2 Gap Fix Design — 7 Gaps (P0+P1)

Date: 2026-03-24 Branch: feat/v2-fix-gaps Status: Design

1. Problem Statement

The current graph visualization has three critical issues:

  1. All 63 nodes share a single forceCenter, producing a "hairball" layout with no group separation
  2. All node statuses are hardcoded to "unknown" — node colors are meaningless grey
  3. DesignDocument upstream/downstream edges are dead code (file paths vs node IDs mismatch)

Additionally, the detail panel is nearly empty, there's no way to return from drill-down, no edit shortcut, and no legend.

2. Design Decisions

DD-1: Architecture Layer Layout (方案 A)

Y-axis represents abstraction level, matching TOGAF/ArchiMate conventions:

group_id        targetX(ratio)  targetY(ratio)
business        0.50            0.15
application     0.50            0.38
data            0.30            0.65
technology      0.70            0.65
cross-layer     0.50            0.85

DD-2: Compound Graph for Document View

  • Document nodes are containers; entity nodes nest inside their parent document
  • Toggle between default (group-partitioned) and document view modes
  • Both doc→doc edges and entity→entity edges coexist

DD-3: Status Mapping via source_file

  • Entity type → source CSV file → FileStatus from ScanResult
  • Same-CSV entities share that file's status (one CSV = one entity type's completeness)

3. Gap Specifications


3.1 GAP-B1: Node Status Mapping

Layer: Backend Priority: P0 File: backend/app/modules/graph/application/services.py

Current behavior: GraphService.build_panorama() hardcodes status="unknown" for all nodes.

Target behavior: Each node gets its status from the Scanner's FileStatus for the source CSV file that contains that entity type.

Implementation:

Note: build_panorama() will need an additional design_dir: str parameter to convert DesignDocument.file_path (absolute) to design-dir-relative paths. This parameter is passed from the route handler which already has access to the project's design directory.

  1. Build file_status_map: dict[str, str] from scan_result.file_statuses:

    file_status_map = {fs.path: fs.status.value for fs in scan_result.file_statuses}
    
  2. Define entity-type-to-source-file mapping (from design/data-architecture/01-entities.csv):

    _SOURCE_FILES = {
        "capability": "business-architecture/02-capability-map.csv",
        "module": "application-architecture/02-modules.csv",
        "entity": "data-architecture/01-entities.csv",
        "runtime_component": "technology-architecture/01-runtime-components.csv",
    }
    
  3. When creating each node:

    status = file_status_map.get(_SOURCE_FILES.get(node_type, ""), "unknown")
    

Acceptance criteria:

  • Nodes have real status values (ok/sparse/missing/template-residue/placeholder-heavy) instead of "unknown"
  • Nodes without a known source file retain "unknown"

3.2 GAP-B3: DesignDocument Edges — Compound Graph

Layer: Backend Priority: P0 File: backend/app/modules/graph/domain/entities.py, backend/app/modules/graph/application/services.py

Current behavior: Step 9 in build_panorama() tries to match doc.upstream/doc.downstream (file paths like ./02-capability-map.csv) against node IDs. Always fails because (a) paths ≠ IDs and (b) DesignDocuments are never added as nodes.

Target behavior:

  1. DesignDocument objects become graph nodes (type="document", group="cross-layer")
  2. Entity nodes get a parent field pointing to their containing document's doc_id
  3. Two edge types: doc→doc (relation="documents") and entity→entity (existing relations unchanged)

Domain model change — GraphNode:

@dataclass
class GraphNode:
    id: str
    type: str          # capability | module | entity | runtime_component | document
    label: str
    status: str
    group_id: str
    parent: str | None = None  # doc_id of containing document, if any

Implementation in build_panorama():

Path normalization note: DesignDocument.file_path is an absolute path (e.g., /home/user/project/design/business-architecture/01-scope-and-goals.md), while FileStatusEntry.path is relative to the design directory (e.g., business-architecture/01-scope-and-goals.md). The upstream/downstream fields in frontmatter are relative paths (e.g., ../business-architecture/01-scope-and-goals.md). All paths must be normalized to design-dir-relative format before comparison.

Helper function:

def _to_rel_path(doc_file_path: str, design_dir: str) -> str:
    """Convert absolute doc.file_path to design-dir-relative path."""
    from pathlib import PurePosixPath
    try:
        return str(PurePosixPath(doc_file_path).relative_to(design_dir))
    except ValueError:
        return doc_file_path

def _resolve_ref_path(ref_path: str, doc_rel_path: str) -> str:
    """Resolve a relative upstream/downstream ref against the doc's directory."""
    from pathlib import PurePosixPath
    doc_dir = str(PurePosixPath(doc_rel_path).parent)
    resolved = str(PurePosixPath(doc_dir) / ref_path)
    # Normalize away ../ segments
    parts = []
    for part in PurePosixPath(resolved).parts:
        if part == '..':
            if parts:
                parts.pop()
        else:
            parts.append(part)
    return str(PurePosixPath(*parts)) if parts else ""

Step 5.5 — Create document nodes:

file_to_doc: dict[str, str] = {}
for doc in scan_result.design_documents:
    doc_rel = _to_rel_path(doc.file_path, design_dir)
    file_to_doc[doc_rel] = doc.doc_id
    nodes.append(GraphNode(
        id=doc.doc_id,
        type="document",
        label=doc.title or doc.doc_id,
        status=file_status_map.get(doc_rel, "unknown"),
        group_id="cross-layer",
    ))
    node_ids.add(doc.doc_id)

Entity nodes get parent:

parent_doc_id = file_to_doc.get(_SOURCE_FILES.get(node_type))
node = GraphNode(..., parent=parent_doc_id)

Step 9 — Fix doc→doc edges using file path mapping (with deduplication):

path_to_doc = {}
doc_rel_paths = {}
for doc in scan_result.design_documents:
    doc_rel = _to_rel_path(doc.file_path, design_dir)
    path_to_doc[doc_rel] = doc.doc_id
    doc_rel_paths[doc.doc_id] = doc_rel

seen_edges: set[tuple[str, str]] = set()
for doc in scan_result.design_documents:
    doc_rel = doc_rel_paths[doc.doc_id]
    for down_path in doc.downstream:
        resolved = _resolve_ref_path(down_path, doc_rel)
        down_doc_id = path_to_doc.get(resolved)
        if down_doc_id and down_doc_id in node_ids:
            edge_key = (doc.doc_id, down_doc_id)
            if edge_key not in seen_edges:
                seen_edges.add(edge_key)
                edges.append(GraphEdge(source=doc.doc_id, target=down_doc_id, relation="documents"))
    # Only process downstream to avoid duplicates (A.downstream=B ↔ B.upstream=A)

Acceptance criteria:

  • Document nodes appear in the graph (type="document", group="cross-layer")
  • Entity nodes have correct parent references
  • doc→doc edges are created based on upstream/downstream file path resolution
  • API response includes parent field (null for nodes without a parent)

3.3 GAP-F1: Group-Partitioned Layout + Compound Layout

Layer: Frontend Priority: P0 File: frontend/src/modules/graph/components/GraphPanorama.vue

Current behavior: Single d3.forceCenter() for all nodes — all groups overlap.

Target behavior: Two layout modes controlled by a toggle.

Default Mode (Document View OFF)

  • Filter out nodes where type === "document" and edges where relation === "documents"
  • Use d3.forceX / d3.forceY per group with strength ~0.3-0.5:
    const groupPositions = {
      business:     { x: 0.50, y: 0.15 },
      application:  { x: 0.50, y: 0.38 },
      data:         { x: 0.30, y: 0.65 },
      technology:   { x: 0.70, y: 0.65 },
      'cross-layer': { x: 0.50, y: 0.85 },
    }
    
    simulation
      .force('x', d3.forceX(d => (groupPositions[d.group_id] || groupPositions['cross-layer']).x * width).strength(0.4))
      .force('y', d3.forceY(d => (groupPositions[d.group_id] || groupPositions['cross-layer']).y * height).strength(0.4))
      .force('collide', d3.forceCollide(30))
      .force('link', d3.forceLink(edges).id(d => d.id).distance(60))
    
  • Remove the single forceCenter — forceX/forceY provide the centering per group

Document View Mode (Toggle ON)

  • Show all nodes including documents
  • Document nodes rendered as large <rect> containers, sized by child count:
    const childCount = nodes.filter(n => n.parent === doc.id).length
    doc.width = Math.max(150, childCount * 60)
    doc.height = Math.max(100, childCount * 40)
    
  • Document containers participate in force simulation (with high mass to resist movement)
  • Entity nodes with parent are constrained inside their parent's bounds on each tick:
    simulation.on('tick', () => {
      nodes.forEach(n => {
        if (n.parent) {
          const p = nodeMap[n.parent]
          const pad = 20
          n.x = clamp(n.x, p.x - p.width/2 + pad, p.x + p.width/2 - pad)
          n.y = clamp(n.y, p.y - p.height/2 + pad, p.y + p.height/2 - pad)
        }
      })
    })
    
  • doc→doc edges rendered between container borders
  • entity→entity edges rendered normally (may cross container boundaries)

Toggle

  • State: showDocumentView: ref(false)
  • UI: Toggle button in toolbar area
  • Switching destroys current simulation and reinitializes with the appropriate mode

Node Rendering

Shapes (both modes):

type shape size
capability circle r=18
module rect 28×28
entity diamond (rotated rect) 24×24
runtime_component circle r=14
document large rect container dynamic

Colors (by status):

status color
ok #4CAF50
sparse #FFC107
missing #F44336
template-residue #FF9800
placeholder-heavy #9C27B0
unknown #9E9E9E

Edge styles:

relation style
traces_to solid, #666
depends_on dashed, #999
documents solid, #42A5F5 (blue)
integrates_with dotted, #AB47BC

Acceptance criteria:

  • Default mode: nodes cluster by group in 5 distinct regions, no "hairball"
  • Document view: documents render as containers with entities nested inside
  • Toggle smoothly switches between modes
  • Nodes have correct shapes and colors

3.4 GAP-F2: Rich GraphDetail Panel

Layer: Frontend Priority: P1 File: frontend/src/modules/graph/components/GraphDetail.vue

Current behavior: Shows only id, type, status, group_id.

Target behavior: Full attribute display + related entity list, fetched from detail APIs.

API calls by node type:

node.type endpoint response type
capability GET /entities/capabilities/{id} CapabilityDetail
module GET /entities/modules/{id} ModuleDetail
entity GET /entities/entities/{id} EntityDetail
document no API call, use node properties
runtime_component no API call, use node properties

Note on non-API types: For document and runtime_component nodes that have no detail API, the panel displays the basic GraphNode fields (id, type, label, status, group_id, parent). This is acceptable — these types have limited attributes. A detail API for these types can be added later if needed.

Panel layout:

┌─ GraphDetail ──────────────────────────┐
│ ✕ Close                                │
│                                        │
│ ● CAP-001                       [Edit] │
│ capability · business                  │
│                                        │
│ ── Attributes ──                       │
│ name: 项目管理                          │
│ description: 管理项目生命周期...         │
│ owner: ...                             │
│                                        │
│ ── Related Entities ──                 │
│ → MOD-001 项目管理模块  (traces_to)     │
│ → MOD-003 扫描服务      (traces_to)     │
│ ← DOC-005 scope文档  (documents)       │
│                                        │
└────────────────────────────────────────┘

Behavior:

  • On node selection: if type has detail API, fetch it (show spinner while loading)
  • Error fallback: show basic 4 fields
  • Clicking a related entity → updates selected node → panel refreshes
  • Related entities are derived from graphView.edges where source or target matches the selected node

Acceptance criteria:

  • Detail API is called for capability/module/entity nodes
  • All attributes from the API response are displayed
  • Related entities list is clickable and navigates the graph
  • Graceful fallback on API error

3.5 GAP-F5: Edit Button in GraphDetail

Layer: Frontend Priority: P1 File: frontend/src/modules/graph/components/GraphDetail.vue

Current behavior: No way to jump from graph node to editor.

Target behavior: "Edit" button in the detail panel header, navigating to the editor page.

Implementation:

  • Button visible only when source_file is available:
    • From detail API response (CapabilityDetail, ModuleDetail, EntityDetail have this field)
    • For document nodes: use node properties
    • For runtime_component: use static _SOURCE_FILES mapping
  • Click action:
    router.push({
      path: `/projects/${projectId}/editor`,
      query: { file: sourceFilePath }
    })
    

Acceptance criteria:

  • Edit button appears for nodes with known source files
  • Clicking navigates to editor with correct file pre-selected
  • Button hidden for nodes without source file info

3.6 GAP-F4: Back to Panorama Button

Layer: Frontend Priority: P1 File: frontend/src/modules/graph/components/GraphPanorama.vue

Current behavior: Double-click drills down to neighbor subgraph; no way to return.

Target behavior: Floating "back" button in drill-down mode.

Implementation:

  • State: isDrillDown: ref(false), drillDownNodeLabel: ref('')
  • On double-click drill-down: set isDrillDown = true, store the node label
  • Render (conditionally):
    <div v-if="isDrillDown" class="drill-down-bar">
      <button @click="returnToPanorama">← 返回全景图</button>
      <span>当前: {{ drillDownNodeLabel }}</span>
    </div>
    
  • returnToPanorama(): calls existing loadGraph() method, sets isDrillDown = false

Acceptance criteria:

  • Button appears only in drill-down mode
  • Shows which node was drilled into
  • Clicking restores the full panorama graph

3.7 GAP-F3: Graph Legend

Layer: Frontend Priority: P1 File: New frontend/src/modules/graph/components/GraphLegend.vue, imported in GraphPanorama.vue

Design:

┌─ Legend ────────────────────┐
│ Shapes            Status    │
│ ○ Capability      ● OK      │
│ ■ Module          ● Sparse  │
│ ◇ Entity          ● Missing │
│ ○ Runtime Comp    ● Template│
│ ▭ Document        ● Placeholder │
│                   ● Unknown │
│ Edges                       │
│ ── traces_to               │
│ -- depends_on              │
│ ━━ documents               │
│ ·· integrates_with         │
└────────────────────────────┘

Implementation:

  • Positioned bottom-right of the graph canvas, position: absolute
  • Semi-transparent background (rgba(255,255,255,0.9))
  • Collapsible: click title to toggle expand/collapse
  • Default: expanded
  • Uses actual SVG shapes/colors matching the graph rendering

Acceptance criteria:

  • Legend renders in bottom-right corner
  • Shows all node shapes, status colors, and edge styles
  • Collapsible
  • Does not block graph interaction (pointer-events on legend only)

4. Files Changed

File Change Type Gaps
backend/app/modules/graph/domain/entities.py Modify B3
backend/app/modules/graph/application/services.py Modify B1, B3
frontend/src/modules/graph/components/GraphPanorama.vue Modify F1, F4
frontend/src/modules/graph/components/GraphDetail.vue Modify F2, F5
frontend/src/modules/graph/components/GraphLegend.vue New F3
frontend/src/shared/types/api.ts Modify B3 (add parent field to GraphNode interface)

5. Out of Scope

The following gaps from the full analysis are NOT addressed in this spec:

  • GAP-X1 (layered drill-down) — Phase 2
  • GAP-X2 (implementation progress on nodes) — Phase 2
  • GAP-B2 (more entity types as nodes) — separate effort
  • GAP-B4 (recursive impact traversal) — separate effort
  • GAP-B5 (LLM-based impl tracking) — separate effort
  • GAP-F6 (route path alignment) — cosmetic
  • GAP-F7 (panel position conflict) — cosmetic

6. Constraints

  1. Do not modify files under design/ — those are the source of truth
  2. All changes must pass vue-tsc (frontend) and Python type checking (backend)
  3. Implementation order follows DDD layers: Domain → Infrastructure → Application → Interfaces