Author SHA1 Message Date
oleg-lukasonokandClaude Code 6584df0666 Fix review findings: importer DoS, sync data loss, views/profile rendering, docs
Source importers: lstat before open so FIFOs no longer hang the directory
walk; replace the quadratic Rust macro regex with a linear scan.
Three-way sync: report page add/add and delete-vs-modify as conflicts
instead of silently overwriting or resurrecting; canonical comparison so
key order is not a change; the sync action withholds output and fails on
conflicts unless --force. Story publishing escapes <, >, & and U+2028/9
inside the embedded JSON.
Views: drop parentId of unselected containers, re-layout instead of
manual geometry, validate before serialising; flatten() merges identical
nodes repeated across pages so C4 output works with every analysis
action. Dark theme edge labels get a background; tube-map corridors sit
above the stations; C4 containers keep the swimlane style and orphan
relationships land on the matching page; router keeps container header
bands as obstacles; sequence self-messages loop on one side.
Docs: SKILL.md lists all 23 CLI actions and how each capability family
is invoked, task wrappers for query/test/what-if/doctor, capability
tables and --page scope corrected, maintenance snippet uses the real
synchronous action signature, duplicated rule bullets moved to the rule
references, coverage matrix wording made verifiable.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-06 17:01:50 +03:00
oleg-lukasonokandGitHub Enterprise 7610235781 Merge pull request #2 from CTOTools-skills-code-agent/1.0.0.0/IIAA-XYZ-skill-metadata-and-macos-portability-001
Fix skill origin metadata and macOS source-importer portability
2026-09-06 12:58:46 +03:00
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 ```bash
cd scripts cd scripts
pnpm install pnpm install --frozen-lockfile
``` ```
## Build ## Build
+41 -92
View File
@@ -1,6 +1,6 @@
--- ---
name: diagrams-drawio 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 license: Proprietary
metadata: metadata:
author: workspace-swiss-knife author: workspace-swiss-knife
@@ -14,15 +14,15 @@ compatibility: Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, an
# Draw.io Diagram Skill # 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 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 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** — 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 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 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 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 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 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): **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 - [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/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/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/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 ## 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 ```bash
cd <skill>/scripts 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 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-connectors-validation, page-labels-validation, page-shape-bbox-validation,
# page-orphans, page-recommendations, page-hierarchy-full, # page-orphans, page-recommendations, page-hierarchy-full,
# page-negative-space-summary, quality, validate # 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) # Validate a .drawio file (mandatory final gate)
task validate -- --file="/path/to/diagram.drawio" 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 # Import an editable Draw.io file to loss-aware semantic IR
task import -- --file="/path/to/diagram.drawio" --output="/path/to/model.yaml" 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" 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 # 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 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" 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/ # Build TypeScript to dist/
task build task build
``` ```
See [references/capabilities.md](./references/capabilities.md) for what every action outputs. 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 ## 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 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 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 4. **Run the mandatory validation actions** against the generated file
@@ -236,8 +250,9 @@ Every diagram must have this structure:
## XML reference ## 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: 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]
https://raw.githubusercontent.com/jgraph/drawio-mcp/main/shared/xml-reference.md
[^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 ## 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` - Always use unique `id` values for each `mxCell`
## Additional points ## Layout and style non-negotiables
- Always use a __10pt grid__. 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:
- 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.
### Same-level shape spacing and parent size rules - **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°.
**Rule: Same-level siblings must be placed as close as possible while maintaining grid-aligned gaps.** - **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.
Shapes at the same hierarchy level (siblings inside the same parent, or all top-level shapes) must: - **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`.
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
--- ---
+7 -11
View File
@@ -1,6 +1,6 @@
# Agents365 capability coverage # 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 ## 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. | | `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. | | `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. | | `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. | | `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.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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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 ## 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. 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 # 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 ## 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 ```bash
node dist/cli/commands.js --action build --file architecture.yaml --output architecture.drawio 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 ## 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 [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 ## Capability 3 — Diagram analysis (drawio-tools CLI)
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)
A **TypeScript / Node.js** CLI tool for programmatic analysis of `.drawio` files. 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=…`. 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 # 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. Always prints YAML to stdout. Exit code `0` on success, `1` on error.
### Actions ### 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` | | `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 | | 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 | | `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 | | `doctor` | Local environment | Report optional backend availability without launching processes or requiring a model file |
#### Inventory See [semantic-lifecycle.md](./semantic-lifecycle.md) for command contracts, safety behavior, built-in policy identifiers, and examples.
| 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).
--- ---
## 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; ```ts
- `themes/` and `shape-catalog/` — five validated themes and an offline generic shape catalog; import { importSource } from "<skill>/scripts/dist/services/source-importers/index.js";
- `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.
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 ```bash
cd <skill>/scripts cd <skill>/scripts
pnpm install pnpm install --frozen-lockfile
task build # or: pnpm run build task build # or: pnpm run build
``` ```
@@ -99,7 +99,11 @@ src/
│ │ └── page-summary.ts # buildPageSummary() — shared per-page serialisation helper │ │ └── page-summary.ts # buildPageSummary() — shared per-page serialisation helper
│ ├── hierarchy-builder/ │ ├── hierarchy-builder/
│ │ └── hierarchy-builder.ts # buildHierarchy() — shared BFS depth map + containment tree │ │ └── 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/ └── actions/
├── build|import|edit|views|query|test|what-if|sync|story|doctor/ ├── build|import|edit|views|query|test|what-if|sync|story|doctor/
│ # authoring and semantic lifecycle actions │ # authoring and semantic lifecycle actions
@@ -113,6 +117,8 @@ src/
├── page-orphans/ # isolated shapes + dangling connectors ├── page-orphans/ # isolated shapes + dangling connectors
├── page-recommendations/ # page size recommendation ├── page-recommendations/ # page size recommendation
├── page-hierarchy-full/ # nesting levels with full shape geometry (x, y, width, height) ├── 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 └── 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 ## 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 ```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`: 2. Register it in `src/cli/commands.ts` under `ACTIONS`:
```ts ```ts
"my-action": () => import("../actions/my-action/action.js"), "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 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 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 ### Naming convention
Actions follow `{object}-{action}` naming: Actions follow `{object}-{action}` naming:
- `page-*` — operates on a single diagram page (uses `--page`, default 0) - `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` — operates on all pages - `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`, 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) - `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 - **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 ✓ - Example: `startSize=40`, body=80 → total=120 ✓; body=160 → total=200 ✓
- Use ≤ 3 primary color families; create hierarchy with shades - 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 - 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 ## 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. - **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. - 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. - 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. - 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. - **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 --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 ## Linked audience views
+5 -3
View File
@@ -12,11 +12,13 @@ tasks:
run: run:
desc: | desc: |
Run any drawio-tools action against a .drawio file. 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>] Usage: task cli:run -- --file="/path/to/diagram.drawio" --action=<action> [--page <index>] [--output <path>]
Actions: summary, page-summary, page-hierarchy, page-connectors-summary, page-connectors-validation, 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-labels-validation, page-shape-bbox-validation, page-orphans, page-recommendations,
page-hierarchy-full, page-negative-space-summary, quality, validate page-hierarchy-full, page-negative-space-summary, quality, validate
Lifecycle: import, edit, views, query, test, what-if, sync, story, doctor
cmds: cmds:
- | - |
./.scripts/cli/api/run.sh {{ .CLI_ARGS }} ./.scripts/cli/api/run.sh {{ .CLI_ARGS }}
+45 -3
View File
@@ -20,8 +20,8 @@ tasks:
run: run:
desc: | desc: |
Run any drawio-tools action against a .drawio file. 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>] Usage: task run -- --file="/path/to/diagram.drawio" --action=<action> [--page <index>] [--output <path>]
cmds: cmds:
- task: cli:run - task: cli:run
vars: vars:
@@ -62,14 +62,56 @@ tasks:
CLI_ARGS: "--action=views {{ .CLI_ARGS }}" CLI_ARGS: "--action=views {{ .CLI_ARGS }}"
silent: true 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: 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: cmds:
- task: cli:run - task: cli:run
vars: vars:
CLI_ARGS: "--action=sync {{ .CLI_ARGS }}" CLI_ARGS: "--action=sync {{ .CLI_ARGS }}"
silent: true 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: story:
desc: Publish a self-contained accessible offline architecture story. desc: Publish a self-contained accessible offline architecture story.
cmds: cmds:
+7 -3
View File
@@ -1,10 +1,14 @@
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js"; import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { assertOutputSafe, atomicWrite, loadIR, structuredText, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js"; import { assertOutputSafe, atomicWrite, loadIR, structuredText, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
import { syncDiagramIR } from "../../services/semantic-lifecycle/sync.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 (!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]); if (outputPath) assertOutputSafe(outputPath, [filePath, options.base, options.spec]);
const result = syncDiagramIR(loadIR(options.base), loadIR(filePath), loadIR(options.spec), { prune: options.prune }); 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]); // Unresolved conflicts fail the action (exit code 1) and block writing unless --force accepts the manual-preferred merge.
return { action: "sync", output, dryRun: options.dryRun === true, added: result.added, removed: result.removed, conflicts: result.conflicts, preview: options.dryRun ? result.ir : undefined }; 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 { 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 { projectLinkedViews, type ViewName } from "../../services/semantic-lifecycle/analysis.js";
import { atomicWrite, loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.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> { 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)) ?? []; const unknown = names?.filter((name) => !allowed.has(name)) ?? [];
if (unknown.length) throw new Error(`Unknown linked view: ${unknown.join(", ")}`); if (unknown.length) throw new Error(`Unknown linked view: ${unknown.join(", ")}`);
const views = projectLinkedViews(source, names); 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]); const output = atomicWrite(outputPath, diagramIRToDrawio(ir), [filePath]);
return { action: "views", output, views: views.map(({ id, fallback, fallbackReason, hint }) => ({ id, fallback, fallbackReason, hint })) }; 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 = { 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>> = { const ACTIONS: Record<string, () => Promise<ActionModule>> = {
@@ -63,7 +63,7 @@ const ACTIONS: Record<string, () => Promise<ActionModule>> = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function main(): Promise<void> { 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 { try {
({ values } = parseArgs({ ({ values } = parseArgs({
args: process.argv.slice(2), args: process.argv.slice(2),
@@ -76,6 +76,7 @@ async function main(): Promise<void> {
base: { type: "string" }, base: { type: "string" },
strict: { type: "boolean" }, strict: { type: "boolean" },
prune: { type: "boolean" }, prune: { type: "boolean" },
force: { type: "boolean" },
"dry-run": { type: "boolean" }, "dry-run": { type: "boolean" },
fail: { type: "string" }, fail: { type: "string" },
views: { type: "string" }, views: { type: "string" },
@@ -100,6 +101,7 @@ Lifecycle options:
--base <path> Previous generated model for three-way sync --base <path> Previous generated model for three-way sync
--strict Treat policy warnings as failures --strict Treat policy warnings as failures
--prune Remove identities absent from incoming sync model --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 --dry-run Return edit/sync preview without writing
--fail <node-id> Failed node for what-if or story overlay --fail <node-id> Failed node for what-if or story overlay
--views <csv> executive,system,deployment,dataflow,security --views <csv> executive,system,deployment,dataflow,security
@@ -149,7 +151,7 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
try { try {
const mod = await ACTIONS[actionName](); const mod = await ACTIONS[actionName]();
const result = mod.run(filePath, pageIndex, values.output, { 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, dryRun: values["dry-run"], fail: values.fail, views: values.views, kind: values.kind,
property: values.property, from: values.from, to: values.to, property: values.property, from: values.from, to: values.to,
}); });
@@ -1,6 +1,11 @@
import assert from "node:assert/strict"; 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 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 type { DiagramPage, DiagramPoint, DiagramGeometry } from "../../model/diagram-ir.js";
import { routePageEdges } from "./orthogonal-router.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); 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 { interface Obstacle extends DiagramGeometry {
id: string; id: string;
@@ -102,6 +102,15 @@ function absoluteGeometryById(page: DiagramPage): Map<string, DiagramGeometry> {
return result; 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> { function ancestorIds(page: DiagramPage, id: string): Set<string> {
const nodes = new Map(page.nodes.map((node) => [node.id, node])); const nodes = new Map(page.nodes.map((node) => [node.id, node]));
const ancestors = new Set<string>(); const ancestors = new Set<string>();
@@ -134,9 +143,16 @@ function routeEdge(edge: DiagramEdge, page: DiagramPage, geometries: Map<string,
} }
const endpoints = endpointPair(source, target); const endpoints = endpointPair(source, target);
const endpointAncestors = new Set([...ancestorIds(page, edge.source), ...ancestorIds(page, edge.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 const obstacles: Obstacle[] = page.nodes
.filter((node) => node.id !== edge.source && node.id !== edge.target && !endpointAncestors.has(node.id) && geometries.has(node.id)) .filter((node) => node.id !== edge.source && node.id !== edge.target && geometries.has(node.id))
.map((node) => ({ id: node.id, ...geometries.get(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 sourceStub = outwardStub(endpoints.source, source, padding);
const targetStub = outwardStub(endpoints.target, target, padding); const targetStub = outwardStub(endpoints.target, target, padding);
const middleX = snap((sourceStub.x + targetStub.x) / 2); const middleX = snap((sourceStub.x + targetStub.x) / 2);
+27 -1
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { validateDiagramIR } from "../../model/diagram-ir.js"; import { validateDiagramIR } from "../../model/diagram-ir.js";
import { projectC4 } from "./c4.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"], api: ["c4-containers-shop", "c4-components-api"],
orders: ["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); 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`); }), `${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) ? pageFor("c4-containers", element.id)
: element.type === "container" && model.elements.some((item) => item.type === "component" && item.parentId === element.id) : element.type === "container" && model.elements.some((item) => item.type === "component" && item.parentId === element.id)
? pageFor("c4-components", element.id) : undefined; ? pageFor("c4-components", element.id) : undefined;
const isContainer = model.elements.some((child) => child.parentId === element.id && included.has(child.id));
return { return {
id: element.id, id: element.id,
label: element.label, 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 } : {}), ...(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 } : {}) }, properties: { ...cloneRecord(element.properties), c4Type: element.type, ...(drillDownPage ? { drillDownPage } : {}) },
provenance: cloneRecord(element.provenance), 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] }); 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) { for (const relationship of model.relationships) {
if (pageSpecs.some((page) => page.elements.some((element) => element.id === relationship.source) if (pageSpecs.some((page) => page.elements.some((element) => element.id === relationship.source)
&& page.elements.some((element) => element.id === relationship.target))) continue; && 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) => const candidates = pageSpecs.filter((page) => page.elements.some((element) =>
element.id === relationship.source || element.id === relationship.target)); 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)); const included = new Set(page.elements.map((element) => element.id));
for (const endpoint of [relationship.source, relationship.target]) { for (const endpoint of [relationship.source, relationship.target]) {
if (!included.has(endpoint)) { 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.equal(waypointRight, 530);
assert.ok(ir.pages[0].width! >= waypointRight + 40); 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 fromX = xById.get(message.from)! + 60;
const toX = xById.get(message.to)! + 60; const toX = xById.get(message.to)! + 60;
const direction = Math.sign(toX - fromX) || 1; 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 clearX = (id: string, centerX: number, side: number) => centerX + side * (10 + maxDepthById.get(id)! * 10);
const sourceX = clearX(message.from, fromX, direction); 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 [ 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}-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]; const nodes = [...participantNodes, ...lifelineNodes, ...activationNodes, ...anchorNodes];
@@ -82,7 +86,7 @@ export function createSequenceDiagram(input: SequenceInput): DiagramIRV2 {
const direction = Math.sign(toX - fromX) || 1; const direction = Math.sign(toX - fromX) || 1;
const sourceX = fromX + direction * (10 + maxDepthById.get(message.from)! * 10); const sourceX = fromX + direction * (10 + maxDepthById.get(message.from)! * 10);
const targetX = toX - direction * (10 + maxDepthById.get(message.to)! * 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) }; 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)); 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 }); 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()); memberships.forEach((lines) => lines.sort());
const firstLine = new Map<string, number>(); const firstLine = new Map<string, number>();
lines.forEach((line, lineIndex) => line.stations.forEach((id) => { if (!firstLine.has(id)) firstLine.set(id, lineIndex); })); 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 centers = new Map<string, DiagramPoint>();
const nodes = stations.map((station, index) => { 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)!; 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) }; 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) }; 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; 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); 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", () => { test("semantic operations fail closed on ambiguous page-local IDs", () => {
const ambiguous: DiagramIRV2 = { const ambiguous: DiagramIRV2 = {
version: 2, 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 PolicyFinding { rule: string; severity: "error" | "warning"; subject: string; message: string; hint: string }
export interface PolicyReport { errors: number; warnings: number; findings: PolicyFinding[] } 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[] } { function flatten(ir: DiagramIRV2): { nodes: DiagramNode[]; edges: DiagramEdge[] } {
validateDiagramIR(ir); validateDiagramIR(ir);
const pagesByNodeId = new Map<string, string[]>(); 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 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) { for (const edge of page.edges) {
if (pageIds.length > 1) throw new Error(`Ambiguous semantic node ID ${id} appears on pages ${pageIds.join(", ")}`); 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[] } { 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[] { 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); 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 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 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 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>`; 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 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 test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js"; import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { syncDiagramIR } from "./sync.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.equal(pruned.ir.pages.some((page) => page.id === "legacy"), false);
assert.ok(pruned.removed.includes("legacy")); 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 SyncConflict { pageId: string; id: string; field: string; base: unknown; manual: unknown; incoming: unknown }
export interface SyncResult { ir: DiagramIRV2; added: string[]; removed: string[]; conflicts: SyncConflict[] } 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 { 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]; 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); added.push(incomingNode.id);
continue; 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); const node = structuredClone(incomingNode);
for (const field of ["label", "kind", "parentId", "properties", "provenance", "extensions"] as const) { 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); 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); added.push(incomingEdge.id);
continue; 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); const edge = structuredClone(incomingEdge);
for (const field of ["source", "target", "label", "kind", "properties", "provenance", "extensions"] as const) { 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); 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 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 pages = incoming.pages.map((page) => {
const basePage = basePages.get(page.id); const manualPage = manualPages.get(page.id); 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); return syncPage(basePage, manualPage, page, options.prune === true, conflicts, added, removed);
}); });
const incomingPageIds = new Set(incoming.pages.map((page) => page.id)); const incomingPageIds = new Set(incoming.pages.map((page) => page.id));
@@ -1,4 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises"; import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; 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"]); 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); 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 { 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 type { FileHandle } from "node:fs/promises";
import { isAbsolute, relative, resolve } from "node:path"; 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) => { ? nonJavaScriptScan.text.split(/\r?\n/).flatMap((line, index) => {
const mod = line.match(/^\s*mod\s+([A-Za-z_]\w*)\s*;/)?.[1]; const mod = line.match(/^\s*mod\s+([A-Za-z_]\w*)\s*;/)?.[1];
const use = line.match(/^\s*use\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 (mod) return [{ name: mod, line: index + 1 }];
if (use) { if (use) {
const root = use.includes("::{") ? use.slice(0, use.indexOf("::{")) : use.split("::").slice(0, -1).join("::") || 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 } }; 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 { function isConfined(root: string, candidate: string): boolean {
const pathFromRoot = relative(root, candidate); const pathFromRoot = relative(root, candidate);
return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot)); return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot));
} }
async function openConfined(candidate: string, root: string): Promise<{ handle: FileHandle; canonical: string }> { 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; let handle: FileHandle;
try { 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) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("Source symlink escape risk cannot be opened safely"); if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("Source symlink escape risk cannot be opened safely");
throw error; throw error;
@@ -132,3 +132,18 @@ test("theme validation rejects invalid colors and insufficient contrast", () =>
lowContrast.node.fillColor = "#777777"; lowContrast.node.fillColor = "#777777";
assert.throws(() => validateTheme(lowContrast), /contrast/i); 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 entries = parseStyle(style);
const originalFillNone = entries.some(({ key, value }) => key === "fillColor" && value?.toLowerCase() === "none"); const originalFillNone = entries.some(({ key, value }) => key === "fillColor" && value?.toLowerCase() === "none");
const visual = new Map<string, string>(); const visual = new Map<string, string>();
if (colors.fillColor) visual.set("fillColor", originalFillNone ? "none" : colors.fillColor); 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.strokeColor) visual.set("strokeColor", colors.strokeColor);
if (colors.fontColor) { if (colors.fontColor) {
let fontColor = colors.fontColor; let fontColor = colors.fontColor;
@@ -95,7 +97,7 @@ function themedStyle(style: string | undefined, colors: Partial<ThemeRoleStyle>,
visual.set("fontColor", fontColor); visual.set("fontColor", fontColor);
} }
visual.set("sketch", sketch ? "1" : "0"); 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 handled = new Set<string>();
const output = entries.flatMap((entry) => { const output = entries.flatMap((entry) => {
if (!visualKeys.has(entry.key)) return [entry]; if (!visualKeys.has(entry.key)) return [entry];
@@ -106,7 +108,7 @@ function themedStyle(style: string | undefined, colors: Partial<ThemeRoleStyle>,
visual.delete(entry.key); visual.delete(entry.key);
return [{ key: entry.key, value: replacement }]; 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); const value = visual.get(key);
if (value !== undefined) output.push({ key, value }); if (value !== undefined) output.push({ key, value });
} }
@@ -122,7 +124,7 @@ export function applyTheme(ir: DiagramIRV2, selected: BuiltInThemeName | ThemeDe
...node, ...node,
style: themedStyle(node.style, node.kind === "container" ? selectedTheme.container : selectedTheme.node, selectedTheme.sketch === true, selectedTheme.backgroundColor), 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 { return {
...isolated, ...isolated,
theme: selectedTheme.name, theme: selectedTheme.name,