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>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 })) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 edge of page.edges) {
|
||||||
|
const existing = edges.get(edge.id);
|
||||||
|
if (!existing) { edges.set(edge.id, edge); continue; }
|
||||||
|
if (existing.source !== edge.source || existing.target !== edge.target) throw new Error(`Ambiguous semantic edge ID ${edge.id} connects different endpoints on different pages`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const [id, pageIds] of pagesByNodeId) {
|
return { nodes: [...nodes.values()], edges: [...edges.values()] };
|
||||||
if (pageIds.length > 1) throw new Error(`Ambiguous semantic node ID ${id} appears on pages ${pageIds.join(", ")}`);
|
|
||||||
}
|
|
||||||
return { nodes: ir.pages.flatMap((page) => page.nodes), edges: ir.pages.flatMap((page) => page.edges) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user