From 6584df066638c4ed1a2590d7127381c34b0f8056 Mon Sep 17 00:00:00 2001 From: Oleg Lukasonok Date: Sun, 6 Sep 2026 17:01:50 +0300 Subject: [PATCH] Fix review findings: importer DoS, sync data loss, views/profile rendering, docs Source importers: lstat before open so FIFOs no longer hang the directory walk; replace the quadratic Rust macro regex with a linear scan. Three-way sync: report page add/add and delete-vs-modify as conflicts instead of silently overwriting or resurrecting; canonical comparison so key order is not a change; the sync action withholds output and fails on conflicts unless --force. Story publishing escapes <, >, & and U+2028/9 inside the embedded JSON. Views: drop parentId of unselected containers, re-layout instead of manual geometry, validate before serialising; flatten() merges identical nodes repeated across pages so C4 output works with every analysis action. Dark theme edge labels get a background; tube-map corridors sit above the stations; C4 containers keep the swimlane style and orphan relationships land on the matching page; router keeps container header bands as obstacles; sequence self-messages loop on one side. Docs: SKILL.md lists all 23 CLI actions and how each capability family is invoked, task wrappers for query/test/what-if/doctor, capability tables and --page scope corrected, maintenance snippet uses the real synchronous action signature, duplicated rule bullets moved to the rule references, coverage matrix wording made verifiable. Co-Authored-By: Claude Code --- README.md | 2 +- SKILL.md | 133 ++++++------------ references/agents365-capability-coverage.md | 18 +-- references/capabilities.md | 131 ++++++++++------- references/maintenance.md | 31 ++-- references/rules-layout.md | 44 +++++- references/rules-style.md | 11 +- references/semantic-lifecycle.md | 2 +- scripts/.scripts/cli/Taskfile.yml | 8 +- scripts/Taskfile.yml | 48 ++++++- scripts/src/actions/sync/action.ts | 10 +- scripts/src/actions/views/action.test.ts | 80 +++++++++++ scripts/src/actions/views/action.ts | 12 +- scripts/src/cli/commands.ts | 8 +- .../orthogonal-router.test.ts | 63 +++++++++ .../authoring-router/orthogonal-router.ts | 22 ++- scripts/src/services/profiles/c4.test.ts | 28 +++- scripts/src/services/profiles/c4.ts | 15 +- .../src/services/profiles/sequence.test.ts | 34 +++++ scripts/src/services/profiles/sequence.ts | 10 +- .../src/services/profiles/tube-map.test.ts | 28 ++++ scripts/src/services/profiles/tube-map.ts | 7 +- .../semantic-lifecycle/analysis.test.ts | 42 ++++++ .../services/semantic-lifecycle/analysis.ts | 64 ++++++++- .../semantic-lifecycle/publishing.test.ts | 9 ++ .../services/semantic-lifecycle/publishing.ts | 3 +- .../services/semantic-lifecycle/sync.test.ts | 52 +++++++ .../src/services/semantic-lifecycle/sync.ts | 28 +++- .../services/source-importers/index.test.ts | 20 +++ .../src/services/source-importers/index.ts | 22 ++- .../src/services/themes/theme-service.test.ts | 15 ++ scripts/src/services/themes/theme-service.ts | 10 +- 32 files changed, 801 insertions(+), 209 deletions(-) create mode 100644 scripts/src/actions/views/action.test.ts diff --git a/README.md b/README.md index df88de8..5ff5bb1 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Built with TypeScript using [`@maxgraph/core`](https://github.com/maxGraph/maxGr ```bash cd scripts -pnpm install +pnpm install --frozen-lockfile ``` ## Build diff --git a/SKILL.md b/SKILL.md index 5f90a7c..cb8041f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: diagrams-drawio -description: Always use when user asks to create, generate, draw, or design a diagram, flowchart, architecture diagram, ER diagram, sequence diagram, class diagram, network diagram, mockup, wireframe, or UI sketch, or mentions draw.io, drawio, drawoi, .drawio files, or diagram export to PNG/SVG/PDF. +description: Generate, analyse, edit and publish draw.io diagrams. Always use when user asks to create, generate, draw, or design a diagram, flowchart, architecture diagram, ER diagram, sequence diagram, class diagram, network diagram, mockup, wireframe, or UI sketch, or mentions draw.io, drawio, drawoi, .drawio files, or diagram export to PNG/SVG/PDF. license: Proprietary metadata: author: workspace-swiss-knife @@ -14,15 +14,15 @@ compatibility: Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, an # Draw.io Diagram Skill -This skill covers seven capability families: +This skill covers seven capability families (same headings and order as [references/capabilities.md](./references/capabilities.md)): -1. **Deterministic YAML generation** — validate v1/v2 semantic Diagram IR and build native multi-page `.drawio` XML with stable IDs, dependency-aware layout, and obstacle-aware routing -2. **Direct XML generation** — create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements -3. **Diagram analysis** — run the `drawio-tools` CLI to analyse an existing `.drawio` file: inventory shapes and connectors, validate layout quality, detect overlaps/orphans, and recommend page sizes -4. **Semantic lifecycle** — loss-aware Draw.io import, transactional stable-ID edits, linked views, semantic query/policy/what-if analysis, three-way synchronization, and self-contained offline story publishing -5. **Safe source importers** — bounded native TypeScript subsets for Python, JavaScript/TypeScript, Go, Rust, Terraform, Kubernetes, Docker Compose, SQL, OpenAPI, and CI dependency graphs -6. **Toolbox transforms** — themes, offline generic shape search, reverse Mermaid/Markdown, semantic diff, strict relabeling, and accessible heatmaps -7. **Specialized profiles** — C4, sequence, tube map, compression, runbook, timelapse, and dependency-ordered build-up services +1. **Deterministic YAML generation** — validate v1/v2 semantic Diagram IR and build native multi-page `.drawio` XML with stable IDs, dependency-aware layout, and obstacle-aware routing. Invoked as CLI action `build` / `task generate` +2. **Direct XML generation** — create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements. Agent-authored XML (this file), then CLI action `validate` / `task validate` +3. **Diagram analysis** — analyse an existing `.drawio` file: inventory shapes and connectors, validate layout quality, detect overlaps/orphans, and recommend page sizes. Invoked as CLI actions `summary`, `page-*`, `quality`, `validate` via `task run -- --action=` +4. **Semantic lifecycle** — loss-aware Draw.io import, transactional stable-ID edits, linked views, semantic query/policy/what-if analysis, three-way synchronization, and self-contained offline story publishing. Invoked as CLI actions `import`, `edit`, `views`, `query`, `test`, `what-if`, `sync`, `story`, `doctor` (each has a same-named `task`) +5. **Safe source importers** — bounded native TypeScript subsets for Python, JavaScript/TypeScript, Go, Rust, Terraform, Kubernetes, Docker Compose, SQL, OpenAPI, and CI dependency graphs. Library only: `import { importSource } from 'dist/services/source-importers/index.js'`; not reachable as a CLI action +6. **Toolbox transforms** — themes, offline generic shape search, reverse Mermaid/Markdown, semantic diff, strict relabeling, and accessible heatmaps. Library only: `import ... from 'dist/services/transforms/{reverse,semantic-diff,relabel,heatmap}.js'`, `'dist/services/themes/theme-service.js'`, `'dist/services/shape-catalog/shape-catalog.js'`; not reachable as a CLI action +7. **Specialized profiles** — C4, sequence, tube map, compression, runbook, timelapse, and dependency-ordered build-up services. Library only: `import ... from 'dist/services/profiles/index.js'`; not reachable as a CLI action **Reference files** (read these when using this skill): - [references/capabilities.md](./references/capabilities.md) — full list of capabilities and all CLI analysis actions an agent can execute @@ -33,21 +33,24 @@ This skill covers seven capability families: - [references/routing-best-practices.md](./references/routing-best-practices.md) — corridor planning, routing patterns, overlap verification, swimlane routing, validation workflow - [references/maintenance.md](./references/maintenance.md) — maintaining and rebuilding the skill itself - [references/semantic-lifecycle.md](./references/semantic-lifecycle.md) — import, edit, views, query, policy, what-if, sync, story, and doctor workflows -- [references/agents365-capability-coverage.md](./references/agents365-capability-coverage.md) — strict evidence matrix for all 42 compared peer tools, including partial and deferred scope +- [references/agents365-capability-coverage.md](./references/agents365-capability-coverage.md) — internal comparison matrix against a 42-entry peer-tool feature list (not independently verifiable), including partial and deferred scope ## Available scripts -This skill ships the `drawio-tools` TypeScript CLI under `scripts/`. All commands run via `task` from the `scripts/` directory (one-time setup: `pnpm install` + `task build`): +This skill ships the `drawio-tools` TypeScript CLI under `scripts/`. All commands run via `task` from the `scripts/` directory (one-time setup: `pnpm install --frozen-lockfile` + `task build`): ```bash cd /scripts -# Run any analysis action against a .drawio file +# Run any action against a .drawio file or Diagram IR model task run -- --file="/path/to/diagram.drawio" --action=page-connectors-validation -# Actions: summary, page-summary, page-hierarchy, page-connectors-summary, +# Analysis actions: summary, page-summary, page-hierarchy, page-connectors-summary, # page-connectors-validation, page-labels-validation, page-shape-bbox-validation, # page-orphans, page-recommendations, page-hierarchy-full, # page-negative-space-summary, quality, validate +# Lifecycle actions: import, edit, views, query, test, what-if, sync, story, doctor +# (see references/semantic-lifecycle.md) +# Authoring action: build (task generate) # Validate a .drawio file (mandatory final gate) task validate -- --file="/path/to/diagram.drawio" @@ -58,20 +61,31 @@ task generate -- --file="/path/to/spec.yaml" --output="/path/to/diagram.drawio" # Import an editable Draw.io file to loss-aware semantic IR task import -- --file="/path/to/diagram.drawio" --output="/path/to/model.yaml" -# Apply one atomic stable-ID edit batch +# Apply one atomic stable-ID edit batch (examples/edit-batch.yaml targets examples/platform-v2.yaml) task edit -- --file="/path/to/model.yaml" --spec="/path/to/edit-batch.yaml" --output="/path/to/edited.drawio" +# Query, policy-test, and what-if analyse a model +task query -- --file="/path/to/model.yaml" --kind=service +task test -- --file="/path/to/model.yaml" --strict +task what-if -- --file="/path/to/model.yaml" --fail=api + +# Three-way sync: --base = previous generated, --file = manually edited, --spec = newly generated +task sync -- --base="/path/to/before.yaml" --file="/path/to/edited.yaml" --spec="/path/to/after.yaml" --output="/path/to/synced.drawio" + # Generate linked audience views or a self-contained offline story task views -- --file="/path/to/model.yaml" --views="executive,system,security" --output="/path/to/views.drawio" task story -- --file="/path/to/model.yaml" --output="/path/to/story.html" +# Report optional local backends (no --file needed) +task doctor + # Build TypeScript to dist/ task build ``` See [references/capabilities.md](./references/capabilities.md) for what every action outputs. -Source importers, toolbox transforms, and specialized profiles are currently native TypeScript service APIs, not additional CLI actions. Do not invent action names for them; use the exported services or the documented lifecycle actions. +Source importers, toolbox transforms, and specialized profiles (families 5–7 above) are native TypeScript service APIs, not CLI actions. Do not invent action names for them; import the listed `dist/services/...` modules or use the documented lifecycle actions. --- @@ -79,7 +93,7 @@ Generate draw.io diagrams as native `.drawio` files. Optionally export to PNG, S ## How to create a diagram -1. **Prefer YAML Diagram IR for repeatable diagrams** — use v1 for simple single-page diagrams or v2 for multiple pages, semantic kinds, explicit geometry/waypoints, provenance, and deterministic `linear`, `layered`, `tree`, `grid`, or `manual` layout; then run `build`. See `examples/platform-v2.yaml` and `schemas/diagram-ir-v2.schema.json` +1. **Prefer YAML Diagram IR for repeatable diagrams** — use v1 for simple single-page diagrams or v2 for multiple pages, semantic kinds, explicit geometry/waypoints, provenance, and deterministic `linear`, `layered`, `tree`, `grid`, or `manual` layout; then run `task generate` (CLI action `build`). See `examples/platform-v2.yaml` and `schemas/diagram-ir-v2.schema.json` 2. **Generate draw.io XML** in mxGraphModel format for the requested diagram 3. **Write the XML** to a `.drawio` file in the current working directory using the Write tool 4. **Run the mandatory validation actions** against the generated file @@ -236,8 +250,9 @@ Every diagram must have this structure: ## XML reference -For the complete draw.io XML reference including common styles, edge routing, containers, layers, tags, metadata, dark mode colors, and XML well-formedness rules, fetch and follow the instructions at: -https://raw.githubusercontent.com/jgraph/drawio-mcp/main/shared/xml-reference.md +For the complete draw.io XML reference including common styles, edge routing, containers, layers, tags, metadata, dark mode colors, and XML well-formedness rules, read and follow the local copy at [references/xml-references.md](./references/xml-references.md). No network access is required.[^xml-upstream] + +[^xml-upstream]: Latest upstream source of that reference (only consult if the local copy is suspected to be stale): https://raw.githubusercontent.com/jgraph/drawio-mcp/main/shared/xml-reference.md ## Troubleshooting @@ -256,82 +271,16 @@ https://raw.githubusercontent.com/jgraph/drawio-mcp/main/shared/xml-reference.md - Always use unique `id` values for each `mxCell` -## Additional points +## Layout and style non-negotiables -- Always use a __10pt grid__. -- Align all elements to the grid; avoid freehand / off-grid placement. -- Use page dimensions that fully fit the diagram, including margins, legends, and connector routing corridors. -- Keep every main shape's `x`, `y`, `width`, and `height` aligned to clean grid increments. -- Prefer element widths and heights divisible by `40`; if a canonical vendor icon has a fixed non-divisible size, wrap it inside a grid-aligned card/container. -- Leave intentional whitespace corridors between columns and rows for connectors. +The full mandatory rule sets live in [references/rules-layout.md](./references/rules-layout.md) (grid, 40pt spacing, parent sizing, layer model, connector routing, legends, swimlanes, sequence diagrams) and [references/rules-style.md](./references/rules-style.md) (dimensions, colors, borders, text fit, single-port and corner rules). Read both before generating XML. The short version: -### Same-level shape spacing and parent size rules - -**Rule: Same-level siblings must be placed as close as possible while maintaining grid-aligned gaps.** - -Shapes at the same hierarchy level (siblings inside the same parent, or all top-level shapes) must: - -1. **Minimise the gap** between each other — pack them tightly, leaving only enough space for connectors to pass through. -2. **Gap must be grid-aligned** — both the `x` (horizontal gap) and `y` (vertical gap) distances between adjacent same-level shapes must be **divisible by 40 pt**. - -| Gap type | Minimum recommended | Must be divisible by | -|---|---|---| -| Horizontal gap between siblings (x-axis) | 40 pt | 40 pt | -| Vertical gap between siblings (y-axis) | 40 pt | 40 pt | - -> **Example:** If shape A ends at `x=320` and shape B starts at `x=360`, the gap is 40 pt (1 grid unit). If shape A ends at `x=320` and the gap must be wider for a connector corridor, use `x=400` (gap = 80 pt, divisible by 40) — never `x=350` (gap = 30 pt, not on a 40 pt boundary). - -**Rule: A parent (container) shape must always be large enough to fully enclose all its children, including inner padding.** - -When children grow (new children added, children resized, or children repositioned), the parent container must expand to accommodate them. Apply the following sizing formula: - -``` -parent.width = max_child_right + right_padding (right_padding ≥ 40, divisible by 40) -parent.height = max_child_bottom + bottom_padding (bottom_padding ≥ 40, divisible by 40) -``` - -Where: -- `max_child_right = max(child.x + child.width)` over all children (relative to parent) -- `max_child_bottom = max(child.y + child.height)` over all children (relative to parent) -- Left/top inner padding (the space between parent top-left and first child) must also be ≥ 40 pt and divisible by 40 pt. - -**Cascade rule:** Parent expansion may cause *its* parent to also need expansion. Walk up the containment tree and resize each ancestor in turn until the outermost container fits all descendants. - -**Positioning rule after expansion:** When a parent container grows, re-check sibling spacing at every affected level. All gaps must remain divisible by 40 pt after the resize. - -- Avoid routing connectors through shapes, cards, labels, icons, legends, or containers containing important content. -- Prefer orthogonal connectors with explicit waypoints when auto-routing causes overlaps. -- If connector auto-routing crosses shapes, use absolute routed points or fixed waypoints instead of relying on `source` / `target` auto-routing. -- Keep connector labels off the connector path when diagrams are dense. -- If connector labels are needed, place them in a dedicated legend, side note, or clearly empty corridor. -- Do not allow connector labels to overlap shapes, cards, icons, or other text. -- Avoid long text inside narrow shapes; use wider cards or wrap labels in a dedicated text area inside the card. -- Ensure service names fit within the visual card width and do not extend beyond card boundaries. -- For service-card diagrams, prefer a consistent card pattern: icon on the left, service label on the right, enough padding around both. -- Avoid overlapping shapes, including external actors, cards, icons, notes, legends, and containers. -- When using canonical vendor icons, preserve the original icon styling; adjust surrounding card/container layout instead of modifying the icon style. -- For rectangles and containers, do not use rounding unless the style explicitly requires it. -- Avoid borders on shapes unless they are needed for visual separation or canonical styling. -- Use at most __3 primary color families__; create hierarchy with lighter/darker shades instead of adding many unrelated colors. -- Keep connector colors simple and consistent; use dashed lines only for semantically different flows such as admin, private, async, or backup paths. -- Keep arrowheads outside shape interiors; connectors should touch shape/card edges, not pass through the body. -- Validate that all draw.io XML is well-formed and contains required root cells `0` and `1`. -- Ensure all `mxCell` IDs are unique. -- Every edge must include an `mxGeometry` child; use waypoint arrays for routed connectors. -- For complex diagrams, validate connector paths against shape bounding boxes before finalizing. -- Prefer a separate legend for flow explanations when connector labels would clutter the diagram. -- Keep legends outside the main routing area so connectors do not cross legend text. -- Use consistent spacing between diagram lanes/columns and between rows of cards. -- Keep diagram complexity medium by grouping services into logical zones/layers instead of scattering many unrelated services. -- Use swimlanes or containers for major domains, but avoid placing dense connector routes directly through container titles. -- Before completion, re-open or visually inspect the diagram and check specifically for: - - shape overlap - - connector overlap with cards/icons - - connector label overlap - - text overflowing card boundaries - - page clipping - - inconsistent spacing - - missing canonical vendor shapes +- **10pt grid, 40pt rhythm** — every shape aligned to the grid; dimensions and sibling gaps divisible by 40 (gap ≥ 40); parents enclose children with ≥ 40pt padding on all sides and grow (cascading upward) when children grow. +- **Connectors overlap only Layer 0 (containers)** — never shapes, labels, icons, legends, or swimlane headers; use explicit orthogonal waypoints (`edgeStyle=orthogonalEdgeStyle`) when auto-routing would clip; no crossings unless unavoidable, then 90°. +- **One exit point, one entry point per shape, mid-side only** — never a corner; arrowheads stop at the shape edge; first/last waypoint ≥ 20px clear of any shape. +- **≤ 3 color families, `rounded=0`, no decorative borders, dashed lines only for semantically distinct flows**; text must fit inside its shape; keep canonical vendor icons unmodified and wrap them in grid-aligned cards. +- **Labels and legends off the routing path** — connector labels in a clear corridor or a legend placed outside the routing area; group services into logical zones instead of scattering them. +- **Page fits everything** including margins, legends, and corridors; before finishing check overlap, connector/label overlap, text overflow, page clipping, inconsistent spacing, and missing canonical shapes, then run `page-connectors-validation`, `page-shape-bbox-validation`, and `validate`. --- diff --git a/references/agents365-capability-coverage.md b/references/agents365-capability-coverage.md index 5d7b91d..07734ac 100644 --- a/references/agents365-capability-coverage.md +++ b/references/agents365-capability-coverage.md @@ -1,6 +1,6 @@ # Agents365 capability coverage -> Evidence basis: clean-room behavioral comparison of 42 peer tools. Peer source and bundled assets were not copied because the inspected mirror had no complete license file. +> Evidence basis: internal comparison against a 42-entry peer-tool feature list (tool names as listed in the matrix; no version or commit was recorded, so the comparison is **not independently verifiable**). Peer source and bundled assets were not copied. The "Native evidence" column points to files in this repository and is verifiable; the "Remaining gap" column reflects the internal feature list only. ## Reading the classifications @@ -32,16 +32,16 @@ These are strict peer-parity labels. A `partial` row can still contain substanti | `ciimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | The service imports a bounded generic jobs/needs object, but lacks CLI registration, repository workflow discovery, triggers, runners, matrices, reusable workflows, GitLab stages, and inferred stage dependencies. | | `composeimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | Services, depends_on, and simple named volumes are covered through a service API; links, volumes_from, long-form mounts, network grouping, conventional-file discovery, and CLI exposure remain absent. | | `compress.py` | **partial** | `scripts/src/services/profiles/compression.ts`
`scripts/src/services/profiles/compression.test.ts` | A deterministic BFS clustering service emits summary and full IR pages, but there is no Draw.io-facing action, exact loss-aware detail-file workflow, label-propagation parity, or member-cell drill-down target contract. | -| `dbxicons.py` | **optional-deferred** | `scripts/src/services/shape-catalog/shape-catalog.ts`
clean-room 42-tool mapping audit | No licensed Databricks manifest, aliases, variants, pinned-ref embedding, refresh operation, host allowlist, or CLI action was delivered. | +| `dbxicons.py` | **optional-deferred** | `scripts/src/services/shape-catalog/shape-catalog.ts`
internal peer feature-list comparison (not independently verifiable) | No licensed Databricks manifest, aliases, variants, pinned-ref embedding, refresh operation, host allowlist, or CLI action was delivered. | | `diagram_ir.py` | **partial** | `scripts/src/model/diagram-ir.ts`
`scripts/src/services/semantic-lifecycle/import-drawio.ts`
`scripts/src/services/semantic-lifecycle/analysis.ts`
`scripts/src/services/semantic-lifecycle/sync.ts`
`scripts/src/services/semantic-lifecycle/publishing.ts` | Versioned IR, loss-aware import, views, query, policies, failure impact, sync, and story publishing exist, but articulation analysis, a unified architecture-review contract, contrast analysis, multilingual labeling, and explicit peer-IR-v1 compatibility are incomplete. | | `diagramctl.py` | **partial** | `scripts/src/cli/commands.ts`
`scripts/src/cli/semantic-lifecycle.test.ts`
`scripts/src/actions/doctor/action.ts`
`scripts/src/actions/sync/action.ts` | Lifecycle actions are registered in the existing --action CLI, but integrated importer, profile, transform, and reverse-export services are not registered. There is no uniform peer-equivalent result envelope. | -| `diagramctl_mcp.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
clean-room 42-tool mapping audit | No optional MCP package, JSON-RPC initialization, tools/list, tools/call bridge, closed schemas, timeout handling, or MCP entrypoint exists. | +| `diagramctl_mcp.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
internal peer feature-list comparison (not independently verifiable) | No optional MCP package, JSON-RPC initialization, tools/list, tools/call bridge, closed schemas, timeout handling, or MCP entrypoint exists. | | `dockerimports.py` | **optional-deferred** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | The importer registry has Docker Compose but no Docker inspect snapshot kind, container/network/volume instance normalization, redaction contract, stdin parity, or action. | | `drawio2mermaid.py` | **partial** | `scripts/src/services/transforms/reverse.ts`
`scripts/src/services/transforms/reverse.test.ts`
`scripts/src/services/semantic-lifecycle/import-drawio.ts` | A deterministic IR-to-Mermaid service exists, but there is no Draw.io-to-Mermaid CLI/action, shape-form mapping, direction/fence controls, or lossy-conversion report. | -| `drawio2pptx.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
clean-room 42-tool mapping audit | No optional Draw.io renderer adapter, per-page raster loop, PPTX writer, slide sizing, scale option, or structured unavailable result exists. | +| `drawio2pptx.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
internal peer feature-list comparison (not independently verifiable) | No optional Draw.io renderer adapter, per-page raster loop, PPTX writer, slide sizing, scale option, or structured unavailable result exists. | | `drawiodiff.py` | **partial** | `scripts/src/services/transforms/semantic-diff.ts`
`scripts/src/services/transforms/semantic-diff.test.ts`
`scripts/src/services/semantic-lifecycle/import-drawio.ts` | The IR service classifies added, removed, changed, moved, and rerouted entities, but no CLI/action composes Draw.io import with diffing, no by-label ambiguity mode exists, and no color-coded graph output or summary diagram is emitted. | | `drawiohtml.py` | **optional-deferred** | `scripts/src/services/semantic-lifecycle/publishing.ts`
`scripts/src/actions/story/action.ts` | Story HTML is a different semantic publisher; there is no page-to-SVG export adapter, SVG sanitizer, tabbed viewer, pan/zoom/search UI, drill-down link rewrite, or publish-viewer action. | -| `edgeports.py` | **rejected** | `scripts/src/services/authoring-router/orthogonal-router.ts`
`scripts/src/services/authoring-router/orthogonal-router.test.ts`
clean-room 42-tool mapping audit | Only routing during authoring exists. The post-import boundary-port editor, pinned-port preservation, dry-run, idempotence, and transactional Draw.io write path were not selected for this post-phase implementation. | +| `edgeports.py` | **rejected** | `scripts/src/services/authoring-router/orthogonal-router.ts`
`scripts/src/services/authoring-router/orthogonal-router.test.ts`
internal peer feature-list comparison (not independently verifiable) | Only routing during authoring exists. The post-import boundary-port editor, pinned-port preservation, dry-run, idempotence, and transactional Draw.io write path were not selected for this post-phase implementation. | | `encode_drawio_url.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
`scripts/package.json` | Compression primitives are available transitively, but there is no byte-compatible URL encoder, viewer/edit modes, size policy, privacy warning, or CLI action. | | `explain.py` | **partial** | `scripts/src/services/transforms/reverse.ts`
`scripts/src/services/transforms/reverse.test.ts` | Structured Markdown for IR pages, nodes, and flows exists as a service, but no Draw.io-facing explain action, tier/type inference, C4 context, unknown-section reporting, or output-file contract is exposed. | | `goimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | Bounded Go import extraction exists, but it emits imported paths as library nodes rather than resolving only intra-module packages; module discovery, grouping, transitive reduction, and CLI exposure are absent. | @@ -54,14 +54,14 @@ These are strict peer-parity labels. A `partial` row can still contain substanti | `pyimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | Bounded import/from extraction and dynamic-import diagnostics exist, but relative and absolute intra-project resolution, stdlib/third-party exclusion, package grouping, transitive reduction, syntax-error diagnostics, and CLI exposure are absent. | | `raster2drawio.py` | **partial** | `scripts/src/actions/build/action.ts`
`scripts/src/authoring/ir-to-drawio.ts`
`scripts/src/services/layout/layout-engine.ts`
`scripts/src/model/diagram-ir.ts` | Generic IR build supports explicit geometry, styles, edges, and automatic layout, but there is no raster-extracted graph compatibility schema/action, x/y/w/h shorthand conversion, partial-coordinate policy, confidence/provenance convention, or extraction-warning envelope. | | `relabel.py` | **partial** | `scripts/src/services/transforms/relabel.ts`
`scripts/src/services/transforms/relabel.test.ts` | A strict complete-map IR relabel service preserves non-label structure, but there is no extraction mode, page-name handling, UserObject traversal contract, partial-map/unmatched reporting, Draw.io transactional write, or CLI action. | -| `repair_png.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
clean-room 42-tool mapping audit | No PNG chunk validator, signature-specific repair, atomic in-place replacement, idempotence gate, version gate, or optional action exists. | +| `repair_png.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
internal peer feature-list comparison (not independently verifiable) | No PNG chunk validator, signature-specific repair, atomic in-place replacement, idempotence gate, version gate, or optional action exists. | | `restyle.py` | **partial** | `scripts/src/services/themes/theme-service.ts`
`scripts/src/services/themes/theme-service.test.ts` | Five validated built-in themes and immutable IR application exist, but the peer-style palette-slot schema, user preset loader, hue/neutral color remapping, global extras, versioned JSON schema, Draw.io transactional action, and CLI exposure are incomplete. | | `runbook.py` | **partial** | `scripts/src/services/profiles/runbook.ts`
`scripts/src/services/profiles/runbook.test.ts` | The service emits escaped self-contained interactive HTML from an explicit RunbookGraph, but it does not parse Draw.io, infer node types/start nodes/choices, report fallback selection, or expose a publish-runbook action. | | `rustimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | A bounded subset recognizes mod and simple use roots and diagnoses macros, but crate/self/super resolution, module-file discovery, complete brace expansion, external-crate exclusion, grouping, reduction, and CLI exposure are absent. | | `seqlayout.py` | **partial** | `scripts/src/services/profiles/sequence.ts`
`scripts/src/services/profiles/sequence.test.ts` | Participants, ordered messages, lifelines, activations, return validation, and editable geometry are implemented as a service, but notes are unsupported and no sequence schema file, input action, direction/options contract, or CLI registration exists. | | `shapesearch.py` | **partial** | `scripts/src/services/shape-catalog/shape-catalog.ts`
`scripts/src/services/shape-catalog/shape-catalog.test.ts` | Deterministic exact/alias/fuzzy search exists for eight hand-curated generic shapes, not the licensed 10k+ palette index; compound/tag/Soundex ranking, dimensions, gzip integrity controls, expected ecosystem queries, and CLI output are missing. | | `sqlerd.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | A narrow line-oriented subset finds simple tables and REFERENCES edges, but columns/types, PK/FK markers, quoted/schema identifiers, composite keys, schema grouping, crow's-foot styles, unsupported-syntax diagnostics, and CLI exposure are missing. | -| `svgflow.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
clean-room 42-tool mapping audit | No optional Draw.io SVG export adapter, SVG parser/sanitizer, connector detection, animation injection, reduced-motion handling, or export-flow-svg action exists. | +| `svgflow.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
internal peer feature-list comparison (not independently verifiable) | No optional Draw.io SVG export adapter, SVG parser/sanitizer, connector detection, animation injection, reduced-motion handling, or export-flow-svg action exists. | | `tfimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | A bounded line-oriented resource/reference subset exists, but modules, multiline/nested HCL handling, comments/string false-positive guarantees, diagnostics for dynamic expressions, cloud styles, grouping, transitive reduction, no-icons mode, and CLI exposure are incomplete. | | `tfstate.py` | **optional-deferred** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | No Terraform show-JSON snapshot source kind, nested module traversal, count/for_each instance expansion, sensitive-value redaction, state relationship extraction, stdin parity, or optional action exists. | | `timelapse.py` | **partial** | `scripts/src/services/profiles/timelapse.ts`
`scripts/src/services/profiles/timelapse.test.ts` | The service classifies changes across caller-supplied IR snapshots, but it has no scoped git history/archive adapter, deterministic commit sampling, importer allowlist, Draw.io frame rendering, HTML player, resource limits, or CLI action. | @@ -71,7 +71,3 @@ These are strict peer-parity labels. A `partial` row can still contain substanti ## Architectural boundary The retained implementation stays in the existing TypeScript/Node.js stack with pnpm, Taskfile, `@maxgraph/core`, and the established action-based CLI. Python, Graphviz, Eclipse Layout Kernel (ELK), Model Context Protocol (MCP), browser services, network icon retrieval, and Draw.io Desktop are not mandatory dependencies. Optional adapters must report availability honestly. - -## Delivery note - -This matrix describes the combined integrated candidate and deliberate scope decisions. Gitea publication and Hermes runtime installation are separate gates and must not be inferred from this document. \ No newline at end of file diff --git a/references/capabilities.md b/references/capabilities.md index c5ede4d..1a7b592 100644 --- a/references/capabilities.md +++ b/references/capabilities.md @@ -1,12 +1,12 @@ # diagrams-drawio — Capabilities -This file lists all capabilities an agent can use from this skill. +This file lists all capabilities an agent can use from this skill. The seven capability families below use the same headings and order as the list at the top of [SKILL.md](../SKILL.md). --- ## Capability 1 — Deterministic YAML generation -Build native `.drawio` XML from a validated semantic Diagram IR: +Build native `.drawio` XML from a validated semantic Diagram IR (CLI action `build`, `task generate`): ```bash node dist/cli/commands.js --action build --file architecture.yaml --output architecture.drawio @@ -18,22 +18,14 @@ IR v1 remains compatible. IR v2 adds multiple pages, containers and semantic kin ## Capability 2 — Direct XML generation -Create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements. +Create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements. The agent authors the mxGraphModel XML directly and then runs the mandatory `validate` action (`task validate`). See [SKILL.md](../SKILL.md) for the generation workflow, XML format, page sizes, element shapes, and well-formedness rules. -See [rules-layout.md](./rules-layout.md) for mandatory connector and layout rules. +See [rules-layout.md](./rules-layout.md) and [rules-style.md](./rules-style.md) for mandatory connector, layout, and style rules. --- -## Capability 3 — Semantic lifecycle - -The native TypeScript core supports loss-aware Draw.io import and deterministic round trips; transactional stable-ID edit batches; linked audience views; semantic query, architecture policy, and failure what-if analysis; three-way synchronization; accessible self-contained story HTML; and a non-launching doctor report. - -See [semantic-lifecycle.md](./semantic-lifecycle.md) for command contracts, safety behavior, built-in policy identifiers, and examples. - ---- - -## Capability 4 — Diagram analysis (drawio-tools CLI) +## Capability 3 — Diagram analysis (drawio-tools CLI) A **TypeScript / Node.js** CLI tool for programmatic analysis of `.drawio` files. Entry point: `node dist/cli/commands.js` (run from the skill's `scripts/` directory), or `task run -- --file=… --action=…`. @@ -46,7 +38,7 @@ node dist/cli/commands.js --file --action [--page ] [--ou # short flags: -f, -a, -p, -o, -h ``` -`--page` selects the diagram tab (0-based, default 0). Ignored by `summary` (processes all pages). +`--page` selects the diagram tab (0-based, default 0). Only the actions marked `--page` below honour it; actions marked "page 0 only" always analyse the first page, and all-page actions ignore it. Always prints YAML to stdout. Exit code `0` on success, `1` on error. ### Actions @@ -57,7 +49,41 @@ Always prints YAML to stdout. Exit code `0` on success, `1` on error. |---|---|---| | `build` | YAML Diagram IR | Validate and convert a YAML specification to native `.drawio`; requires `--output` | -#### Semantic lifecycle +#### Inventory + +| Action | Scope | Description | +|---|---|---| +| `summary` | All pages | Full inventory of shapes + edges for every page/tab | +| `page-summary` | `--page` | Full inventory of shapes + edges for one page | +| `page-hierarchy` | `--page` | Recursive containment tree with totalLevels (nesting depth count) | +| `page-hierarchy-full` | `--page` | Same as page-hierarchy + full geometry (x, y, width, height) per shape at each nesting level | +| `page-connectors-summary` | page 0 only | Per-connector details: type, label, waypoints, source/target names | + +#### Validation + +| Action | Scope | Description | +|---|---|---| +| `validate` | All pages | **Mandatory final gate** — XML well-formedness + maxGraph compile + sanity check (vertices/edges > 0). Run before finishing any diagram work | +| `quality` | All pages | Objective clipping, overflow, placeholder, external-asset, palette, page-density, and edge-density checks with explicit severity | +| `page-connectors-validation` | page 0 only | Connector-shape overlaps + connector crossings + corner-port, header-edge, and single-port violations | +| `page-labels-validation` | page 0 only | Empty labels, duplicate labels, labels > 80 chars | +| `page-shape-bbox-validation` | page 0 only | Non-containment bounding box overlaps between shapes | +| `page-orphans` | page 0 only | Isolated shapes (no edges) + dangling connectors (missing endpoints) | + +#### Layout + +| Action | Scope | Description | +|---|---|---| +| `page-recommendations` | page 0 only | Smallest standard page size (A4→A3→A2→A1→custom) that fits content with 80 px margin | +| `page-negative-space-summary` | `--page` | Free horizontal corridors (negative space) per nesting level, bbox-based and text-aware; input for connector routing and [negative-space-diagram.md](./negative-space-diagram.md) | + +> For install/build instructions, source structure, and how to add new actions, see [maintenance.md](./maintenance.md). + +--- + +## Capability 4 — Semantic lifecycle + +The native TypeScript core supports loss-aware Draw.io import and deterministic round trips; transactional stable-ID edit batches; linked audience views; semantic query, architecture policy, and failure what-if analysis; three-way synchronization; accessible self-contained story HTML; and a non-launching doctor report. Every action below also has a same-named `task` wrapper (`task import`, `task edit`, …). | Action | Scope | Description | |---|---|---| @@ -71,44 +97,49 @@ Always prints YAML to stdout. Exit code `0` on success, `1` on error. | `story` | Diagram IR v2 | Write self-contained accessible offline HTML; optional `--fail` overlay | | `doctor` | Local environment | Report optional backend availability without launching processes or requiring a model file | -#### Inventory - -| Action | Scope | Description | -|---|---|---| -| `summary` | All pages | Full inventory of shapes + edges for every page/tab | -| `page-summary` | Single page (`--page`) | Full inventory of shapes + edges for one page | -| `page-hierarchy` | Page 0 | Recursive containment tree with totalLevels (nesting depth count) | -| `page-hierarchy-full` | Page 0 | Same as page-hierarchy + full geometry (x, y, width, height) per shape at each nesting level | -| `page-connectors-summary` | Page 0 | Per-connector details: type, label, waypoints, source/target names | - -#### Validation - -| Action | Scope | Description | -|---|---|---| -| `validate` | All pages | **Mandatory final gate** — XML well-formedness + maxGraph compile + sanity check (vertices/edges > 0). Run before finishing any diagram work | -| `quality` | All pages | Objective clipping, overflow, placeholder, external-asset, palette, page-density, and edge-density checks with explicit severity | -| `page-connectors-validation` | Page 0 | Connector-shape overlaps + connector crossings | -| `page-labels-validation` | Page 0 | Empty labels, duplicate labels, labels > 80 chars | -| `page-shape-bbox-validation` | Page 0 | Non-containment bounding box overlaps between shapes | -| `page-orphans` | Page 0 | Isolated shapes (no edges) + dangling connectors (missing endpoints) | - -#### Layout - -| Action | Scope | Description | -|---|---|---| -| `page-recommendations` | Page 0 | Smallest standard page size (A4→A3→A2→A1→custom) that fits content with 80 px margin | - -> For install/build instructions, source structure, and how to add new actions, see [maintenance.md](./maintenance.md). +See [semantic-lifecycle.md](./semantic-lifecycle.md) for command contracts, safety behavior, built-in policy identifiers, and examples. --- -## Capability 5 — Native service libraries +## Capability 5 — Safe source importers -The package includes additional strict TypeScript APIs under `scripts/src/services/`: +**Library only** — not reachable as a CLI action. Import from `dist/services/source-importers/index.js` (source `scripts/src/services/source-importers/index.ts`): -- `source-importers/` — conservative, bounded source/configuration topology extraction with diagnostics and no code execution; -- `themes/` and `shape-catalog/` — five validated themes and an offline generic shape catalog; -- `transforms/` — reverse Mermaid/Markdown, semantic diff, complete-map relabeling, and accessible bounded heatmaps; -- `profiles/` — C4, sequence, tube map, compression, runbook, timelapse, and build-up profiles. +```ts +import { importSource } from "/scripts/dist/services/source-importers/index.js"; +``` -These services are covered by the native test suite but are not registered CLI actions. Their exact peer-relative coverage and deliberate omissions are listed in [agents365-capability-coverage.md](./agents365-capability-coverage.md). +`importSource({ sourceKind, path, input })` performs conservative, bounded topology extraction with diagnostics and no code execution for `python`, `javascript`, `typescript`, `go`, `rust`, `terraform`, `kubernetes`, `docker-compose`, `sql`, `openapi`, and `ci` inputs, returning a Diagram IR v2 plus diagnostics and provenance. + +--- + +## Capability 6 — Toolbox transforms + +**Library only** — not reachable as a CLI action. Modules under `dist/services/` (source `scripts/src/services/`): + +| Module | Exports | Purpose | +|---|---|---| +| `themes/theme-service.js` | `applyTheme`, `validateTheme`, `contrastRatio` | Five validated built-in themes, immutable IR application | +| `shape-catalog/shape-catalog.js` | `searchShapes` | Offline generic shape search (exact/alias/fuzzy) | +| `transforms/reverse.js` | `diagramIRToMermaid`, `diagramIRToStructuredMarkdown` | Reverse Mermaid / Markdown from IR | +| `transforms/semantic-diff.js` | `semanticDiff` | Added/removed/changed/moved/rerouted classification between two IR models | +| `transforms/relabel.js` | `relabelDiagram` | Strict complete-map relabeling preserving structure | +| `transforms/heatmap.js` | `applyMetricsHeatmap` | Accessible bounded metric heatmaps with legend metadata | + +--- + +## Capability 7 — Specialized profiles + +**Library only** — not reachable as a CLI action. Import from `dist/services/profiles/index.js` (source `scripts/src/services/profiles/index.ts`): + +```ts +import { projectC4, createSequenceDiagram, createTubeMap, compressExecutiveView, + createRunbookHtml, createArchitectureTimelapse, createBuildup } + from "/scripts/dist/services/profiles/index.js"; +``` + +C4 projection, sequence diagram layout, tube map routing, executive-view compression, runbook HTML, architecture timelapse, and dependency-ordered build-up frames. + +--- + +Capabilities 5–7 are covered by the native test suite but are not registered CLI actions. Their exact peer-relative coverage and deliberate omissions are listed in [agents365-capability-coverage.md](./agents365-capability-coverage.md). diff --git a/references/maintenance.md b/references/maintenance.md index 97fab3b..cd100db 100644 --- a/references/maintenance.md +++ b/references/maintenance.md @@ -36,7 +36,7 @@ The git repository is the source of truth. Install dependencies and build inside ```bash cd /scripts -pnpm install +pnpm install --frozen-lockfile task build # or: pnpm run build ``` @@ -99,7 +99,11 @@ src/ │ │ └── page-summary.ts # buildPageSummary() — shared per-page serialisation helper │ ├── hierarchy-builder/ │ │ └── hierarchy-builder.ts # buildHierarchy() — shared BFS depth map + containment tree -│ └── semantic-lifecycle/ # import, edit, views/query/policy/what-if, sync, story, atomic I/O +│ ├── semantic-lifecycle/ # import, edit, views/query/policy/what-if, sync, story, atomic I/O +│ ├── layout/, authoring-router/ # deterministic layout + obstacle-aware routing used by build (ir-to-drawio) +│ ├── connector-router/ # edge path reconstruction used by page-connectors-summary/-validation +│ ├── maxgraph-loader/ # jsdom polyfill + maxGraph state loader (used by validate and negative-space) +│ └── source-importers/, transforms/, themes/, shape-catalog/, profiles/ # library-only services (no CLI action) └── actions/ ├── build|import|edit|views|query|test|what-if|sync|story|doctor/ │ # authoring and semantic lifecycle actions @@ -113,6 +117,8 @@ src/ ├── page-orphans/ # isolated shapes + dangling connectors ├── page-recommendations/ # page size recommendation ├── page-hierarchy-full/ # nesting levels with full shape geometry (x, y, width, height) + ├── page-negative-space-summary/ # free horizontal corridors per nesting level (bbox + text-aware) + ├── quality/ # all-pages clipping/overflow/placeholder/palette/density checks with severity └── validate/ # MANDATORY final gate — XML well-formedness + maxGraph compile + sanity check ``` @@ -145,25 +151,34 @@ Each action exports `run(filePath, pageIndex?, outputPath?, options?): Record/action.ts` exporting: +1. Create `src/actions//action.ts` exporting a **synchronous** `run` (the dispatcher in `commands.ts` reads `result.summary` / `result.failed` without `await`, so an `async` function or a returned `Promise` would break exit-code handling). Copy the signature from an existing action, e.g. `src/actions/page-hierarchy/action.ts`: ```ts - export async function run(filePath: string, pageIndex?: number): Promise> + export function run(filePath: string, pageIndex: number = 0): Record { + const pages = parseAllPages(filePath); + const page = pages[pageIndex]; + if (!page) { + return { error: true, message: `Page index ${pageIndex} not found` }; + } + // ... + return { action: "", /* ... */ }; + } ``` + The full `ActionModule` contract is `run(filePath: string, pageIndex?: number, outputPath?: string, options?: LifecycleActionOptions): Record`; declare only the parameters you use. Set `failed: true` in the result to make the CLI exit with code `1`. 2. Register it in `src/cli/commands.ts` under `ACTIONS`: ```ts "my-action": () => import("../actions/my-action/action.js"), ``` -3. Use `parseDiagram(filePath)` (first page) or `parseAllPages(filePath)` (all pages) from the parser +3. Use `parseAllPages(filePath)[pageIndex]` to honour `--page`, or `parseDiagram(filePath)` when the action is deliberately page-0-only; `parseAllPages` for all-page actions. Document the choice in the Scope column of `capabilities.md` 4. Optionally import `buildPageSummary(page)` from `page-summary.ts` for standard shape/edge serialisation 5. Return a plain object — the CLI serialises it to YAML automatically -6. Run `pnpm run build` to compile and verify no TypeScript errors +6. Run `task build` (or `pnpm run build`) to compile and verify no TypeScript errors, then add the action to `capabilities.md`, the `Actions:` list in `commands.ts --help`, and the Taskfile descriptions ### Naming convention Actions follow `{object}-{action}` naming: -- `page-*` — operates on a single diagram page (uses `--page`, default 0) -- `summary` — operates on all pages +- `page-*` — operates on a single diagram page (`page-summary`, `page-hierarchy`, `page-hierarchy-full`, `page-negative-space-summary` honour `--page`; the remaining `page-*` actions currently analyse page 0 only) +- `summary`, `validate`, `quality` — operate on all pages --- diff --git a/references/rules-layout.md b/references/rules-layout.md index efc9633..0479a4d 100644 --- a/references/rules-layout.md +++ b/references/rules-layout.md @@ -27,7 +27,49 @@ These rules are **MANDATORY** — apply them when generating any diagram. - `startSize=40`, 1 row h=80: total = 40+40+80+40 = 200 (children at y=80) - `startSize=40`, 2 rows h=40 each: total = 40+40+40+40+40+40 = 240 (row1 at y=80, row2 at y=160) - After generating or editing a diagram, run `page-spacing-audit` and `page-swimlane-audit` to verify. + After generating or editing a diagram, run `page-shape-bbox-validation`, `page-connectors-validation`, and `page-hierarchy-full` (geometry per nesting level) to verify. + +### Page size + +- Use page dimensions that fully fit the diagram, **including margins, legends, and connector routing corridors** — never let content clip the page edge +- `page-recommendations` reports the smallest standard page size that fits the content with an 80 px margin + +### Parent (container) sizing — MANDATORY + +**A parent shape must always be large enough to fully enclose all its children, including inner padding.** When children grow (added, resized, or repositioned), expand the parent using: + +``` +parent.width = max_child_right + right_padding (right_padding ≥ 40, divisible by 40) +parent.height = max_child_bottom + bottom_padding (bottom_padding ≥ 40, divisible by 40) +``` + +Where `max_child_right = max(child.x + child.width)` and `max_child_bottom = max(child.y + child.height)` over all children (relative to the parent). Left/top inner padding must also be ≥ 40 pt and divisible by 40 pt. + +- **Cascade rule:** parent expansion may force *its* parent to expand. Walk up the containment tree and resize each ancestor until the outermost container fits all descendants +- **Re-check after expansion:** when a parent grows, re-verify sibling spacing at every affected level — all gaps must remain divisible by 40 pt + +### Grouping and complexity + +- Keep diagram complexity medium: group services into logical zones/layers (swimlanes or containers per major domain) instead of scattering many unrelated services +- Never route dense connector bundles directly through container titles/headers (see label-crossing prohibition below) + +### Connector labels and legends + +- In dense diagrams keep connector labels **off the connector path**; if labels are needed, place them in a clearly empty corridor, a side note, or a dedicated legend +- Prefer a separate legend for flow explanations whenever connector labels would clutter the diagram +- Keep legends **outside the main routing area** so no connector crosses legend text +- Connector labels must never overlap shapes, cards, icons, or other text + +### Final visual inspection + +Before completion, re-open or visually inspect the diagram and check specifically for: +- shape overlap (including external actors, cards, icons, notes, legends, containers) +- connector overlap with cards/icons +- connector label overlap +- text overflowing card boundaries +- page clipping +- inconsistent spacing between lanes/columns and between rows +- missing canonical vendor shapes --- diff --git a/references/rules-style.md b/references/rules-style.md index a0c2a5f..570004e 100644 --- a/references/rules-style.md +++ b/references/rules-style.md @@ -15,9 +15,17 @@ These rules are **MANDATORY** — apply them when generating any diagram. - **Swimlane body height (total − startSize) must be divisible by 40** — so total height = 40 + N×40 = a multiple of 40 - Example: `startSize=40`, body=80 → total=120 ✓; body=160 → total=200 ✓ - Use ≤ 3 primary color families; create hierarchy with shades -- Use dashed connectors only for semantically distinct flows (async, backup, admin) +- Keep connector colors simple and consistent; use dashed connectors only for semantically distinct flows (async, backup, admin, private) +- Avoid borders on shapes unless needed for visual separation or canonical styling - Shape size is driven by **content**, not routing — typically 80–160px tall per label line +### Text fit and cards + +- Avoid long text inside narrow shapes; use wider cards or wrap the label in a dedicated text area inside the card +- Service names must fit within the visual card width and never extend beyond card boundaries +- For service-card diagrams use one consistent card pattern: icon on the left, service label on the right, enough padding around both +- When using canonical vendor icons, **preserve the original icon styling**; adjust the surrounding card/container layout instead of modifying the icon style + --- ## Connector routing rules — MANDATORY @@ -27,6 +35,7 @@ These rules are **MANDATORY** — apply them when generating any diagram. - **Connectors must never attach at a shape corner** — the exit/entry point must lie on the middle of a side: top-center, bottom-center, left-center, or right-center. A point that is simultaneously on both an X-edge (left or right) AND a Y-edge (top or bottom) of the shape bounding box is a corner and is forbidden. Use the midpoint of the chosen side: `x_mid = (x1+x2)/2` for top/bottom sides, `y_mid = (y1+y2)/2` for left/right sides. - These rules ensure a clean "bus-bar" fan-out/fan-in pattern and prevent connectors from diverging at their source or converging at their target at different positions, which creates visual clutter and increases crossing risk. - Exception: shapes with only a single outgoing or single incoming connector — the single-port rule trivially holds. The corner rule still applies. +- **Arrowheads stay outside shape interiors** — connectors touch the shape/card edge and never pass through the body. - When computing waypoints: use `page-negative-space-summary` to find free X corridors per row, then pick an exit/entry coordinate that lies within a free corridor at the next traversed level. This minimises connector-shape overlaps and connector crossings. - **Waypoints must never be closer than 20px to any shape** — the first waypoint after a shape exit must be at least 20px away from the shape's edge in the direction of travel (e.g., if exiting bottom at y=520, first waypoint y ≥ 540; if exiting right at x=1040, first waypoint x ≥ 1060). The last waypoint before a shape entry must likewise be at least 20px away from the shape's edge. This 20px clearance also applies to any waypoint relative to same-level sibling shapes the connector passes by — the waypoint must not come within 20px of any sibling shape's bounding box side it is adjacent to. diff --git a/references/semantic-lifecycle.md b/references/semantic-lifecycle.md index ff38be2..e6fda30 100644 --- a/references/semantic-lifecycle.md +++ b/references/semantic-lifecycle.md @@ -32,7 +32,7 @@ node dist/cli/commands.js --action edit \ --output edited.drawio ``` -A batch targets one page and supports typed `add`, `update`, `move`, `delete`, and `connect` operations. Supported preconditions are `exists`, `not-exists`, and `property-equals`, each expressed with a `type` and `id`. Unknown precondition or operation types, update payloads that attempt to change `id`, duplicate identifiers, invalid parents/endpoints, and non-cascading deletion of referenced elements fail closed and abort the entire batch. Dry runs return a preview and write nothing. +A batch targets one page and supports typed `add`, `update`, `move`, `delete`, and `connect` operations. Supported preconditions are `exists`, `not-exists`, and `property-equals`, each expressed with a `type` and `id`; `property-equals` additionally requires `property` (the property name) and `value` (the exact expected value). The shipped `examples/edit-batch.yaml` is tied to `examples/platform-v2.yaml` (page `system`, node `api`) — build or import that model first, or adapt the IDs before applying the batch to another model. Unknown precondition or operation types, update payloads that attempt to change `id`, duplicate identifiers, invalid parents/endpoints, and non-cascading deletion of referenced elements fail closed and abort the entire batch. Dry runs return a preview and write nothing. ## Linked audience views diff --git a/scripts/.scripts/cli/Taskfile.yml b/scripts/.scripts/cli/Taskfile.yml index 807af38..a6ac27c 100644 --- a/scripts/.scripts/cli/Taskfile.yml +++ b/scripts/.scripts/cli/Taskfile.yml @@ -12,11 +12,13 @@ tasks: run: desc: | - Run any drawio-tools action against a .drawio file. - Usage: task cli:run -- --file="/path/to/diagram.drawio" --action= [--page ] - Actions: summary, page-summary, page-hierarchy, page-connectors-summary, page-connectors-validation, + Run any drawio-tools action against a .drawio file or Diagram IR model. + Usage: task cli:run -- --file="/path/to/diagram.drawio" --action= [--page ] [--output ] + Authoring: build + Analysis: summary, page-summary, page-hierarchy, page-connectors-summary, page-connectors-validation, page-labels-validation, page-shape-bbox-validation, page-orphans, page-recommendations, page-hierarchy-full, page-negative-space-summary, quality, validate + Lifecycle: import, edit, views, query, test, what-if, sync, story, doctor cmds: - | ./.scripts/cli/api/run.sh {{ .CLI_ARGS }} diff --git a/scripts/Taskfile.yml b/scripts/Taskfile.yml index 2f5a8ea..ac2516b 100644 --- a/scripts/Taskfile.yml +++ b/scripts/Taskfile.yml @@ -20,8 +20,8 @@ tasks: run: desc: | - Run any drawio-tools action against a .drawio file. - Usage: task run -- --file="/path/to/diagram.drawio" --action= [--page ] + Run any drawio-tools action against a .drawio file or Diagram IR model (see `task cli:run` for the action list). + Usage: task run -- --file="/path/to/diagram.drawio" --action= [--page ] [--output ] cmds: - task: cli:run vars: @@ -62,14 +62,56 @@ tasks: CLI_ARGS: "--action=views {{ .CLI_ARGS }}" silent: true + query: + desc: | + Filter a Diagram IR v2 model by kind/property, or find a directed path. + Usage: task query -- --file="model.yaml" --kind=service | --property=k=v | --from= --to= + cmds: + - task: cli:run + vars: + CLI_ARGS: "--action=query {{ .CLI_ARGS }}" + silent: true + + test: + desc: | + Run built-in architecture policy rules against a Diagram IR v2 model. + Usage: task test -- --file="model.yaml" [--spec="policy-rules.yaml"] [--strict] + cmds: + - task: cli:run + vars: + CLI_ARGS: "--action=test {{ .CLI_ARGS }}" + silent: true + + what-if: + desc: | + Calculate outgoing failure impact from one node; requires --file and --fail. + Usage: task what-if -- --file="model.yaml" --fail= + cmds: + - task: cli:run + vars: + CLI_ARGS: "--action=what-if {{ .CLI_ARGS }}" + silent: true + sync: - desc: Three-way stable-ID synchronization; requires --base, --file, and --spec. + desc: | + Three-way stable-ID synchronization; requires --base, --file, and --spec. + Usage: task sync -- --base="before.yaml" --file="edited.yaml" --spec="after.yaml" --output="synced.drawio" [--prune] [--dry-run] cmds: - task: cli:run vars: CLI_ARGS: "--action=sync {{ .CLI_ARGS }}" silent: true + doctor: + desc: | + Report optional local backend availability without launching anything; no --file needed. + Usage: task doctor + cmds: + - task: cli:run + vars: + CLI_ARGS: "--action=doctor {{ .CLI_ARGS }}" + silent: true + story: desc: Publish a self-contained accessible offline architecture story. cmds: diff --git a/scripts/src/actions/sync/action.ts b/scripts/src/actions/sync/action.ts index 832b4a9..faa005a 100644 --- a/scripts/src/actions/sync/action.ts +++ b/scripts/src/actions/sync/action.ts @@ -1,10 +1,14 @@ import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js"; import { assertOutputSafe, atomicWrite, loadIR, structuredText, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js"; import { syncDiagramIR } from "../../services/semantic-lifecycle/sync.js"; -export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record { +export type SyncActionOptions = LifecycleActionOptions & { force?: boolean }; +export function run(filePath: string, _page = 0, outputPath?: string, options: SyncActionOptions = {}): Record { if (!options.base || !options.spec) throw new Error("sync requires --base and --spec "); if (!options.dryRun && !outputPath) throw new Error("sync requires --output unless --dry-run is used"); if (outputPath) assertOutputSafe(outputPath, [filePath, options.base, options.spec]); const result = syncDiagramIR(loadIR(options.base), loadIR(filePath), loadIR(options.spec), { prune: options.prune }); - let output: string | undefined; if (!options.dryRun && outputPath) output = atomicWrite(outputPath, /\.(drawio|xml)$/i.test(outputPath) ? diagramIRToDrawio(result.ir) : structuredText(result.ir, outputPath), [filePath, options.base, options.spec]); - return { action: "sync", output, dryRun: options.dryRun === true, added: result.added, removed: result.removed, conflicts: result.conflicts, preview: options.dryRun ? result.ir : undefined }; + // Unresolved conflicts fail the action (exit code 1) and block writing unless --force accepts the manual-preferred merge. + const failed = result.conflicts.length > 0 && options.force !== true; + const summary = result.conflicts.length === 0 ? "sync merged without conflicts" : failed ? `${result.conflicts.length} unresolved conflict(s); output not written (resolve them or pass --force to write the manual-preferred merge)` : `${result.conflicts.length} conflict(s) overridden by --force; manual values kept`; + let output: string | undefined; if (!options.dryRun && outputPath && !failed) output = atomicWrite(outputPath, /\.(drawio|xml)$/i.test(outputPath) ? diagramIRToDrawio(result.ir) : structuredText(result.ir, outputPath), [filePath, options.base, options.spec]); + return { action: "sync", output, dryRun: options.dryRun === true, failed, summary, added: result.added, removed: result.removed, conflicts: result.conflicts, preview: options.dryRun ? result.ir : undefined }; } diff --git a/scripts/src/actions/views/action.test.ts b/scripts/src/actions/views/action.test.ts new file mode 100644 index 0000000..9e1ce83 --- /dev/null +++ b/scripts/src/actions/views/action.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { loadGraphStates } from "../../services/maxgraph-loader/graph-loader.js"; +import { parseAllPages } from "../../services/drawio-parser/parser.js"; +import { projectC4 } from "../../services/profiles/c4.js"; +import { run as validate } from "../validate/action.js"; +import { run as views } from "./action.js"; + +const SPEC = `version: 2 +pages: + - id: main + title: Main + layout: { type: layered } + nodes: + - { id: zone, label: Zone, kind: container } + - { id: api, label: API, kind: service, parentId: zone, properties: { importance: 9 } } + - { id: worker, label: Worker, kind: service, parentId: zone, properties: { importance: 8 } } + - { id: db, label: DB, kind: database, parentId: zone, properties: { importance: 7 } } + - { id: user, label: User, kind: actor, properties: { importance: 10 } } + edges: + - { id: e1, source: user, target: api } + - { id: e2, source: api, target: worker } + - { id: e3, source: api, target: db, kind: write } +`; + +function pageVertexIds(file: string, pageName: string): Set { + const page = parseAllPages(file).find((item) => item.pageName === pageName)!; + return new Set(loadGraphStates(page.graphModelXml).vertexBounds.keys()); +} + +test("views drop parents that were projected away so every selected vertex renders", () => { + const dir = mkdtempSync(join(tmpdir(), "drawio-views-test-")); + const spec = join(dir, "spec.yaml"); + const output = join(dir, "views.drawio"); + try { + writeFileSync(spec, SPEC, "utf8"); + // The security view selects user (actor) and db (database) but not their container "zone". + const result = views(spec, 0, output, { views: "security,dataflow" }) as { views: Array<{ id: string; fallback: boolean }> }; + assert.deepEqual(result.views.map((view) => view.id), ["security", "dataflow"]); + assert.deepEqual(validate(output).summary, { pages: 2, valid: true, invalidPages: 0 }); + assert.deepEqual([...pageVertexIds(output, "Security")].sort(), ["db", "user"]); + assert.deepEqual([...pageVertexIds(output, "Dataflow")].sort(), ["api", "db"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("views render C4 profile output whose elements span several pages", () => { + const dir = mkdtempSync(join(tmpdir(), "drawio-views-c4-test-")); + const source = join(dir, "c4.yaml"); + const output = join(dir, "views.drawio"); + try { + const c4 = projectC4({ + title: "Shop", + elements: [ + { id: "customer", label: "Customer", type: "person" }, + { id: "shop", label: "Shop System", type: "system" }, + { id: "web", label: "Web App", type: "container", parentId: "shop" }, + { id: "api", label: "API", type: "container", parentId: "shop" }, + { id: "ctrl", label: "Controller", type: "component", parentId: "api" }, + ], + relationships: [ + { id: "r1", source: "customer", target: "web", label: "uses" }, + { id: "r2", source: "web", target: "api", label: "calls" }, + { id: "r3", source: "customer", target: "shop", label: "shops" }, + ], + }); + writeFileSync(source, JSON.stringify(c4), "utf8"); + views(source, 0, output, { views: "system,executive" }); + assert.deepEqual(validate(output).summary, { pages: 2, valid: true, invalidPages: 0 }); + assert.deepEqual([...pageVertexIds(output, "System")].sort(), ["api", "ctrl", "customer", "shop", "web"]); + assert.deepEqual([...pageVertexIds(output, "Executive")].sort(), ["api", "ctrl", "customer", "shop", "web"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/src/actions/views/action.ts b/scripts/src/actions/views/action.ts index 66d67ad..96372ff 100644 --- a/scripts/src/actions/views/action.ts +++ b/scripts/src/actions/views/action.ts @@ -1,5 +1,6 @@ import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js"; -import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import type { DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; import { projectLinkedViews, type ViewName } from "../../services/semantic-lifecycle/analysis.js"; import { atomicWrite, loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js"; export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record { @@ -10,7 +11,14 @@ export function run(filePath: string, _page = 0, outputPath?: string, options: L const unknown = names?.filter((name) => !allowed.has(name)) ?? []; if (unknown.length) throw new Error(`Unknown linked view: ${unknown.join(", ")}`); const views = projectLinkedViews(source, names); - const ir: DiagramIRV2 = { version: 2, title: source.title, provenance: source.provenance, pages: views.map((view) => ({ id: view.id, title: view.title, nodes: view.nodes, edges: view.edges, layout: { type: "manual" }, properties: { linkedView: true, sourcePageIds: view.sourcePageIds, fallback: view.fallback, fallbackReason: view.fallbackReason, hint: view.hint } })) }; + // A view unions nodes from several source pages, so their page-relative positions cannot coexist: keep only + // the sizes and let the layered layout place everything (waypoints are dropped for the same reason). + const relayout = (node: DiagramNode): DiagramNode => { + const { geometry, ...rest } = node; + return geometry ? { ...rest, width: geometry.width, height: geometry.height } : rest; + }; + const ir: DiagramIRV2 = { version: 2, title: source.title, provenance: source.provenance, pages: views.map((view) => ({ id: view.id, title: view.title, nodes: view.nodes.map(relayout), edges: view.edges.map(({ waypoints: _waypoints, ...edge }) => edge), layout: { type: "layered" }, properties: { linkedView: true, sourcePageIds: view.sourcePageIds, fallback: view.fallback, fallbackReason: view.fallbackReason, hint: view.hint } })) }; + validateDiagramIR(ir); const output = atomicWrite(outputPath, diagramIRToDrawio(ir), [filePath]); return { action: "views", output, views: views.map(({ id, fallback, fallbackReason, hint }) => ({ id, fallback, fallbackReason, hint })) }; } diff --git a/scripts/src/cli/commands.ts b/scripts/src/cli/commands.ts index 7cc1fc2..e77d6f6 100644 --- a/scripts/src/cli/commands.ts +++ b/scripts/src/cli/commands.ts @@ -29,7 +29,7 @@ import type { LifecycleActionOptions } from "../services/semantic-lifecycle/life // --------------------------------------------------------------------------- type ActionModule = { - run: (filePath: string, pageIndex?: number, outputPath?: string, options?: LifecycleActionOptions) => Record; + run: (filePath: string, pageIndex?: number, outputPath?: string, options?: LifecycleActionOptions & { force?: boolean }) => Record; }; const ACTIONS: Record Promise> = { @@ -63,7 +63,7 @@ const ACTIONS: Record Promise> = { // --------------------------------------------------------------------------- async function main(): Promise { - let values: { file?: string; action?: string; page?: string; output?: string; help?: boolean; spec?: string; base?: string; strict?: boolean; prune?: boolean; "dry-run"?: boolean; fail?: string; views?: string; kind?: string; property?: string[]; from?: string; to?: string }; + let values: { file?: string; action?: string; page?: string; output?: string; help?: boolean; spec?: string; base?: string; strict?: boolean; prune?: boolean; force?: boolean; "dry-run"?: boolean; fail?: string; views?: string; kind?: string; property?: string[]; from?: string; to?: string }; try { ({ values } = parseArgs({ args: process.argv.slice(2), @@ -76,6 +76,7 @@ async function main(): Promise { base: { type: "string" }, strict: { type: "boolean" }, prune: { type: "boolean" }, + force: { type: "boolean" }, "dry-run": { type: "boolean" }, fail: { type: "string" }, views: { type: "string" }, @@ -100,6 +101,7 @@ Lifecycle options: --base Previous generated model for three-way sync --strict Treat policy warnings as failures --prune Remove identities absent from incoming sync model + --force Write sync output despite unresolved conflicts (manual values kept) --dry-run Return edit/sync preview without writing --fail Failed node for what-if or story overlay --views executive,system,deployment,dataflow,security @@ -149,7 +151,7 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`); try { const mod = await ACTIONS[actionName](); const result = mod.run(filePath, pageIndex, values.output, { - spec: values.spec, base: values.base, strict: values.strict, prune: values.prune, + spec: values.spec, base: values.base, strict: values.strict, prune: values.prune, force: values.force, dryRun: values["dry-run"], fail: values.fail, views: values.views, kind: values.kind, property: values.property, from: values.from, to: values.to, }); diff --git a/scripts/src/services/authoring-router/orthogonal-router.test.ts b/scripts/src/services/authoring-router/orthogonal-router.test.ts index fe6d729..e8dd142 100644 --- a/scripts/src/services/authoring-router/orthogonal-router.test.ts +++ b/scripts/src/services/authoring-router/orthogonal-router.test.ts @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; +import { run as validateConnectors } from "../../actions/page-connectors-validation/action.js"; +import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js"; import type { DiagramPage, DiagramPoint, DiagramGeometry } from "../../model/diagram-ir.js"; import { routePageEdges } from "./orthogonal-router.js"; @@ -106,3 +111,61 @@ test("routing resolves nested node geometry to page coordinates", () => { assert.equal(segmentCrossesRect(path[index], path[index + 1], blocker), false); } }); + +test("routes into and out of an enclosing container avoid its header band", () => { + const page: DiagramPage = { + id: "containers", + title: "Containers", + layout: { type: "layered", direction: "horizontal" }, + nodes: [ + { id: "vpc", label: "VPC", kind: "container" }, + { id: "web", label: "Web", parentId: "vpc" }, + { id: "app", label: "App", parentId: "vpc" }, + { id: "db", label: "Database", parentId: "vpc" }, + { id: "user", label: "User" }, + { id: "ext", label: "External Payment Provider", properties: {} }, + ], + edges: [ + { id: "e1", source: "user", target: "web", label: "HTTPS" }, + { id: "e2", source: "web", target: "app" }, + { id: "e3", source: "app", target: "db", label: "SQL" }, + { id: "e4", source: "app", target: "ext", label: "REST" }, + { id: "e5", source: "user", target: "ext", label: "redirect" }, + ], + }; + const xml = diagramIRToDrawio({ version: 2, pages: [page] }); + assert.equal(xml, diagramIRToDrawio({ version: 2, pages: [page] })); + const dir = mkdtempSync(join(tmpdir(), "router-header-band-")); + const file = join(dir, "containers.drawio"); + try { + writeFileSync(file, xml, "utf8"); + const validation = validateConnectors(file) as { summary: { headerEdgeViolations: number; connectorShapeOverlaps: number }; issues: unknown[] }; + assert.equal(validation.summary.headerEdgeViolations, 0, JSON.stringify(validation.issues)); + assert.equal(validation.summary.connectorShapeOverlaps, 0, JSON.stringify(validation.issues)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("router treats only the header band of an endpoint's ancestor as an obstacle", () => { + const zone: DiagramGeometry = { x: 100, y: 100, width: 400, height: 260 }; + const page: DiagramPage = { + id: "band", + title: "Band", + layout: { type: "manual", gridSize: 10 }, + nodes: [ + { id: "zone", label: "Zone", kind: "container", geometry: zone }, + { id: "inside", label: "Inside", parentId: "zone", geometry: { x: 140, y: 120, width: 120, height: 60 } }, + { id: "above", label: "Above", geometry: { x: 140, y: 0, width: 120, height: 40 } }, + ], + edges: [{ id: "down", source: "above", target: "inside" }], + }; + const routed = routePageEdges(page); + // "inside" sits at (240, 220) in page coordinates; the edge runs from the bottom of "above" to its top. + const path = [{ x: 200, y: 40 }, ...routed.edges[0].waypoints!, { x: 300, y: 220 }]; + const header: DiagramGeometry = { ...zone, height: 30 }; + for (let index = 0; index < path.length - 1; index += 1) { + assert.equal(segmentCrossesRect(path[index], path[index + 1], header), false, JSON.stringify(path)); + } + assert.ok(path.some((point) => point.y > zone.y + 30 && point.x < zone.x), "route enters the container body from the side rather than through the header"); +}); diff --git a/scripts/src/services/authoring-router/orthogonal-router.ts b/scripts/src/services/authoring-router/orthogonal-router.ts index 3fa55dd..2fe119b 100644 --- a/scripts/src/services/authoring-router/orthogonal-router.ts +++ b/scripts/src/services/authoring-router/orthogonal-router.ts @@ -1,4 +1,4 @@ -import type { DiagramEdge, DiagramGeometry, DiagramPage, DiagramPoint } from "../../model/diagram-ir.js"; +import type { DiagramEdge, DiagramGeometry, DiagramNode, DiagramPage, DiagramPoint } from "../../model/diagram-ir.js"; interface Obstacle extends DiagramGeometry { id: string; @@ -102,6 +102,15 @@ function absoluteGeometryById(page: DiagramPage): Map { return result; } +// Height of a container's label band. Mirrors ir-to-drawio's default container style (swimlane with +// startSize=30) and draw.io's own swimlane default (40) when a custom style omits startSize. +function headerHeight(node: DiagramNode): number { + if (node.style === undefined) return node.kind === "container" ? 30 : 0; + const explicit = /(?:^|;)startSize=(\d+(?:\.\d+)?)/.exec(node.style); + if (explicit) return Number(explicit[1]); + return /(?:^|;)swimlane(?:;|$)/.test(node.style) ? 40 : 0; +} + function ancestorIds(page: DiagramPage, id: string): Set { const nodes = new Map(page.nodes.map((node) => [node.id, node])); const ancestors = new Set(); @@ -134,9 +143,16 @@ function routeEdge(edge: DiagramEdge, page: DiagramPage, geometries: Map node.id !== edge.source && node.id !== edge.target && !endpointAncestors.has(node.id) && geometries.has(node.id)) - .map((node) => ({ id: node.id, ...geometries.get(node.id)! })); + .filter((node) => node.id !== edge.source && node.id !== edge.target && geometries.has(node.id)) + .flatMap((node) => { + const geometry = geometries.get(node.id)!; + if (!endpointAncestors.has(node.id)) return [{ id: node.id, ...geometry }]; + const header = headerHeight(node); + return header > 0 ? [{ id: node.id, ...geometry, height: header }] : []; + }); const sourceStub = outwardStub(endpoints.source, source, padding); const targetStub = outwardStub(endpoints.target, target, padding); const middleX = snap((sourceStub.x + targetStub.x) / 2); diff --git a/scripts/src/services/profiles/c4.test.ts b/scripts/src/services/profiles/c4.test.ts index 8312b3b..8ee8142 100644 --- a/scripts/src/services/profiles/c4.test.ts +++ b/scripts/src/services/profiles/c4.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js"; import { validateDiagramIR } from "../../model/diagram-ir.js"; import { projectC4 } from "./c4.js"; @@ -39,7 +40,11 @@ test("C4 projects a common model into deterministic linked pages with stable ide api: ["c4-containers-shop", "c4-components-api"], orders: ["c4-components-api"], }); - assert.ok(first.pages.flatMap((page) => page.nodes).every((node) => /rounded=0/.test(node.style ?? ""))); + for (const node of first.pages.flatMap((page) => page.nodes)) { + if (node.kind === "container") assert.equal(node.style, undefined, `${node.id} must take the swimlane container default`); + else assert.match(node.style ?? "", /rounded=0/); + } + assert.equal(first.pages[1].nodes.find((node) => node.id === "shop")?.kind, "container"); assert.equal(validateDiagramIR(first), first); }); @@ -131,3 +136,24 @@ test("C4 deterministically retains adversarial cross-level relationships with pr }), `${relationship.id} has dangling projected endpoints`); } }); + +test("C4 places a container-to-system relationship on the containers page and renders containers as swimlanes", () => { + const model = { + title: "Shop", + elements: [ + { id: "shop", label: "Shop System", type: "system" as const }, + { id: "mail", label: "Mail System", type: "system" as const }, + { id: "api", label: "API", type: "container" as const, parentId: "shop" }, + { id: "ctrl", label: "Controller", type: "component" as const, parentId: "api" }, + ], + relationships: [{ id: "sends", source: "api", target: "mail", label: "sends" }], + }; + const ir = projectC4(model); + assert.deepEqual(ir, projectC4(model)); + assert.deepEqual(ir.pages.filter((page) => page.edges.some((edge) => edge.id === "sends")).map((page) => page.id), ["c4-containers-shop"]); + assert.equal(ir.pages.find((page) => page.id === "c4-components-api")?.nodes.some((node) => node.id === "mail"), false); + const xml = diagramIRToDrawio(ir); + assert.match(xml, /id="shop" value="Shop System" style="swimlane;[^"]*startSize=30/); + assert.match(xml, /id="api" value="API" style="swimlane;[^"]*startSize=30/); + assert.match(xml, /id="mail" value="Mail System" style="rounded=0;whiteSpace=wrap;html=1"/); +}); diff --git a/scripts/src/services/profiles/c4.ts b/scripts/src/services/profiles/c4.ts index 52d419e..3f8e5cc 100644 --- a/scripts/src/services/profiles/c4.ts +++ b/scripts/src/services/profiles/c4.ts @@ -55,12 +55,14 @@ export function projectC4(model: C4Model): DiagramIRV2 { ? pageFor("c4-containers", element.id) : element.type === "container" && model.elements.some((item) => item.type === "component" && item.parentId === element.id) ? pageFor("c4-components", element.id) : undefined; + const isContainer = model.elements.some((child) => child.parentId === element.id && included.has(child.id)); return { id: element.id, label: element.label, - kind: model.elements.some((child) => child.parentId === element.id && included.has(child.id)) ? "container" : `c4-${element.type}`, + kind: isContainer ? "container" : `c4-${element.type}`, ...(element.parentId && included.has(element.parentId) ? { parentId: element.parentId } : {}), - style: "rounded=0;whiteSpace=wrap;html=1", + // Elements shown with their children take the serializer's swimlane default so the label sits in a header band. + ...(isContainer ? {} : { style: "rounded=0;whiteSpace=wrap;html=1" }), properties: { ...cloneRecord(element.properties), c4Type: element.type, ...(drillDownPage ? { drillDownPage } : {}) }, provenance: cloneRecord(element.provenance), }; @@ -92,12 +94,19 @@ export function projectC4(model: C4Model): DiagramIRV2 { if (children.length) pageSpecs.push({ id: pageFor("c4-components", container.id), title: `${container.label} — Components`, elements: [container, ...children] }); } + // Cross-level relationships land on the page that natively shows their deepest endpoint (a container's + // relationship to a system belongs on the containers page, not on the component drill-down). + const levelOf = (type: C4ElementType): number => type === "component" ? 2 : type === "container" ? 1 : 0; + const pageLevel = (id: string): number => levelOf(id.startsWith("c4-components-") ? "component" : id.startsWith("c4-containers-") ? "container" : "system"); for (const relationship of model.relationships) { if (pageSpecs.some((page) => page.elements.some((element) => element.id === relationship.source) && page.elements.some((element) => element.id === relationship.target))) continue; + const [source, target] = [byId.get(relationship.source)!, byId.get(relationship.target)!]; + const deepest = levelOf(target.type) > levelOf(source.type) ? target : source; const candidates = pageSpecs.filter((page) => page.elements.some((element) => element.id === relationship.source || element.id === relationship.target)); - const page = candidates.at(-1) ?? pageSpecs[0]; + const page = candidates.find((candidate) => pageLevel(candidate.id) === levelOf(deepest.type) && candidate.elements.some((element) => element.id === deepest.id)) + ?? candidates.at(-1) ?? pageSpecs[0]; const included = new Set(page.elements.map((element) => element.id)); for (const endpoint of [relationship.source, relationship.target]) { if (!included.has(endpoint)) { diff --git a/scripts/src/services/profiles/sequence.test.ts b/scripts/src/services/profiles/sequence.test.ts index b6b4082..8339f10 100644 --- a/scripts/src/services/profiles/sequence.test.ts +++ b/scripts/src/services/profiles/sequence.test.ts @@ -131,3 +131,37 @@ test("sequence page width includes nested self-call waypoint extents plus margin assert.equal(waypointRight, 530); assert.ok(ir.pages[0].width! >= waypointRight + 40); }); + +test("self-messages loop on the right of the lifeline and never cross the activation bar", () => { + const input = { + title: "Login", + participants: [{ id: "u", label: "User" }, { id: "w", label: "Web" }, { id: "a", label: "Auth" }], + messages: [ + { id: "m1", from: "u", to: "w", label: "login" }, + { id: "m2", from: "w", to: "a", label: "verify" }, + { id: "m3", from: "a", to: "a", label: "hash" }, + { id: "m4", from: "a", to: "a", label: "done", type: "return" as const }, + { id: "m5", from: "a", to: "w", label: "ok", type: "return" as const }, + { id: "m6", from: "w", to: "u", label: "token", type: "return" as const }, + ], + }; + const ir = createSequenceDiagram(input); + const lifelineX = 40 + 2 * 200 + 60; + for (const id of ["m3", "m4"]) { + const source = ir.pages[0].nodes.find((node) => node.id === `${id}-source-anchor`)!.geometry!; + const target = ir.pages[0].nodes.find((node) => node.id === `${id}-target-anchor`)!.geometry!; + const edge = ir.pages[0].edges.find((edge) => edge.id === id)!; + assert.ok(source.x > lifelineX && target.x > lifelineX, `${id} anchors must both sit right of the lifeline`); + assert.equal(target.y, source.y + 30, `${id} returns 30px below its departure`); + assert.ok(edge.waypoints!.every((point) => point.x > lifelineX), `${id} waypoints stay on the right side`); + } + const dir = mkdtempSync(join(tmpdir(), "sequence-self-message-")); + const file = join(dir, "self.drawio"); + try { + writeFileSync(file, diagramIRToDrawio(ir), "utf8"); + const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number }; issues: unknown[] }; + assert.equal(validation.summary.connectorShapeOverlaps, 0, JSON.stringify(validation.issues)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/src/services/profiles/sequence.ts b/scripts/src/services/profiles/sequence.ts index b1d22aa..2f21002 100644 --- a/scripts/src/services/profiles/sequence.ts +++ b/scripts/src/services/profiles/sequence.ts @@ -66,12 +66,16 @@ export function createSequenceDiagram(input: SequenceInput): DiagramIRV2 { const fromX = xById.get(message.from)! + 60; const toX = xById.get(message.to)! + 60; const direction = Math.sign(toX - fromX) || 1; + const self = message.from === message.to; const clearX = (id: string, centerX: number, side: number) => centerX + side * (10 + maxDepthById.get(id)! * 10); const sourceX = clearX(message.from, fromX, direction); - const targetX = clearX(message.to, toX, -direction); + // A self-message loops out to the right and returns 30px lower on the same side, so its target + // anchor sits right of the lifeline too; otherwise the return leg would cross the activation bar. + const targetX = clearX(message.to, toX, self ? direction : -direction); + const targetY = self ? y + 30 : y; return [ { id: `${message.id}-source-anchor`, label: "\u200B", kind: "sequence-message-anchor", geometry: { x: sourceX - 1, y: y - 1, width: 2, height: 2 }, style: "opacity=0;fillOpacity=0;strokeOpacity=0;connectable=1", properties: { participantId: message.from, messageId: message.id, role: "source" } }, - { id: `${message.id}-target-anchor`, label: "\u200B", kind: "sequence-message-anchor", geometry: { x: targetX - 1, y: y - 1, width: 2, height: 2 }, style: "opacity=0;fillOpacity=0;strokeOpacity=0;connectable=1", properties: { participantId: message.to, messageId: message.id, role: "target" } }, + { id: `${message.id}-target-anchor`, label: "\u200B", kind: "sequence-message-anchor", geometry: { x: targetX - 1, y: targetY - 1, width: 2, height: 2 }, style: "opacity=0;fillOpacity=0;strokeOpacity=0;connectable=1", properties: { participantId: message.to, messageId: message.id, role: "target" } }, ]; }); const nodes = [...participantNodes, ...lifelineNodes, ...activationNodes, ...anchorNodes]; @@ -82,7 +86,7 @@ export function createSequenceDiagram(input: SequenceInput): DiagramIRV2 { const direction = Math.sign(toX - fromX) || 1; const sourceX = fromX + direction * (10 + maxDepthById.get(message.from)! * 10); const targetX = toX - direction * (10 + maxDepthById.get(message.to)! * 10); - const waypoints = message.from === message.to ? [{ x: sourceX + 60, y }, { x: sourceX + 60, y: y + 30 }, { x: targetX, y: y + 30 }] : [{ x: sourceX, y }, { x: targetX, y }]; + const waypoints = message.from === message.to ? [{ x: sourceX + 60, y }, { x: sourceX + 60, y: y + 30 }] : [{ x: sourceX, y }, { x: targetX, y }]; return { id: message.id, source: `${message.id}-source-anchor`, target: `${message.id}-target-anchor`, label: message.label, kind: `sequence-${message.type ?? "call"}`, waypoints, properties: { sequence: index + 1, activationDepth: messageDepths[index], semanticSource: message.from, semanticTarget: message.to }, provenance: message.provenance === undefined ? undefined : structuredClone(message.provenance) }; }); const activationRight = Math.max(0, ...activationNodes.map((node) => node.geometry.x + node.geometry.width)); diff --git a/scripts/src/services/profiles/tube-map.test.ts b/scripts/src/services/profiles/tube-map.test.ts index 6384ca5..dda0d03 100644 --- a/scripts/src/services/profiles/tube-map.test.ts +++ b/scripts/src/services/profiles/tube-map.test.ts @@ -119,3 +119,31 @@ test("tube lines sharing stations have distinct complete routes including statio rmSync(dir, { recursive: true, force: true }); } }); + +test("five or more line corridors stay above the first station row and never cross station boxes", () => { + const ir = createTubeMap({ + title: "Five lines", + stations: ["s1", "s2", "s3", "s4", "s5", "s6"].map((id) => ({ id, label: id.toUpperCase() })), + lines: [ + { id: "l1", label: "L1", color: "#dd0000", stations: ["s1", "s2", "s3"] }, + { id: "l2", label: "L2", color: "#0000dd", stations: ["s2", "s4"] }, + { id: "l3", label: "L3", color: "#00aa00", stations: ["s5", "s6"] }, + { id: "l4", label: "L4", color: "#ccaa00", stations: ["s6", "s3"] }, + { id: "l5", label: "L5", color: "#aa00aa", stations: ["s1", "s4"] }, + ], + }); + const topStation = Math.min(...ir.pages[0].nodes.map((node) => node.geometry!.y)); + for (const edge of ir.pages[0].edges) { + const corridor = edge.waypoints!.find((point, index, points) => index > 0 && point.y === points[index - 1].y)!; + assert.ok(corridor.y < topStation, `corridor ${corridor.y} of ${edge.id} runs through the first station row (top ${topStation})`); + } + const dir = mkdtempSync(join(tmpdir(), "tube-map-five-lines-")); + const file = join(dir, "five.drawio"); + try { + writeFileSync(file, diagramIRToDrawio(ir), "utf8"); + const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number } }; + assert.equal(validation.summary.connectorShapeOverlaps, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/src/services/profiles/tube-map.ts b/scripts/src/services/profiles/tube-map.ts index a5aadf1..1aa4dd7 100644 --- a/scripts/src/services/profiles/tube-map.ts +++ b/scripts/src/services/profiles/tube-map.ts @@ -22,9 +22,12 @@ export function createTubeMap(input: TubeMapInput): DiagramIRV2 { memberships.forEach((lines) => lines.sort()); const firstLine = new Map(); lines.forEach((line, lineIndex) => line.stations.forEach((id) => { if (!firstLine.has(id)) firstLine.set(id, lineIndex); })); + // Every line owns a horizontal corridor at 40 + 20*i; the first station row sits below all of them + // so corridors never run through station boxes however many lines there are. + const corridorBand = 20 * lines.length; const centers = new Map(); const nodes = stations.map((station, index) => { - const center = { x: 100 + index * 160, y: 100 + (firstLine.get(station.id) ?? lines.length) * 160 }; centers.set(station.id, center); + const center = { x: 100 + index * 160, y: 100 + corridorBand + (firstLine.get(station.id) ?? lines.length) * 160 }; centers.set(station.id, center); const stationLines = memberships.get(station.id)!; return { id: station.id, label: station.label, kind: stationLines.length > 1 ? "tube-interchange" : "tube-station", geometry: { x: center.x - 20, y: center.y - 20, width: 40, height: 40 }, style: `ellipse;whiteSpace=wrap;html=1;strokeWidth=${stationLines.length > 1 ? 6 : 3}`, properties: { interchange: stationLines.length > 1, lines: stationLines }, provenance: station.provenance === undefined ? undefined : structuredClone(station.provenance) }; }); @@ -45,6 +48,6 @@ export function createTubeMap(input: TubeMapInput): DiagramIRV2 { ]; return { id: `${line.id}-segment-${index + 1}`, source, target, kind: "tube-line", style: `edgeStyle=none;rounded=0;strokeColor=${line.color};strokeWidth=8;endArrow=none`, waypoints, properties: { lineId: line.id, lineLabel: line.label, segment: index + 1 }, provenance: line.provenance === undefined ? undefined : structuredClone(line.provenance) }; })); - const ir: DiagramIRV2 = { version: 2, title: input.title, pages: [{ id: "tube-map", title: input.title, nodes, edges, layout: { type: "manual", gridSize: 10 }, width: Math.max(320, stations.length * 160 + 40), height: Math.max(320, (lines.length + 1) * 160), properties: { profile: "tube-map", lines: lines.map((line) => ({ id: line.id, label: line.label, color: line.color })) } }], provenance: input.provenance === undefined ? undefined : structuredClone(input.provenance), properties: { profile: "tube-map" } }; + const ir: DiagramIRV2 = { version: 2, title: input.title, pages: [{ id: "tube-map", title: input.title, nodes, edges, layout: { type: "manual", gridSize: 10 }, width: Math.max(320, stations.length * 160 + 40), height: Math.max(320, (lines.length + 1) * 160 + corridorBand), properties: { profile: "tube-map", lines: lines.map((line) => ({ id: line.id, label: line.label, color: line.color })) } }], provenance: input.provenance === undefined ? undefined : structuredClone(input.provenance), properties: { profile: "tube-map" } }; return validateDiagramIR(ir) as DiagramIRV2; } diff --git a/scripts/src/services/semantic-lifecycle/analysis.test.ts b/scripts/src/services/semantic-lifecycle/analysis.test.ts index 51215c1..7dcaad6 100644 --- a/scripts/src/services/semantic-lifecycle/analysis.test.ts +++ b/scripts/src/services/semantic-lifecycle/analysis.test.ts @@ -56,6 +56,48 @@ test("what-if outgoing reachability stops at failure-isolating edges", () => { assert.throws(() => simulateFailure(IR, "missing"), /unknown node/i); }); +test("semantic operations merge an element projected onto several pages", () => { + const shared: DiagramIRV2 = { + version: 2, + pages: [ + { id: "context", title: "Context", nodes: [ + { id: "shop", label: "Shop", kind: "c4-system", properties: { c4Type: "system" }, provenance: { line: 2 } }, + { id: "buyer", label: "Buyer", kind: "actor" }, + ], edges: [{ id: "uses", source: "buyer", target: "shop" }] }, + { id: "containers", title: "Containers", nodes: [ + { id: "shop", label: "Shop", kind: "container", properties: { c4Type: "system" }, provenance: { line: 2 } }, + { id: "buyer", label: "Buyer", kind: "actor" }, + { id: "api", label: "API", kind: "service", parentId: "shop" }, + ], edges: [{ id: "uses", source: "buyer", target: "shop" }, { id: "calls", source: "buyer", target: "api" }] }, + ], + }; + const system = projectLinkedViews(shared, ["system"])[0]; + assert.deepEqual(system.nodes.map((node) => node.id), ["shop", "buyer", "api"]); + assert.equal(system.nodes.find((node) => node.id === "shop")?.kind, "c4-system", "specific kind wins over the presentational container promotion"); + assert.equal(system.nodes.find((node) => node.id === "api")?.parentId, undefined, "parent that is not a container in the merged model is dropped"); + assert.deepEqual(system.edges.map((edge) => edge.id), ["uses", "calls"]); + assert.deepEqual(queryDiagram(shared, { from: "buyer", to: "api" }).path, ["buyer", "api"]); + assert.equal(runPolicies(shared, ["no-orphans"]).findings.length, 0); + assert.deepEqual(simulateFailure(shared, "buyer").impacted, ["api", "shop"]); + assert.throws(() => queryDiagram({ ...shared, pages: [shared.pages[0], { ...shared.pages[1], edges: [{ id: "uses", source: "api", target: "shop" }] }] }, {}), /ambiguous.*edge.*uses/i); +}); + +test("views keep container parents that are part of the selection and detach the rest", () => { + const nested: DiagramIRV2 = { version: 2, pages: [{ id: "p", title: "P", nodes: [ + { id: "outer", label: "Outer", kind: "container", geometry: { x: 100, y: 100, width: 400, height: 300 }, properties: { importance: 5 } }, + { id: "inner", label: "Inner", kind: "container", parentId: "outer", geometry: { x: 20, y: 40, width: 200, height: 150 } }, + { id: "leaf", label: "Leaf", kind: "service", parentId: "inner", geometry: { x: 10, y: 50, width: 120, height: 60 }, properties: { importance: 9 } }, + ], edges: [] }] }; + const [executive] = projectLinkedViews({ ...nested, pages: [{ ...nested.pages[0], nodes: nested.pages[0].nodes.slice(0, 2).concat(Array.from({ length: 11 }, (_, index) => ({ id: `n${index}`, label: `N${index}`, properties: { importance: 8 } })), nested.pages[0].nodes.slice(2)) }] }, ["executive"]); + const leaf = executive.nodes.find((node) => node.id === "leaf")!; + assert.equal(executive.nodes.some((node) => node.id === "inner"), false); + assert.equal(leaf.parentId, undefined); + assert.deepEqual(leaf.geometry, { x: 130, y: 190, width: 120, height: 60 }); + const [system] = projectLinkedViews(nested, ["system"]); + assert.equal(system.nodes.find((node) => node.id === "leaf")?.parentId, "inner"); + assert.deepEqual(system.nodes.find((node) => node.id === "leaf")?.geometry, { x: 10, y: 50, width: 120, height: 60 }); +}); + test("semantic operations fail closed on ambiguous page-local IDs", () => { const ambiguous: DiagramIRV2 = { version: 2, diff --git a/scripts/src/services/semantic-lifecycle/analysis.ts b/scripts/src/services/semantic-lifecycle/analysis.ts index eef03df..e75af8b 100644 --- a/scripts/src/services/semantic-lifecycle/analysis.ts +++ b/scripts/src/services/semantic-lifecycle/analysis.ts @@ -8,20 +8,72 @@ export interface QueryResult { nodes: DiagramNode[]; edges: DiagramEdge[]; path? export interface PolicyFinding { rule: string; severity: "error" | "warning"; subject: string; message: string; hint: string } export interface PolicyReport { errors: number; warnings: number; findings: PolicyFinding[] } +function stableJson(value: unknown): string { + if (value === undefined) return "null"; + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +// A semantic element may legitimately be projected onto several pages (the C4 profile does this). +// Projections agree on identity (label, properties, provenance, extensions); only presentation may differ: +// a page may promote the element to kind "container" when its children are shown there, and parentId +// depends on whether the parent is projected onto that page. Anything else is a genuine conflict. +function mergeNode(existing: DiagramNode, incoming: DiagramNode, pageIds: string[]): DiagramNode { + const conflict = (): never => { throw new Error(`Ambiguous semantic node ID ${existing.id} appears on pages ${pageIds.join(", ")} with conflicting definitions`); }; + if (existing.label !== incoming.label) conflict(); + for (const key of ["properties", "provenance", "extensions"] as const) if (stableJson(existing[key]) !== stableJson(incoming[key])) conflict(); + let kind = existing.kind; + if (existing.kind !== incoming.kind) { + if (existing.kind === "container") kind = incoming.kind; + else if (incoming.kind !== "container") conflict(); + } + return { ...existing, ...(kind === undefined ? {} : { kind }), ...(existing.parentId === undefined && incoming.parentId !== undefined ? { parentId: incoming.parentId } : {}) }; +} + function flatten(ir: DiagramIRV2): { nodes: DiagramNode[]; edges: DiagramEdge[] } { validateDiagramIR(ir); const pagesByNodeId = new Map(); + const nodes = new Map(); + const edges = new Map(); for (const page of ir.pages) { - for (const node of page.nodes) pagesByNodeId.set(node.id, [...(pagesByNodeId.get(node.id) ?? []), page.id]); + for (const node of page.nodes) { + const pageIds = [...(pagesByNodeId.get(node.id) ?? []), page.id]; + pagesByNodeId.set(node.id, pageIds); + const existing = nodes.get(node.id); + nodes.set(node.id, existing ? mergeNode(existing, node, pageIds) : node); + } + for (const edge of page.edges) { + const existing = edges.get(edge.id); + if (!existing) { edges.set(edge.id, edge); continue; } + if (existing.source !== edge.source || existing.target !== edge.target) throw new Error(`Ambiguous semantic edge ID ${edge.id} connects different endpoints on different pages`); + } } - for (const [id, pageIds] of pagesByNodeId) { - if (pageIds.length > 1) throw new Error(`Ambiguous semantic node ID ${id} appears on pages ${pageIds.join(", ")}`); - } - return { nodes: ir.pages.flatMap((page) => page.nodes), edges: ir.pages.flatMap((page) => page.edges) }; + return { nodes: [...nodes.values()], edges: [...edges.values()] }; } function induced(nodes: DiagramNode[], edges: DiagramEdge[], selected: Set): { nodes: DiagramNode[]; edges: DiagramEdge[] } { - return { nodes: nodes.filter((node) => selected.has(node.id)).map((node) => structuredClone(node)), edges: edges.filter((edge) => selected.has(edge.source) && selected.has(edge.target)).map((edge) => structuredClone(edge)) }; + const byId = new Map(nodes.map((node) => [node.id, node])); + const keepsParent = (node: DiagramNode): boolean => node.parentId !== undefined && selected.has(node.parentId) && byId.get(node.parentId)?.kind === "container"; + // Geometry is relative to the parent; when the parent is projected away, re-express it in page coordinates. + const detach = (node: DiagramNode): DiagramNode => { + const { parentId, ...rest } = node; + if (!rest.geometry) return rest; + let offset = { x: 0, y: 0 }; + const seen = new Set(); + for (let current = parentId ? byId.get(parentId) : undefined; current && !seen.has(current.id); current = current.parentId ? byId.get(current.parentId) : undefined) { + seen.add(current.id); + offset = { x: offset.x + (current.geometry?.x ?? 0), y: offset.y + (current.geometry?.y ?? 0) }; + } + return { ...rest, geometry: { ...rest.geometry, x: rest.geometry.x + offset.x, y: rest.geometry.y + offset.y } }; + }; + return { + nodes: nodes.filter((node) => selected.has(node.id)).map((node) => structuredClone(keepsParent(node) ? node : detach(node))), + edges: edges.filter((edge) => selected.has(edge.source) && selected.has(edge.target)).map((edge) => structuredClone(edge)), + }; } export function projectLinkedViews(ir: DiagramIRV2, requested: ViewName[] = ["executive", "system", "deployment", "dataflow", "security"]): LinkedView[] { diff --git a/scripts/src/services/semantic-lifecycle/publishing.test.ts b/scripts/src/services/semantic-lifecycle/publishing.test.ts index 97b0e1f..d42654d 100644 --- a/scripts/src/services/semantic-lifecycle/publishing.test.ts +++ b/scripts/src/services/semantic-lifecycle/publishing.test.ts @@ -44,3 +44,12 @@ test("story fails closed on ambiguous multi-page node IDs", () => { }; assert.throws(() => createStoryHtml(ambiguous), /ambiguous.*shared.*one.*two/i); }); + +test("story HTML escapes markup-significant characters inside the embedded JSON so labels cannot break out of ")); + assert.deepEqual((JSON.parse(blob) as Array<{ label: string }>).map((step) => step.label), [label]); +}); diff --git a/scripts/src/services/semantic-lifecycle/publishing.ts b/scripts/src/services/semantic-lifecycle/publishing.ts index c50acb8..68704f4 100644 --- a/scripts/src/services/semantic-lifecycle/publishing.ts +++ b/scripts/src/services/semantic-lifecycle/publishing.ts @@ -26,7 +26,8 @@ export function createStoryHtml(ir: DiagramIRV2, options: StoryOptions = {}): st const steps = nodes.map((node) => ({ id: node.id, label: node.label, detail: `${node.kind ?? "component"}${node.properties?.owner ? ` · owner: ${String(node.properties.owner)}` : ""}${node.provenance ? ` · provenance: ${stableJson(node.provenance)}` : ""}` })); const nodeSvg = nodes.map((node) => { const p = positions.get(node.id)!; const status = node.id === failed ? "failed" : impacted.has(node.id) ? "impacted" : "normal"; return `${escapeHtml(node.label)}`; }).join(""); const alternatives = steps.map((step) => `
  • ${escapeHtml(step.label)} — ${escapeHtml(step.detail)}
  • `).join("") + edges.map((edge) => `
  • ${escapeHtml(edge.source)} → ${escapeHtml(edge.target)}${edge.label ? ` — ${escapeHtml(edge.label)}` : ""}
  • `).join(""); - const data = stableJson(steps).replaceAll(" must not contain markup-significant characters (`