Merge pull request #3 from CTOTools-skills-code-agent/1.0.0.0/IIAA-XYZ-review-fixes-001

Fix review findings: importer DoS, sync data loss, views/profile rendering, docs
This commit is contained in:
2026-09-06 17:02:24 +03:00
committed by GitHub Enterprise
32 changed files with 801 additions and 209 deletions
+1 -1
View File
@@ -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
+41 -92
View File
@@ -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=<name>`
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 <skill>/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`.
---
+7 -11
View File
@@ -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`<br>`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`<br>`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`<br>`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`<br>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`<br>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`<br>`scripts/src/services/semantic-lifecycle/import-drawio.ts`<br>`scripts/src/services/semantic-lifecycle/analysis.ts`<br>`scripts/src/services/semantic-lifecycle/sync.ts`<br>`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`<br>`scripts/src/cli/semantic-lifecycle.test.ts`<br>`scripts/src/actions/doctor/action.ts`<br>`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`<br>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`<br>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`<br>`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`<br>`scripts/src/services/transforms/reverse.test.ts`<br>`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`<br>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`<br>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`<br>`scripts/src/services/transforms/semantic-diff.test.ts`<br>`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`<br>`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`<br>`scripts/src/services/authoring-router/orthogonal-router.test.ts`<br>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`<br>`scripts/src/services/authoring-router/orthogonal-router.test.ts`<br>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`<br>`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`<br>`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`<br>`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`<br>`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`<br>`scripts/src/authoring/ir-to-drawio.ts`<br>`scripts/src/services/layout/layout-engine.ts`<br>`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`<br>`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`<br>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`<br>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`<br>`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`<br>`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`<br>`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`<br>`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`<br>`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`<br>`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`<br>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`<br>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`<br>`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`<br>`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`<br>`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.
+81 -50
View File
@@ -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 <path> --action <action> [--page <index>] [--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 "<skill>/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 "<skill>/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).
+23 -8
View File
@@ -36,7 +36,7 @@ The git repository is the source of truth. Install dependencies and build inside
```bash
cd <skill>/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<st
## Adding a new action
1. Create `src/actions/<name>/action.ts` exporting:
1. Create `src/actions/<name>/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<Record<string, unknown>>
export function run(filePath: string, pageIndex: number = 0): Record<string, unknown> {
const pages = parseAllPages(filePath);
const page = pages[pageIndex];
if (!page) {
return { error: true, message: `Page index ${pageIndex} not found` };
}
// ...
return { action: "<name>", /* ... */ };
}
```
The full `ActionModule` contract is `run(filePath: string, pageIndex?: number, outputPath?: string, options?: LifecycleActionOptions): Record<string, unknown>`; 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
---
+43 -1
View File
@@ -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
---
+10 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+5 -3
View File
@@ -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=<action> [--page <index>]
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=<action> [--page <index>] [--output <path>]
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 }}
+45 -3
View File
@@ -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=<action> [--page <index>]
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=<action> [--page <index>] [--output <path>]
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=<id> --to=<id>
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=<node-id>
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:
+7 -3
View File
@@ -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<string, unknown> {
export type SyncActionOptions = LifecycleActionOptions & { force?: boolean };
export function run(filePath: string, _page = 0, outputPath?: string, options: SyncActionOptions = {}): Record<string, unknown> {
if (!options.base || !options.spec) throw new Error("sync requires --base <base> and --spec <incoming>"); 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 };
}
+80
View File
@@ -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<string> {
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 });
}
});
+10 -2
View File
@@ -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<string, unknown> {
@@ -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 })) };
}
+5 -3
View File
@@ -29,7 +29,7 @@ import type { LifecycleActionOptions } from "../services/semantic-lifecycle/life
// ---------------------------------------------------------------------------
type ActionModule = {
run: (filePath: string, pageIndex?: number, outputPath?: string, options?: LifecycleActionOptions) => Record<string, unknown>;
run: (filePath: string, pageIndex?: number, outputPath?: string, options?: LifecycleActionOptions & { force?: boolean }) => Record<string, unknown>;
};
const ACTIONS: Record<string, () => Promise<ActionModule>> = {
@@ -63,7 +63,7 @@ const ACTIONS: Record<string, () => Promise<ActionModule>> = {
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
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<void> {
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 <path> 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 <node-id> Failed node for what-if or story overlay
--views <csv> 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,
});
@@ -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");
});
@@ -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<string, DiagramGeometry> {
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<string> {
const nodes = new Map(page.nodes.map((node) => [node.id, node]));
const ancestors = new Set<string>();
@@ -134,9 +143,16 @@ function routeEdge(edge: DiagramEdge, page: DiagramPage, geometries: Map<string,
}
const endpoints = endpointPair(source, target);
const endpointAncestors = new Set([...ancestorIds(page, edge.source), ...ancestorIds(page, edge.target)]);
// Containers enclosing an endpoint may be crossed, except for their header band: a route through the
// label bar (or along its bottom line) merges visually with the container border.
const obstacles: Obstacle[] = page.nodes
.filter((node) => 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);
+27 -1
View File
@@ -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"/);
});
+12 -3
View File
@@ -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)) {
@@ -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 });
}
});
+7 -3
View File
@@ -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));
@@ -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 });
}
});
+5 -2
View File
@@ -22,9 +22,12 @@ export function createTubeMap(input: TubeMapInput): DiagramIRV2 {
memberships.forEach((lines) => lines.sort());
const firstLine = new Map<string, number>();
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<string, DiagramPoint>();
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;
}
@@ -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,
@@ -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<string, unknown>;
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<string, string[]>();
const nodes = new Map<string, DiagramNode>();
const edges = new Map<string, DiagramEdge>();
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 [id, pageIds] of pagesByNodeId) {
if (pageIds.length > 1) throw new Error(`Ambiguous semantic node ID ${id} appears on pages ${pageIds.join(", ")}`);
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`);
}
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<string>): { 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<string>();
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[] {
@@ -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 <script>", () => {
const label = "<!--<script>&</script>
x";
const html = createStoryHtml({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "a", label }], edges: [], layout: { type: "manual" } }] });
const blob = /<script>const STEPS=(.*?);let i=-1;/s.exec(html)![1];
assert.doesNotMatch(blob, /[<>&\u2028\u2029]/);
assert.equal(html.indexOf("</script>"), html.lastIndexOf("</script>"));
assert.deepEqual((JSON.parse(blob) as Array<{ label: string }>).map((step) => step.label), [label]);
});
@@ -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 `<g class="node ${status}" data-id="${escapeHtml(node.id)}" data-status="${status}" tabindex="0" role="button" aria-label="${escapeHtml(`${node.label}, ${node.kind ?? "component"}`)}"><rect x="${p.x}" y="${p.y}" width="${p.width}" height="${p.height}"/><text x="${p.x + p.width / 2}" y="${p.y + p.height / 2}" text-anchor="middle">${escapeHtml(node.label)}</text></g>`; }).join("");
const alternatives = steps.map((step) => `<li><strong>${escapeHtml(step.label)}</strong> — ${escapeHtml(step.detail)}</li>`).join("") + edges.map((edge) => `<li>${escapeHtml(edge.source)} → ${escapeHtml(edge.target)}${edge.label ? ` — ${escapeHtml(edge.label)}` : ""}</li>`).join("");
const data = stableJson(steps).replaceAll("</", "<\\/");
// JSON inside <script> must not contain markup-significant characters (`<!--`, `<script`, `</script`) or JS line terminators.
const data = stableJson(steps).replace(/[<>&\u2028\u2029]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self' data:; font-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>body{font:16px system-ui;margin:auto;max-width:1200px;padding:1rem;color:#17202a;background:#f7f8fa}button{padding:.5rem 1rem;margin-right:.5rem}svg{width:100%;min-height:500px;background:#fff;border:1px solid #667}.node rect{fill:#dae8fc;stroke:#315d87;stroke-width:2}.node.failed rect{fill:#f8cecc;stroke:#8c2f2b;stroke-width:4}.node.impacted rect{fill:#ffe6cc;stroke:#9a5c00;stroke-width:3}.node.active rect{stroke:#005fcc;stroke-width:5}line{stroke:#45525e;stroke-width:2}text{fill:#17202a}#narration{padding:1rem;border-left:4px solid #005fcc;background:#fff}</style></head><body><header><h1>${escapeHtml(title)}</h1><p>${nodes.length} components and ${edges.length} relationships.</p><button id="prev" type="button">Previous</button><button id="next" type="button">Next</button><button id="overview" type="button">Overview</button><span id="counter" aria-live="polite"></span><div id="narration" aria-live="polite">Overview</div></header><main><svg viewBox="0 0 1000 600" role="img" aria-labelledby="diagram-title diagram-desc"><title id="diagram-title">${escapeHtml(title)}</title><desc id="diagram-desc">Architecture containing ${nodes.length} components and ${edges.length} directed relationships.</desc><defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L0,6 L9,3 z"/></marker></defs>${edgeSvg}${nodeSvg}</svg><details><summary>Text alternative</summary><ol>${alternatives}</ol></details></main><script>const STEPS=${data};let i=-1;const nodes=[...document.querySelectorAll('.node')];function show(n){i=n;nodes.forEach(x=>x.classList.remove('active'));if(i>=0&&i<STEPS.length){const s=STEPS[i];nodes.find(x=>x.dataset.id===s.id)?.classList.add('active');narration.textContent=s.label+' — '+s.detail;counter.textContent=(i+1)+' / '+STEPS.length}else{narration.textContent='Overview';counter.textContent='Overview'}}next.onclick=()=>show(Math.min(STEPS.length-1,i+1));prev.onclick=()=>show(Math.max(-1,i-1));overview.onclick=()=>show(-1);nodes.forEach((node,index)=>{node.onclick=()=>show(index);node.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();show(index)}}});document.addEventListener('keydown',e=>{if(e.key==='ArrowRight')next.click();if(e.key==='ArrowLeft')prev.click()});show(-1);</script></body></html>`;
}
@@ -1,4 +1,7 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { syncDiagramIR } from "./sync.js";
@@ -90,3 +93,52 @@ test("sync retains removed pages unless pruning is explicit", () => {
assert.equal(pruned.ir.pages.some((page) => page.id === "legacy"), false);
assert.ok(pruned.removed.includes("legacy"));
});
const page = (id: string, nodes: DiagramIRV2["pages"][number]["nodes"]): DiagramIRV2["pages"][number] => ({ id, title: id, nodes, edges: [], layout: { type: "manual" } });
test("page-level add/add is reported as an $add conflict and manual content is kept", () => {
const b: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A" }])] };
const manual: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A" }]), page("p2", [{ id: "m1", label: "manual-only work" }])] };
const incoming: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A" }]), page("p2", [{ id: "g1", label: "generated" }])] };
const result = syncDiagramIR(b, manual, incoming);
assert.deepEqual(result.conflicts.map((c) => [c.pageId, c.id, c.field]), [["p2", "p2", "$add"]]);
assert.deepEqual(result.ir.pages.find((p) => p.id === "p2")!.nodes.map((n) => n.id), ["m1"]);
assert.deepEqual(syncDiagramIR(b, manual, manual).conflicts, []);
});
test("manual delete versus incoming modify yields a $delete conflict instead of resurrecting the node", () => {
const b: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A" }, { id: "k", label: "K" }])] };
const manual: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "k", label: "K" }])] };
const incoming: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A-renamed" }, { id: "k", label: "K" }])] };
const result = syncDiagramIR(b, manual, incoming);
assert.deepEqual(result.conflicts.map((c) => [c.id, c.field, c.manual, (c.incoming as { label: string }).label]), [["a", "$delete", undefined, "A-renamed"]]);
assert.deepEqual(result.ir.pages[0].nodes.map((n) => n.id), ["k"]);
assert.deepEqual(syncDiagramIR(b, manual, b).conflicts, []);
});
test("object key order is not a change: equality uses canonical serialization", () => {
const b: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A", properties: { x: 1, y: 2 } }])] };
const manual: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A", properties: { y: 2, x: 1 } }])] };
const incoming: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A", properties: { x: 1, y: 3 } }])] };
const result = syncDiagramIR(b, manual, incoming);
assert.deepEqual(result.conflicts, []);
assert.deepEqual(result.ir.pages[0].nodes[0].properties, { x: 1, y: 3 });
});
test("sync action fails and withholds output on conflicts unless --force is given", async () => {
const { run } = await import("../../actions/sync/action.js");
const dir = mkdtempSync(join(tmpdir(), "sync-action-"));
try {
const write = (name: string, ir: DiagramIRV2) => { const p = join(dir, name); writeFileSync(p, JSON.stringify(ir), "utf8"); return p; };
const b = write("base.json", { version: 2, pages: [page("p1", [{ id: "a", label: "A" }])] });
const manual = write("manual.json", { version: 2, pages: [page("p1", [{ id: "a", label: "A manual" }])] });
const incoming = write("incoming.json", { version: 2, pages: [page("p1", [{ id: "a", label: "A incoming" }])] });
const out = join(dir, "out.json");
const blocked = run(manual, 0, out, { base: b, spec: incoming });
assert.equal(blocked.failed, true); assert.equal(blocked.output, undefined); assert.equal(existsSync(out), false); assert.match(String(blocked.summary), /conflict/);
const forced = run(manual, 0, out, { base: b, spec: incoming, force: true });
assert.equal(forced.failed, false); assert.equal(existsSync(out), true); assert.equal((forced.conflicts as unknown[]).length, 1);
const clean = run(manual, 0, out, { base: b, spec: manual });
assert.equal(clean.failed, false); assert.equal(clean.output, out);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
@@ -4,7 +4,13 @@ import { validateDiagramIR } from "../../model/diagram-ir.js";
export interface SyncConflict { pageId: string; id: string; field: string; base: unknown; manual: unknown; incoming: unknown }
export interface SyncResult { ir: DiagramIRV2; added: string[]; removed: string[]; conflicts: SyncConflict[] }
const equal = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
/** Canonical serialization with sorted object keys so key order alone never counts as a change. */
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).filter((key) => record[key] !== undefined).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; }
return String(JSON.stringify(value));
}
const equal = (a: unknown, b: unknown): boolean => canonical(a) === canonical(b);
function conflictValue<T extends DiagramNode | DiagramEdge>(pageId: string, id: string, field: keyof T, base: T, manual: T, incoming: T, conflicts: SyncConflict[]): unknown {
const b = base[field]; const m = manual[field]; const n = incoming[field];
@@ -32,7 +38,11 @@ function syncPage(base: DiagramPage, manual: DiagramPage, incoming: DiagramPage,
added.push(incomingNode.id);
continue;
}
if (!manualNode) { nodes.push(structuredClone(incomingNode)); continue; }
if (!manualNode) {
// Manual deleted a node that incoming changed since base: a delete/modify conflict, not a silent resurrection.
if (!equal(incomingNode, baseNode)) { conflicts.push({ pageId: base.id, id: incomingNode.id, field: "$delete", base: structuredClone(baseNode), manual: undefined, incoming: structuredClone(incomingNode) }); continue; }
nodes.push(structuredClone(incomingNode)); continue;
}
const node = structuredClone(incomingNode);
for (const field of ["label", "kind", "parentId", "properties", "provenance", "extensions"] as const) {
const value = conflictValue<DiagramNode>(base.id, node.id, field, baseNode, manualNode, incomingNode, conflicts);
@@ -66,7 +76,10 @@ function syncPage(base: DiagramPage, manual: DiagramPage, incoming: DiagramPage,
added.push(incomingEdge.id);
continue;
}
if (!manualEdge) { edges.push(structuredClone(incomingEdge)); continue; }
if (!manualEdge) {
if (!equal(incomingEdge, baseEdge)) { conflicts.push({ pageId: base.id, id: incomingEdge.id, field: "$delete", base: structuredClone(baseEdge), manual: undefined, incoming: structuredClone(incomingEdge) }); continue; }
edges.push(structuredClone(incomingEdge)); continue;
}
const edge = structuredClone(incomingEdge);
for (const field of ["source", "target", "label", "kind", "properties", "provenance", "extensions"] as const) {
const value = conflictValue<DiagramEdge>(base.id, edge.id, field, baseEdge, manualEdge, incomingEdge, conflicts);
@@ -91,7 +104,14 @@ export function syncDiagramIR(base: DiagramIRV2, manual: DiagramIRV2, incoming:
const basePages = new Map(base.pages.map((page) => [page.id, page])); const manualPages = new Map(manual.pages.map((page) => [page.id, page]));
const pages = incoming.pages.map((page) => {
const basePage = basePages.get(page.id); const manualPage = manualPages.get(page.id);
if (!basePage || !manualPage) { added.push(page.id); return structuredClone(page); }
if (!manualPage) { added.push(page.id); return structuredClone(page); }
if (!basePage) {
// Page add/add: both sides introduced the same page ID independently; keep manual and report it like node-level `$add`.
added.push(page.id);
if (equal(manualPage, page)) return structuredClone(page);
conflicts.push({ pageId: page.id, id: page.id, field: "$add", base: undefined, manual: structuredClone(manualPage), incoming: structuredClone(page) });
return structuredClone(manualPage);
}
return syncPage(basePage, manualPage, page, options.prune === true, conflicts, added, removed);
});
const incomingPageIds = new Set(incoming.pages.map((page) => page.id));
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -383,3 +384,22 @@ test("loads YAML data without permitting custom tags", async () => {
assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["Service/default/api"]);
await assert.rejects(() => importSource({ sourceKind: "ci", path: "tagged.yaml", input: { type: "file", root } }), /tag|malformed YAML/i);
});
test("rejects a FIFO inside a source directory promptly instead of blocking on open()", async () => {
const root = await mkdtemp(join(tmpdir(), "source-fifo-"));
await writeFile(join(root, "a.py"), "import os\n");
const made = spawnSync("mkfifo", [join(root, "pipe")]);
if (made.status !== 0) return; // platform without mkfifo: nothing to regress
const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error("importSource hung on the FIFO")), 5_000).unref());
await assert.rejects(() => Promise.race([importSource({ sourceKind: "python", path: ".", input: { type: "file", root } }), timeout]), /unsupported entry type/);
await assert.rejects(() => Promise.race([importSource({ sourceKind: "python", path: "pipe", input: { type: "file", root } }), timeout]), /unsupported entry type/);
});
test("Rust macro detection is linear on long lines and still flags macro invocations", async () => {
const started = performance.now();
const long = await textInput("rust", "long.rs", `fn main() {}\n${"a".repeat(200_000)}\n`);
assert.ok(performance.now() - started < 200, `took ${(performance.now() - started).toFixed(0)}ms`);
assert.deepEqual(long.diagnostics.filter((d) => /macros/.test(d.message)), []);
const macros = await textInput("rust", "macros.rs", "println!(\"hi\");\nfoo! (1);\nlet x = a != b;\n! (not);\n");
assert.deepEqual(macros.diagnostics.filter((d) => /macros/.test(d.message)).map((d) => d.line), [1, 2]);
});
+19 -3
View File
@@ -1,5 +1,5 @@
import { constants } from "node:fs";
import { open, readdir, realpath, stat } from "node:fs/promises";
import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { isAbsolute, relative, resolve } from "node:path";
@@ -610,7 +610,7 @@ async function importInline(request: SourceImportRequest): Promise<SourceImportR
? nonJavaScriptScan.text.split(/\r?\n/).flatMap((line, index) => {
const mod = line.match(/^\s*mod\s+([A-Za-z_]\w*)\s*;/)?.[1];
const use = line.match(/^\s*use\s+([^;]+);/)?.[1];
if (/\w+!\s*\(/.test(line)) diagnostics.push({ code: "unknown-construct", severity: "warning", message: "Rust macros are not expanded", path: request.path, line: index + 1 });
if (hasRustMacroCall(line)) diagnostics.push({ code: "unknown-construct", severity: "warning", message: "Rust macros are not expanded", path: request.path, line: index + 1 });
if (mod) return [{ name: mod, line: index + 1 }];
if (use) {
const root = use.includes("::{") ? use.slice(0, use.indexOf("::{")) : use.split("::").slice(0, -1).join("::") || use;
@@ -629,15 +629,31 @@ async function importInline(request: SourceImportRequest): Promise<SourceImportR
return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } };
}
/** Linear-time equivalent of `/\w+!\s*\(/` (the regex backtracks quadratically on long identifier runs). */
function hasRustMacroCall(line: string): boolean {
for (let bang = line.indexOf("!"); bang >= 0; bang = line.indexOf("!", bang + 1)) {
if (bang === 0 || !/\w/.test(line[bang - 1])) continue;
let next = bang + 1;
while (next < line.length && /\s/.test(line[next])) next++;
if (line[next] === "(") return true;
}
return false;
}
function isConfined(root: string, candidate: string): boolean {
const pathFromRoot = relative(root, candidate);
return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot));
}
async function openConfined(candidate: string, root: string): Promise<{ handle: FileHandle; canonical: string }> {
// Inspect before open(): opening a FIFO (or a device) blocks indefinitely, so only regular files and
// directories may reach open(); O_NONBLOCK additionally keeps a FIFO swapped in after lstat from hanging.
const kind = await lstat(candidate);
if (kind.isSymbolicLink()) throw new Error("Source symlink escape risk cannot be opened safely");
if (!kind.isFile() && !kind.isDirectory()) throw new Error("Source directory contains an unsupported entry type");
let handle: FileHandle;
try {
handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW);
handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("Source symlink escape risk cannot be opened safely");
throw error;
@@ -132,3 +132,18 @@ test("theme validation rejects invalid colors and insufficient contrast", () =>
lowContrast.node.fillColor = "#777777";
assert.throws(() => validateTheme(lowContrast), /contrast/i);
});
test("themed edges carry the canvas colour as label background exactly once so labels do not render as boxes", () => {
const source = structuredClone(IR);
source.pages[0].edges[0].style = "edgeStyle=orthogonalEdgeStyle;labelBackgroundColor=#FFFFFF;labelBackgroundColor=#EEEEEE;";
for (const name of ["dark", "corporate"] as const) {
const themed = applyTheme(source, name);
const edgeStyle = themed.pages[0].edges[0].style!;
const tokens = edgeStyle.split(";").filter((token) => token.startsWith("labelBackgroundColor="));
assert.deepEqual(tokens, [`labelBackgroundColor=${BUILT_IN_THEMES[name].backgroundColor}`], edgeStyle);
for (const node of themed.pages[0].nodes) assert.doesNotMatch(node.style!, /labelBackgroundColor/);
}
const untouched = applyTheme(IR, "dark").pages[0].edges[0].style!;
assert.equal(untouched.split(";").filter((token) => token.startsWith("labelBackgroundColor=")).length, 1, untouched);
assert.match(untouched, /labelBackgroundColor=#111827;/);
});
+6 -4
View File
@@ -81,11 +81,13 @@ function parseStyle(style = ""): StyleEntry[] {
});
}
function themedStyle(style: string | undefined, colors: Partial<ThemeRoleStyle>, sketch: boolean, backgroundColor?: string): string {
function themedStyle(style: string | undefined, colors: Partial<ThemeRoleStyle>, sketch: boolean, backgroundColor?: string, labelBackgroundColor?: string): string {
const entries = parseStyle(style);
const originalFillNone = entries.some(({ key, value }) => key === "fillColor" && value?.toLowerCase() === "none");
const visual = new Map<string, string>();
if (colors.fillColor) visual.set("fillColor", originalFillNone ? "none" : colors.fillColor);
// Edge labels are drawn on the canvas, so their backdrop must follow the canvas colour or they render as boxes.
if (labelBackgroundColor) visual.set("labelBackgroundColor", labelBackgroundColor);
if (colors.strokeColor) visual.set("strokeColor", colors.strokeColor);
if (colors.fontColor) {
let fontColor = colors.fontColor;
@@ -95,7 +97,7 @@ function themedStyle(style: string | undefined, colors: Partial<ThemeRoleStyle>,
visual.set("fontColor", fontColor);
}
visual.set("sketch", sketch ? "1" : "0");
const visualKeys = new Set(["fillColor", "strokeColor", "fontColor", "sketch"]);
const visualKeys = new Set(["fillColor", "strokeColor", "fontColor", "labelBackgroundColor", "sketch"]);
const handled = new Set<string>();
const output = entries.flatMap((entry) => {
if (!visualKeys.has(entry.key)) return [entry];
@@ -106,7 +108,7 @@ function themedStyle(style: string | undefined, colors: Partial<ThemeRoleStyle>,
visual.delete(entry.key);
return [{ key: entry.key, value: replacement }];
});
for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) {
for (const key of ["fillColor", "strokeColor", "fontColor", "labelBackgroundColor", "sketch"]) {
const value = visual.get(key);
if (value !== undefined) output.push({ key, value });
}
@@ -122,7 +124,7 @@ export function applyTheme(ir: DiagramIRV2, selected: BuiltInThemeName | ThemeDe
...node,
style: themedStyle(node.style, node.kind === "container" ? selectedTheme.container : selectedTheme.node, selectedTheme.sketch === true, selectedTheme.backgroundColor),
});
const applyEdge = (edge: DiagramEdge): DiagramEdge => ({ ...edge, style: themedStyle(edge.style, selectedTheme.edge, selectedTheme.sketch === true) });
const applyEdge = (edge: DiagramEdge): DiagramEdge => ({ ...edge, style: themedStyle(edge.style, selectedTheme.edge, selectedTheme.sketch === true, undefined, selectedTheme.backgroundColor) });
return {
...isolated,
theme: selectedTheme.name,