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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-06 17:01:50 +03:00
oleg-lukasonokandGitHub Enterprise 7610235781 Merge pull request #2 from CTOTools-skills-code-agent/1.0.0.0/IIAA-XYZ-skill-metadata-and-macos-portability-001
Fix skill origin metadata and macOS source-importer portability
2026-09-06 12:58:46 +03:00
oleg-lukasonokandClaude Code 9b84db4b0a Fix skill origin metadata and macOS source-importer portability
Point SKILL.md origin-repository/origin-path at the renamed
diagrams-drawio repository and workspace path.

openConfined() and the directory walker addressed opened files through
/proc/self/fd, which only exists on Linux, so every file/directory source
import failed on macOS (2 of 150 tests). Keep the procfs path where it is
available and fall back to realpath() plus a dev/inode identity check
against the open handle elsewhere.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-06 12:58:12 +03:00
jarvis-at-skic 9809cf73e9 Merge pull request 'refactor: rename skill to diagrams-drawio' (#3) from refactor/rename-diagrams-drawio into test 2026-09-03 13:11:25 -07:00
jarvis-at-skic 1550609e5b [verified] refactor: rename skill to diagrams-drawio 2026-09-03 20:10:54 +00:00
jarvis-at-skic aec73fdfe8 feat: add integrated diagram lifecycle and capability toolbox (#2)
Reviewed tree 0d81980cafc94b90bd54f88daa27a271d8c69d2e; 150/150 tests passed.
2026-09-03 12:33:25 -07:00
jarvis-at-skic 1db0ab2885 docs: document integrated capability coverage 2026-09-03 19:15:53 +00:00
jarvis-at-skic 8bc406d84e Merge commit 'ced42f02f9a7f5e22c62906405f207f8d8424389' into 1.0.0.0/IIAA-XYZ-agents365-lifecycle-002 2026-09-03 19:13:33 +00:00
jarvis-at-skic 9ae0be3565 Merge commit 'bb378ebd636f4fa3b0b4ce0311db0f0a5a5bf3' into 1.0.0.0/IIAA-XYZ-agents365-lifecycle-002 2026-09-03 19:13:33 +00:00
jarvis-at-skic 2be287aeb9 Merge commit 'fa62a5fc4cdf127b919c90ca7451d24f7e25817c' into 1.0.0.0/IIAA-XYZ-agents365-lifecycle-002
# Conflicts:
#	scripts/src/authoring/ir-to-drawio.ts
2026-09-03 19:13:26 +00:00
jarvis-at-skic ced42f02f9 [verified] feat: add specialized diagram profiles 2026-09-03 19:11:51 +00:00
jarvis-at-skic bb378ebd63 [verified] feat: add safe source importers 2026-09-03 19:11:51 +00:00
jarvis-at-skic db4129168b [verified] feat: add semantic diagram lifecycle 2026-09-03 19:11:50 +00:00
jarvis-at-skic fa62a5fc4c [verified] feat: add diagram toolbox capabilities 2026-09-03 19:11:50 +00:00
jarvis-at-skic 5146b14d43 feat: add deterministic Diagram IR v2 authoring (#1) 2026-09-03 03:30:22 -07:00
77 changed files with 5462 additions and 186 deletions
+41 -4
View File
@@ -1,6 +1,6 @@
# drawio-main
# diagrams-drawio
TypeScript tools for deterministic YAML-to-Draw.io generation, analysis, and verification.
TypeScript tools for deterministic Diagram IR-to-Draw.io generation, loss-aware round trips, transactional editing, semantic analysis, synchronization, offline publishing, and visual verification.
Built with TypeScript using [`@maxgraph/core`](https://github.com/maxGraph/maxGraph) — the official TypeScript successor to mxGraph (the library draw.io is built on) — for accurate XML parsing and absolute coordinate resolution.
@@ -19,7 +19,7 @@ Built with TypeScript using [`@maxgraph/core`](https://github.com/maxGraph/maxGr
```bash
cd scripts
pnpm install
pnpm install --frozen-lockfile
```
## Build
@@ -38,8 +38,12 @@ task run -- --file="diagram.drawio" --action=<action> [--page <index>]
# or directly:
node dist/cli/commands.js --file diagram.drawio --action <action> [--page <index>]
# Build from semantic YAML Diagram IR:
# Build from semantic YAML/JSON Diagram IR:
node dist/cli/commands.js --action build --file architecture.yaml --output architecture.drawio
# Loss-aware import and transactional edit:
node dist/cli/commands.js --action import --file architecture.drawio --output architecture.yaml
node dist/cli/commands.js --action edit --file architecture.yaml --spec ../examples/edit-batch.yaml --output edited.drawio
```
Output is always YAML to stdout.
@@ -62,6 +66,38 @@ See `examples/platform-v2.yaml` and `schemas/diagram-ir-v2.schema.json`.
---
### Semantic lifecycle
| Action | Purpose | Key options |
|---|---|---|
| `import` | Loss-aware Draw.io to Diagram IR v2 | `--output model.yaml|json` |
| `edit` | Atomic stable-ID edit batch | `--spec`, `--output`, `--dry-run` |
| `views` | Linked executive/system/deployment/dataflow/security views | `--views`, `--output` |
| `query` | Kind/property filtering and deterministic shortest paths | `--kind`, `--property`, `--from`, `--to` |
| `test` | Seven built-in architecture policies | `--spec`, `--strict` |
| `what-if` | Failure propagation with isolation boundaries | `--fail` |
| `sync` | Three-way synchronization preserving manual presentation | `--base`, `--spec`, `--output`, `--prune`, `--dry-run` |
| `story` | Self-contained accessible offline HTML | `--output`, optional `--fail` |
| `doctor` | Report optional backend availability without launching it | no `--file` required |
Commands fail closed on ambiguous identities, invalid view/policy names, input/output aliases, and malformed operations. See [`references/semantic-lifecycle.md`](references/semantic-lifecycle.md) for complete contracts and examples.
---
### Native service libraries
The combined package also exposes tested TypeScript service APIs for capabilities that are not registered as CLI actions:
| Family | Implemented native services |
|---|---|
| Safe source importers | Python, JavaScript/TypeScript, Go, Rust, Terraform, Kubernetes, Docker Compose, SQL, OpenAPI, and CI job DAG subsets |
| Toolbox | Five themes, generic offline shape search, Mermaid and Markdown reverse transforms, semantic diff, strict relabeling, and accessible metric heatmaps |
| Specialized profiles | C4, sequence, tube map, executive compression, runbook HTML, timelapse, and dependency-ordered build-up |
These are conservative, bounded service contracts rather than claims of complete language parsers or peer-compatible command-line interfaces. See [`references/agents365-capability-coverage.md`](references/agents365-capability-coverage.md) for the strict 42-tool comparison and remaining gaps.
---
### Inventory
#### `summary`
@@ -166,3 +202,4 @@ Standard sizes: A4 (1169×827), A3 (1654×1169), A2 (2339×1654), A1 (3307×2339
- Bare `<mxGraphModel>` XML files
- `<mxfile><diagram>…</diagram></mxfile>` wrappers (draw.io desktop format, base64+deflate encoded, multi-page supported)
- Loss-aware semantic import preserves unknown wrapper/page/model XML in explicit `$drawio` envelopes for deterministic round trips
+56 -88
View File
@@ -1,23 +1,28 @@
---
name: drawio-main
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.
name: diagrams-drawio
description: Generate, analyse, edit and publish draw.io diagrams. Always use when user asks to create, generate, draw, or design a diagram, flowchart, architecture diagram, ER diagram, sequence diagram, class diagram, network diagram, mockup, wireframe, or UI sketch, or mentions draw.io, drawio, drawoi, .drawio files, or diagram export to PNG/SVG/PDF.
license: Proprietary
metadata:
author: workspace-swiss-knife
version: "2.0"
spec: agentskills.io/specification
origin-repository: git@github.ibm.com:CTOTools-skills-code-agent/drawio-main.git
origin-path: $HOME/projects-ibm/cognitive-architect/workspace-skills-code-agent/drawio-main
origin-repository: git@github.ibm.com:CTOTools-skills-code-agent/diagrams-drawio.git
origin-path: $HOME/projects-skills-code-agent/ws-skills-code-agent/diagrams-drawio
repository: https://gitea.lego-cloud.eu/home-v1-skills-code-agent/diagrams-drawio
compatibility: Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, and other compatible agent environments
---
# Draw.io Diagram Skill
This skill covers three capabilities:
This skill covers seven capability families (same headings and order as [references/capabilities.md](./references/capabilities.md)):
1. **Deterministic YAML generation** — validate v1/v2 semantic Diagram IR and build native multi-page `.drawio` XML with stable IDs, dependency-aware layout, and obstacle-aware routing
2. **Direct XML generation** — create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements
3. **Diagram analysis** — run the `drawio-tools` CLI to analyse an existing `.drawio` file: inventory shapes and connectors, validate layout quality, detect overlaps/orphans, and recommend page sizes
1. **Deterministic YAML generation** — validate v1/v2 semantic Diagram IR and build native multi-page `.drawio` XML with stable IDs, dependency-aware layout, and obstacle-aware routing. Invoked as CLI action `build` / `task generate`
2. **Direct XML generation** — create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements. Agent-authored XML (this file), then CLI action `validate` / `task validate`
3. **Diagram analysis** — analyse an existing `.drawio` file: inventory shapes and connectors, validate layout quality, detect overlaps/orphans, and recommend page sizes. Invoked as CLI actions `summary`, `page-*`, `quality`, `validate` via `task run -- --action=<name>`
4. **Semantic lifecycle** — loss-aware Draw.io import, transactional stable-ID edits, linked views, semantic query/policy/what-if analysis, three-way synchronization, and self-contained offline story publishing. Invoked as CLI actions `import`, `edit`, `views`, `query`, `test`, `what-if`, `sync`, `story`, `doctor` (each has a same-named `task`)
5. **Safe source importers** — bounded native TypeScript subsets for Python, JavaScript/TypeScript, Go, Rust, Terraform, Kubernetes, Docker Compose, SQL, OpenAPI, and CI dependency graphs. Library only: `import { importSource } from 'dist/services/source-importers/index.js'`; not reachable as a CLI action
6. **Toolbox transforms** — themes, offline generic shape search, reverse Mermaid/Markdown, semantic diff, strict relabeling, and accessible heatmaps. Library only: `import ... from 'dist/services/transforms/{reverse,semantic-diff,relabel,heatmap}.js'`, `'dist/services/themes/theme-service.js'`, `'dist/services/shape-catalog/shape-catalog.js'`; not reachable as a CLI action
7. **Specialized profiles** — C4, sequence, tube map, compression, runbook, timelapse, and dependency-ordered build-up services. Library only: `import ... from 'dist/services/profiles/index.js'`; not reachable as a CLI action
**Reference files** (read these when using this skill):
- [references/capabilities.md](./references/capabilities.md) — full list of capabilities and all CLI analysis actions an agent can execute
@@ -27,20 +32,25 @@ This skill covers three capabilities:
- [references/negative-space-diagram.md](./references/negative-space-diagram.md) — rules for generating negative space companion diagrams from `page-negative-space-summary` output
- [references/routing-best-practices.md](./references/routing-best-practices.md) — corridor planning, routing patterns, overlap verification, swimlane routing, validation workflow
- [references/maintenance.md](./references/maintenance.md) — maintaining and rebuilding the skill itself
- [references/semantic-lifecycle.md](./references/semantic-lifecycle.md) — import, edit, views, query, policy, what-if, sync, story, and doctor workflows
- [references/agents365-capability-coverage.md](./references/agents365-capability-coverage.md) — internal comparison matrix against a 42-entry peer-tool feature list (not independently verifiable), including partial and deferred scope
## Available scripts
This skill ships the `drawio-tools` TypeScript CLI under `scripts/`. All commands run via `task` from the `scripts/` directory (one-time setup: `pnpm install` + `task build`):
This skill ships the `drawio-tools` TypeScript CLI under `scripts/`. All commands run via `task` from the `scripts/` directory (one-time setup: `pnpm install --frozen-lockfile` + `task build`):
```bash
cd <skill>/scripts
# Run any analysis action against a .drawio file
# Run any action against a .drawio file or Diagram IR model
task run -- --file="/path/to/diagram.drawio" --action=page-connectors-validation
# Actions: summary, page-summary, page-hierarchy, page-connectors-summary,
# Analysis actions: summary, page-summary, page-hierarchy, page-connectors-summary,
# page-connectors-validation, page-labels-validation, page-shape-bbox-validation,
# page-orphans, page-recommendations, page-hierarchy-full,
# page-negative-space-summary, quality, validate
# Lifecycle actions: import, edit, views, query, test, what-if, sync, story, doctor
# (see references/semantic-lifecycle.md)
# Authoring action: build (task generate)
# Validate a .drawio file (mandatory final gate)
task validate -- --file="/path/to/diagram.drawio"
@@ -48,19 +58,42 @@ task validate -- --file="/path/to/diagram.drawio"
# Build a native .drawio file from a YAML Diagram IR
task generate -- --file="/path/to/spec.yaml" --output="/path/to/diagram.drawio"
# Import an editable Draw.io file to loss-aware semantic IR
task import -- --file="/path/to/diagram.drawio" --output="/path/to/model.yaml"
# Apply one atomic stable-ID edit batch (examples/edit-batch.yaml targets examples/platform-v2.yaml)
task edit -- --file="/path/to/model.yaml" --spec="/path/to/edit-batch.yaml" --output="/path/to/edited.drawio"
# Query, policy-test, and what-if analyse a model
task query -- --file="/path/to/model.yaml" --kind=service
task test -- --file="/path/to/model.yaml" --strict
task what-if -- --file="/path/to/model.yaml" --fail=api
# Three-way sync: --base = previous generated, --file = manually edited, --spec = newly generated
task sync -- --base="/path/to/before.yaml" --file="/path/to/edited.yaml" --spec="/path/to/after.yaml" --output="/path/to/synced.drawio"
# Generate linked audience views or a self-contained offline story
task views -- --file="/path/to/model.yaml" --views="executive,system,security" --output="/path/to/views.drawio"
task story -- --file="/path/to/model.yaml" --output="/path/to/story.html"
# Report optional local backends (no --file needed)
task doctor
# Build TypeScript to dist/
task build
```
See [references/capabilities.md](./references/capabilities.md) for what every action outputs.
Source importers, toolbox transforms, and specialized profiles (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.
---
Generate draw.io diagrams as native `.drawio` files. Optionally export to PNG, SVG, or PDF with the diagram XML embedded (so the exported file remains editable in draw.io).
## How to create a diagram
1. **Prefer YAML Diagram IR for repeatable diagrams** — use v1 for simple single-page diagrams or v2 for multiple pages, semantic kinds, explicit geometry/waypoints, provenance, and deterministic `linear`, `layered`, `tree`, `grid`, or `manual` layout; then run `build`. See `examples/platform-v2.yaml` and `schemas/diagram-ir-v2.schema.json`
1. **Prefer YAML Diagram IR for repeatable diagrams** — use v1 for simple single-page diagrams or v2 for multiple pages, semantic kinds, explicit geometry/waypoints, provenance, and deterministic `linear`, `layered`, `tree`, `grid`, or `manual` layout; then run `task generate` (CLI action `build`). See `examples/platform-v2.yaml` and `schemas/diagram-ir-v2.schema.json`
2. **Generate draw.io XML** in mxGraphModel format for the requested diagram
3. **Write the XML** to a `.drawio` file in the current working directory using the Write tool
4. **Run the mandatory validation actions** against the generated file
@@ -217,8 +250,9 @@ Every diagram must have this structure:
## XML reference
For the complete draw.io XML reference including common styles, edge routing, containers, layers, tags, metadata, dark mode colors, and XML well-formedness rules, fetch and follow the instructions at:
https://raw.githubusercontent.com/jgraph/drawio-mcp/main/shared/xml-reference.md
For the complete draw.io XML reference including common styles, edge routing, containers, layers, tags, metadata, dark mode colors, and XML well-formedness rules, read and follow the local copy at [references/xml-references.md](./references/xml-references.md). No network access is required.[^xml-upstream]
[^xml-upstream]: Latest upstream source of that reference (only consult if the local copy is suspected to be stale): https://raw.githubusercontent.com/jgraph/drawio-mcp/main/shared/xml-reference.md
## Troubleshooting
@@ -237,82 +271,16 @@ https://raw.githubusercontent.com/jgraph/drawio-mcp/main/shared/xml-reference.md
- Always use unique `id` values for each `mxCell`
## Additional points
## Layout and style non-negotiables
- Always use a __10pt grid__.
- Align all elements to the grid; avoid freehand / off-grid placement.
- Use page dimensions that fully fit the diagram, including margins, legends, and connector routing corridors.
- Keep every main shape's `x`, `y`, `width`, and `height` aligned to clean grid increments.
- Prefer element widths and heights divisible by `40`; if a canonical vendor icon has a fixed non-divisible size, wrap it inside a grid-aligned card/container.
- Leave intentional whitespace corridors between columns and rows for connectors.
The full mandatory rule sets live in [references/rules-layout.md](./references/rules-layout.md) (grid, 40pt spacing, parent sizing, layer model, connector routing, legends, swimlanes, sequence diagrams) and [references/rules-style.md](./references/rules-style.md) (dimensions, colors, borders, text fit, single-port and corner rules). Read both before generating XML. The short version:
### Same-level shape spacing and parent size rules
**Rule: Same-level siblings must be placed as close as possible while maintaining grid-aligned gaps.**
Shapes at the same hierarchy level (siblings inside the same parent, or all top-level shapes) must:
1. **Minimise the gap** between each other — pack them tightly, leaving only enough space for connectors to pass through.
2. **Gap must be grid-aligned** — both the `x` (horizontal gap) and `y` (vertical gap) distances between adjacent same-level shapes must be **divisible by 40 pt**.
| Gap type | Minimum recommended | Must be divisible by |
|---|---|---|
| Horizontal gap between siblings (x-axis) | 40 pt | 40 pt |
| Vertical gap between siblings (y-axis) | 40 pt | 40 pt |
> **Example:** If shape A ends at `x=320` and shape B starts at `x=360`, the gap is 40 pt (1 grid unit). If shape A ends at `x=320` and the gap must be wider for a connector corridor, use `x=400` (gap = 80 pt, divisible by 40) — never `x=350` (gap = 30 pt, not on a 40 pt boundary).
**Rule: A parent (container) shape must always be large enough to fully enclose all its children, including inner padding.**
When children grow (new children added, children resized, or children repositioned), the parent container must expand to accommodate them. Apply the following sizing formula:
```
parent.width = max_child_right + right_padding (right_padding ≥ 40, divisible by 40)
parent.height = max_child_bottom + bottom_padding (bottom_padding ≥ 40, divisible by 40)
```
Where:
- `max_child_right = max(child.x + child.width)` over all children (relative to parent)
- `max_child_bottom = max(child.y + child.height)` over all children (relative to parent)
- Left/top inner padding (the space between parent top-left and first child) must also be ≥ 40 pt and divisible by 40 pt.
**Cascade rule:** Parent expansion may cause *its* parent to also need expansion. Walk up the containment tree and resize each ancestor in turn until the outermost container fits all descendants.
**Positioning rule after expansion:** When a parent container grows, re-check sibling spacing at every affected level. All gaps must remain divisible by 40 pt after the resize.
- Avoid routing connectors through shapes, cards, labels, icons, legends, or containers containing important content.
- Prefer orthogonal connectors with explicit waypoints when auto-routing causes overlaps.
- If connector auto-routing crosses shapes, use absolute routed points or fixed waypoints instead of relying on `source` / `target` auto-routing.
- Keep connector labels off the connector path when diagrams are dense.
- If connector labels are needed, place them in a dedicated legend, side note, or clearly empty corridor.
- Do not allow connector labels to overlap shapes, cards, icons, or other text.
- Avoid long text inside narrow shapes; use wider cards or wrap labels in a dedicated text area inside the card.
- Ensure service names fit within the visual card width and do not extend beyond card boundaries.
- For service-card diagrams, prefer a consistent card pattern: icon on the left, service label on the right, enough padding around both.
- Avoid overlapping shapes, including external actors, cards, icons, notes, legends, and containers.
- When using canonical vendor icons, preserve the original icon styling; adjust surrounding card/container layout instead of modifying the icon style.
- For rectangles and containers, do not use rounding unless the style explicitly requires it.
- Avoid borders on shapes unless they are needed for visual separation or canonical styling.
- Use at most __3 primary color families__; create hierarchy with lighter/darker shades instead of adding many unrelated colors.
- Keep connector colors simple and consistent; use dashed lines only for semantically different flows such as admin, private, async, or backup paths.
- Keep arrowheads outside shape interiors; connectors should touch shape/card edges, not pass through the body.
- Validate that all draw.io XML is well-formed and contains required root cells `0` and `1`.
- Ensure all `mxCell` IDs are unique.
- Every edge must include an `mxGeometry` child; use waypoint arrays for routed connectors.
- For complex diagrams, validate connector paths against shape bounding boxes before finalizing.
- Prefer a separate legend for flow explanations when connector labels would clutter the diagram.
- Keep legends outside the main routing area so connectors do not cross legend text.
- Use consistent spacing between diagram lanes/columns and between rows of cards.
- Keep diagram complexity medium by grouping services into logical zones/layers instead of scattering many unrelated services.
- Use swimlanes or containers for major domains, but avoid placing dense connector routes directly through container titles.
- Before completion, re-open or visually inspect the diagram and check specifically for:
- shape overlap
- connector overlap with cards/icons
- connector label overlap
- text overflowing card boundaries
- page clipping
- inconsistent spacing
- missing canonical vendor shapes
- **10pt grid, 40pt rhythm** — every shape aligned to the grid; dimensions and sibling gaps divisible by 40 (gap ≥ 40); parents enclose children with ≥ 40pt padding on all sides and grow (cascading upward) when children grow.
- **Connectors overlap only Layer 0 (containers)** — never shapes, labels, icons, legends, or swimlane headers; use explicit orthogonal waypoints (`edgeStyle=orthogonalEdgeStyle`) when auto-routing would clip; no crossings unless unavoidable, then 90°.
- **One exit point, one entry point per shape, mid-side only** — never a corner; arrowheads stop at the shape edge; first/last waypoint ≥ 20px clear of any shape.
- **≤ 3 color families, `rounded=0`, no decorative borders, dashed lines only for semantically distinct flows**; text must fit inside its shape; keep canonical vendor icons unmodified and wrap them in grid-aligned cards.
- **Labels and legends off the routing path** — connector labels in a clear corridor or a legend placed outside the routing area; group services into logical zones instead of scattering them.
- **Page fits everything** including margins, legends, and corridors; before finishing check overlap, connector/label overlap, text overflow, page clipping, inconsistent spacing, and missing canonical shapes, then run `page-connectors-validation`, `page-shape-bbox-validation`, and `validate`.
---
+30
View File
@@ -0,0 +1,30 @@
pageId: system
preconditions:
- type: exists
id: api
operations:
- type: update
id: api
changes:
label: Public API
properties:
owner: platform
production: true
observability: true
- type: add
node:
id: cache
label: Cache
kind: database
geometry:
x: 520
y: 200
width: 120
height: 80
- type: connect
edge:
id: api-cache
source: api
target: cache
label: Read-through
kind: data
+1 -1
View File
@@ -1,7 +1,7 @@
version: 2
title: Platform views
provenance:
source: drawio-main example
source: diagrams-drawio example
pages:
- id: system
title: System
+8
View File
@@ -0,0 +1,8 @@
rules:
- no-direct-internet-to-database
- no-cycles
- no-orphans
- every-service-has-owner
- production-has-observability
- external-dependencies-have-timeouts
- trust-boundaries-use-protocol
@@ -0,0 +1,73 @@
# Agents365 capability coverage
> 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
- **Implemented** — full mapped peer acceptance contract is available.
- **Partial** — useful native behavior is implemented, but some peer options, adapters, or Command-Line Interface (CLI) exposure remain.
- **Optional deferred** — environment-heavy capability remains outside the mandatory offline core.
- **Rejected** — capability was deliberately excluded from this phase.
These are strict peer-parity labels. A `partial` row can still contain substantial production-ready native functionality.
## Aggregate
| Classification | Count |
|---|---:|
| Implemented | 0 |
| Partial | 29 |
| Optional deferred | 11 |
| Rejected | 2 |
| **Total** | **42** |
## Capability matrix
| Peer tool | Classification | Native evidence | Remaining gap |
|---|---|---|---|
| `aiicons.py` | **optional-deferred** | `scripts/src/services/shape-catalog/shape-catalog.ts`<br>`scripts/src/services/shape-catalog/shape-catalog.test.ts` | Only an eight-entry generic offline shape catalog exists; there is no AI/LLM brand manifest, variant selection, embedding adapter, URL allowlist, or CLI action. |
| `autolayout.py` | **partial** | `scripts/src/services/layout/layout-engine.ts`<br>`scripts/src/services/layout/layout-engine.test.ts`<br>`scripts/src/actions/build/action.ts` | Deterministic native layouts exist, but there is no peer-compatible graph-JSON adapter, group-tree contract, monochrome mode, or objective TB/LR tuning action. |
| `buildup.py` | **partial** | `scripts/src/services/profiles/buildup.ts`<br>`scripts/src/services/profiles/buildup.test.ts` | The service computes deterministic cumulative IR frames, but it does not parse Draw.io through a buildup action, render PNG frames, publish the player HTML, encode GIF, or expose CLI availability semantics. |
| `c4.py` | **partial** | `scripts/src/services/profiles/c4.ts`<br>`scripts/src/services/profiles/c4.test.ts`<br>`scripts/src/services/profiles/index.ts` | The service validates hierarchy and creates linked C4 pages, but no C4 CLI/action or input-file adapter is registered, and canonical peer style/direction options are not fully exposed. |
| `ciimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | The service imports a bounded generic jobs/needs object, but lacks CLI registration, repository workflow discovery, triggers, runners, matrices, reusable workflows, GitLab stages, and inferred stage dependencies. |
| `composeimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | Services, depends_on, and simple named volumes are covered through a service API; links, volumes_from, long-form mounts, network grouping, conventional-file discovery, and CLI exposure remain absent. |
| `compress.py` | **partial** | `scripts/src/services/profiles/compression.ts`<br>`scripts/src/services/profiles/compression.test.ts` | A deterministic BFS clustering service emits summary and full IR pages, but there is no Draw.io-facing action, exact loss-aware detail-file workflow, label-propagation parity, or member-cell drill-down target contract. |
| `dbxicons.py` | **optional-deferred** | `scripts/src/services/shape-catalog/shape-catalog.ts`<br>internal peer feature-list comparison (not independently verifiable) | No licensed Databricks manifest, aliases, variants, pinned-ref embedding, refresh operation, host allowlist, or CLI action was delivered. |
| `diagram_ir.py` | **partial** | `scripts/src/model/diagram-ir.ts`<br>`scripts/src/services/semantic-lifecycle/import-drawio.ts`<br>`scripts/src/services/semantic-lifecycle/analysis.ts`<br>`scripts/src/services/semantic-lifecycle/sync.ts`<br>`scripts/src/services/semantic-lifecycle/publishing.ts` | Versioned IR, loss-aware import, views, query, policies, failure impact, sync, and story publishing exist, but articulation analysis, a unified architecture-review contract, contrast analysis, multilingual labeling, and explicit peer-IR-v1 compatibility are incomplete. |
| `diagramctl.py` | **partial** | `scripts/src/cli/commands.ts`<br>`scripts/src/cli/semantic-lifecycle.test.ts`<br>`scripts/src/actions/doctor/action.ts`<br>`scripts/src/actions/sync/action.ts` | Lifecycle actions are registered in the existing --action CLI, but integrated importer, profile, transform, and reverse-export services are not registered. There is no uniform peer-equivalent result envelope. |
| `diagramctl_mcp.py` | **optional-deferred** | `scripts/src/cli/commands.ts`<br>internal peer feature-list comparison (not independently verifiable) | No optional MCP package, JSON-RPC initialization, tools/list, tools/call bridge, closed schemas, timeout handling, or MCP entrypoint exists. |
| `dockerimports.py` | **optional-deferred** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | The importer registry has Docker Compose but no Docker inspect snapshot kind, container/network/volume instance normalization, redaction contract, stdin parity, or action. |
| `drawio2mermaid.py` | **partial** | `scripts/src/services/transforms/reverse.ts`<br>`scripts/src/services/transforms/reverse.test.ts`<br>`scripts/src/services/semantic-lifecycle/import-drawio.ts` | A deterministic IR-to-Mermaid service exists, but there is no Draw.io-to-Mermaid CLI/action, shape-form mapping, direction/fence controls, or lossy-conversion report. |
| `drawio2pptx.py` | **optional-deferred** | `scripts/src/cli/commands.ts`<br>internal peer feature-list comparison (not independently verifiable) | No optional Draw.io renderer adapter, per-page raster loop, PPTX writer, slide sizing, scale option, or structured unavailable result exists. |
| `drawiodiff.py` | **partial** | `scripts/src/services/transforms/semantic-diff.ts`<br>`scripts/src/services/transforms/semantic-diff.test.ts`<br>`scripts/src/services/semantic-lifecycle/import-drawio.ts` | The IR service classifies added, removed, changed, moved, and rerouted entities, but no CLI/action composes Draw.io import with diffing, no by-label ambiguity mode exists, and no color-coded graph output or summary diagram is emitted. |
| `drawiohtml.py` | **optional-deferred** | `scripts/src/services/semantic-lifecycle/publishing.ts`<br>`scripts/src/actions/story/action.ts` | Story HTML is a different semantic publisher; there is no page-to-SVG export adapter, SVG sanitizer, tabbed viewer, pan/zoom/search UI, drill-down link rewrite, or publish-viewer action. |
| `edgeports.py` | **rejected** | `scripts/src/services/authoring-router/orthogonal-router.ts`<br>`scripts/src/services/authoring-router/orthogonal-router.test.ts`<br>internal peer feature-list comparison (not independently verifiable) | Only routing during authoring exists. The post-import boundary-port editor, pinned-port preservation, dry-run, idempotence, and transactional Draw.io write path were not selected for this post-phase implementation. |
| `encode_drawio_url.py` | **optional-deferred** | `scripts/src/cli/commands.ts`<br>`scripts/package.json` | Compression primitives are available transitively, but there is no byte-compatible URL encoder, viewer/edit modes, size policy, privacy warning, or CLI action. |
| `explain.py` | **partial** | `scripts/src/services/transforms/reverse.ts`<br>`scripts/src/services/transforms/reverse.test.ts` | Structured Markdown for IR pages, nodes, and flows exists as a service, but no Draw.io-facing explain action, tier/type inference, C4 context, unknown-section reporting, or output-file contract is exposed. |
| `goimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | Bounded Go import extraction exists, but it emits imported paths as library nodes rather than resolving only intra-module packages; module discovery, grouping, transitive reduction, and CLI exposure are absent. |
| `heatmap.py` | **partial** | `scripts/src/services/transforms/heatmap.ts`<br>`scripts/src/services/transforms/heatmap.test.ts` | The IR service validates bounded metrics and applies deterministic accessible colors with legend metadata, but it lacks CSV/JSON loading, label matching, geometry scaling, a rendered legend node, Draw.io transactional output, and CLI exposure. |
| `jsimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | A bounded scanner handles static import/export and require while avoiding tested comments and strings, but dynamic string imports, project module resolution, external exclusion, directory grouping, transitive reduction, and CLI exposure remain incomplete. |
| `k8simports.py` | **partial** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | Local object/YAML parsing, namespace-qualified identity, Service selectors, Secret references, and Secret payload redaction are tested. Ingress, ConfigMap, PVC, HPA, list-wrapper normalization, ambiguity diagnostics, icon suppression/grouping, stdin action parity, and CLI registration remain absent. |
| `openapiimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | OpenAPI operations and local component-schema references are imported through a service API, but Swagger 2 definitions, nested schema-to-schema edges, method styling, tag groups, no-schema projection, version diagnostics, and CLI exposure are missing. |
| `prdiff.py` | **optional-deferred** | `scripts/src/services/transforms/semantic-diff.ts`<br>`scripts/src/cli/commands.ts` | Semantic IR diff alone does not provide scoped git revision discovery, A/M/D classification, safe extraction, Draw.io rendering, artifact naming, Markdown reporting, or unavailable-render handling. |
| `pyclasses.py` | **rejected** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | The Python importer extracts imports only. Class declarations, inheritance resolution, multiple inheritance, nested module grouping, ambiguity diagnostics, and transitive reduction were not implemented or registered. |
| `pyimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | Bounded import/from extraction and dynamic-import diagnostics exist, but relative and absolute intra-project resolution, stdlib/third-party exclusion, package grouping, transitive reduction, syntax-error diagnostics, and CLI exposure are absent. |
| `raster2drawio.py` | **partial** | `scripts/src/actions/build/action.ts`<br>`scripts/src/authoring/ir-to-drawio.ts`<br>`scripts/src/services/layout/layout-engine.ts`<br>`scripts/src/model/diagram-ir.ts` | Generic IR build supports explicit geometry, styles, edges, and automatic layout, but there is no raster-extracted graph compatibility schema/action, x/y/w/h shorthand conversion, partial-coordinate policy, confidence/provenance convention, or extraction-warning envelope. |
| `relabel.py` | **partial** | `scripts/src/services/transforms/relabel.ts`<br>`scripts/src/services/transforms/relabel.test.ts` | A strict complete-map IR relabel service preserves non-label structure, but there is no extraction mode, page-name handling, UserObject traversal contract, partial-map/unmatched reporting, Draw.io transactional write, or CLI action. |
| `repair_png.py` | **optional-deferred** | `scripts/src/cli/commands.ts`<br>internal peer feature-list comparison (not independently verifiable) | No PNG chunk validator, signature-specific repair, atomic in-place replacement, idempotence gate, version gate, or optional action exists. |
| `restyle.py` | **partial** | `scripts/src/services/themes/theme-service.ts`<br>`scripts/src/services/themes/theme-service.test.ts` | Five validated built-in themes and immutable IR application exist, but the peer-style palette-slot schema, user preset loader, hue/neutral color remapping, global extras, versioned JSON schema, Draw.io transactional action, and CLI exposure are incomplete. |
| `runbook.py` | **partial** | `scripts/src/services/profiles/runbook.ts`<br>`scripts/src/services/profiles/runbook.test.ts` | The service emits escaped self-contained interactive HTML from an explicit RunbookGraph, but it does not parse Draw.io, infer node types/start nodes/choices, report fallback selection, or expose a publish-runbook action. |
| `rustimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | A bounded subset recognizes mod and simple use roots and diagnoses macros, but crate/self/super resolution, module-file discovery, complete brace expansion, external-crate exclusion, grouping, reduction, and CLI exposure are absent. |
| `seqlayout.py` | **partial** | `scripts/src/services/profiles/sequence.ts`<br>`scripts/src/services/profiles/sequence.test.ts` | Participants, ordered messages, lifelines, activations, return validation, and editable geometry are implemented as a service, but notes are unsupported and no sequence schema file, input action, direction/options contract, or CLI registration exists. |
| `shapesearch.py` | **partial** | `scripts/src/services/shape-catalog/shape-catalog.ts`<br>`scripts/src/services/shape-catalog/shape-catalog.test.ts` | Deterministic exact/alias/fuzzy search exists for eight hand-curated generic shapes, not the licensed 10k+ palette index; compound/tag/Soundex ranking, dimensions, gzip integrity controls, expected ecosystem queries, and CLI output are missing. |
| `sqlerd.py` | **partial** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | A narrow line-oriented subset finds simple tables and REFERENCES edges, but columns/types, PK/FK markers, quoted/schema identifiers, composite keys, schema grouping, crow's-foot styles, unsupported-syntax diagnostics, and CLI exposure are missing. |
| `svgflow.py` | **optional-deferred** | `scripts/src/cli/commands.ts`<br>internal peer feature-list comparison (not independently verifiable) | No optional Draw.io SVG export adapter, SVG parser/sanitizer, connector detection, animation injection, reduced-motion handling, or export-flow-svg action exists. |
| `tfimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | A bounded line-oriented resource/reference subset exists, but modules, multiline/nested HCL handling, comments/string false-positive guarantees, diagnostics for dynamic expressions, cloud styles, grouping, transitive reduction, no-icons mode, and CLI exposure are incomplete. |
| `tfstate.py` | **optional-deferred** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | No Terraform show-JSON snapshot source kind, nested module traversal, count/for_each instance expansion, sensitive-value redaction, state relationship extraction, stdin parity, or optional action exists. |
| `timelapse.py` | **partial** | `scripts/src/services/profiles/timelapse.ts`<br>`scripts/src/services/profiles/timelapse.test.ts` | The service classifies changes across caller-supplied IR snapshots, but it has no scoped git history/archive adapter, deterministic commit sampling, importer allowlist, Draw.io frame rendering, HTML player, resource limits, or CLI action. |
| `tubemap.py` | **partial** | `scripts/src/services/profiles/tube-map.ts`<br>`scripts/src/services/profiles/tube-map.test.ts` | The native service provides deterministic grid-aligned octilinear routes, interchange semantics, validation, and tested obstacle avoidance, but no versioned input schema, build-tubemap action, file adapter, or CLI exposure exists. |
| `validate.py` | **partial** | `scripts/src/actions/validate/action.ts`<br>`scripts/src/actions/quality/action.ts`<br>`scripts/src/actions/page-connectors-validation/action.ts`<br>`scripts/src/actions/page-shape-bbox-validation/action.ts`<br>`scripts/src/actions/page-orphans/action.ts` | Structural compilation and several specialized quality checks exist, but findings remain fragmented rather than one stable code/severity/subject/fix schema; duplicate/reserved ID, parent, off-grid, strict-warning, JSON projection, and comparable readability-score acceptance coverage is incomplete. |
## 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.
+81 -15
View File
@@ -1,12 +1,12 @@
# drawio-main — 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
Build native `.drawio` XML from a validated semantic Diagram IR:
Build native `.drawio` XML from a validated semantic Diagram IR (CLI action `build`, `task generate`):
```bash
node dist/cli/commands.js --action build --file architecture.yaml --output architecture.drawio
@@ -18,10 +18,10 @@ IR v1 remains compatible. IR v2 adds multiple pages, containers and semantic kin
## Capability 2 — Direct XML generation
Create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements.
Create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements. The agent authors the mxGraphModel XML directly and then runs the mandatory `validate` action (`task validate`).
See [SKILL.md](../SKILL.md) for the generation workflow, XML format, page sizes, element shapes, and well-formedness rules.
See [rules-layout.md](./rules-layout.md) for mandatory connector and layout rules.
See [rules-layout.md](./rules-layout.md) and [rules-style.md](./rules-style.md) for mandatory connector, layout, and style rules.
---
@@ -38,7 +38,7 @@ node dist/cli/commands.js --file <path> --action <action> [--page <index>] [--ou
# short flags: -f, -a, -p, -o, -h
```
`--page` selects the diagram tab (0-based, default 0). Ignored by `summary` (processes all pages).
`--page` selects the diagram tab (0-based, default 0). Only the actions marked `--page` below honour it; actions marked "page 0 only" always analyse the first page, and all-page actions ignore it.
Always prints YAML to stdout. Exit code `0` on success, `1` on error.
### Actions
@@ -54,10 +54,10 @@ Always prints YAML to stdout. Exit code `0` on success, `1` on error.
| 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 |
| `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
@@ -65,15 +65,81 @@ Always prints YAML to stdout. Exit code `0` on success, `1` on error.
|---|---|---|
| `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) |
| `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 | Smallest standard page size (A4→A3→A2→A1→custom) that fits content with 80 px margin |
| `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 |
|---|---|---|
| `import` | Bare or wrapped Draw.io | Preserve semantic and unknown XML information in validated IR v2; requires YAML/JSON `--output` |
| `edit` | Diagram IR v2 | Apply one atomic stable-ID batch from `--spec`; supports `--dry-run` and Draw.io/YAML/JSON output |
| `views` | Diagram IR v2 | Project linked `executive`, `system`, `deployment`, `dataflow`, and `security` pages |
| `query` | Diagram IR v2 | Filter by `--kind`/`--property`, or find a deterministic path with `--from` and `--to` |
| `test` | Diagram IR v2 | Execute built-in architecture policy rules; `--strict` treats warnings as failures |
| `what-if` | Diagram IR v2 | Calculate outgoing impact from `--fail`, honoring failure-isolating edges |
| `sync` | Base/manual/incoming IR | Three-way synchronization with explicit `--prune` and `--dry-run` |
| `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 |
See [semantic-lifecycle.md](./semantic-lifecycle.md) for command contracts, safety behavior, built-in policy identifiers, and examples.
---
## Capability 5 — Safe source importers
**Library only** — not reachable as a CLI action. Import from `dist/services/source-importers/index.js` (source `scripts/src/services/source-importers/index.ts`):
```ts
import { importSource } from "<skill>/scripts/dist/services/source-importers/index.js";
```
`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).
+49 -14
View File
@@ -1,4 +1,4 @@
# drawio-main — Maintenance Guide
# diagrams-drawio — Maintenance Guide
This document is for **developers** maintaining or extending the `drawio-tools` CLI.
For agent usage instructions, see [SKILL.md](../SKILL.md).
@@ -36,7 +36,7 @@ The git repository is the source of truth. Install dependencies and build inside
```bash
cd <skill>/scripts
pnpm install
pnpm install --frozen-lockfile
task build # or: pnpm run build
```
@@ -45,7 +45,7 @@ and `$HOME/.claude/skills`, so the repo working copy stays the single source of
```bash
cd <skill-manager>/scripts
task deploy -- --skill-dir="/absolute/path/to/drawio-main"
task deploy -- --skill-dir="/absolute/path/to/diagrams-drawio"
```
Verify the CLI works after installation:
@@ -89,13 +89,24 @@ task build # tsc → dist/
src/
├── cli/
│ └── commands.ts # parseArgs dispatcher → dynamic action imports
├── authoring/
│ └── ir-to-drawio.ts # deterministic IR v1/v2 serializer + preservation envelopes
├── model/
│ └── diagram-ir.ts # canonical types, normalization, runtime validation
├── services/
│ ├── drawio-parser/
│ │ ├── parser.ts # parseAllPages() / parseDiagram() — Shape, Edge, ParsedPage
│ │ └── page-summary.ts # buildPageSummary() — shared per-page serialisation helper
│ └── hierarchy-builder/
│ └── hierarchy-builder.ts # buildHierarchy() — shared BFS depth map + containment tree
│ ├── hierarchy-builder/
│ │ └── hierarchy-builder.ts # buildHierarchy() — shared BFS depth map + containment tree
│ ├── semantic-lifecycle/ # import, edit, views/query/policy/what-if, sync, story, atomic I/O
│ ├── layout/, authoring-router/ # deterministic layout + obstacle-aware routing used by build (ir-to-drawio)
│ ├── connector-router/ # edge path reconstruction used by page-connectors-summary/-validation
│ ├── maxgraph-loader/ # jsdom polyfill + maxGraph state loader (used by validate and negative-space)
│ └── source-importers/, transforms/, themes/, shape-catalog/, profiles/ # library-only services (no CLI action)
└── actions/
├── build|import|edit|views|query|test|what-if|sync|story|doctor/
│ # authoring and semantic lifecycle actions
├── summary/ # all pages inventory
├── page-summary/ # single page inventory (uses --page)
├── page-hierarchy/ # containment tree from parentId
@@ -106,10 +117,12 @@ src/
├── page-orphans/ # isolated shapes + dangling connectors
├── page-recommendations/ # page size recommendation
├── page-hierarchy-full/ # nesting levels with full shape geometry (x, y, width, height)
├── page-negative-space-summary/ # free horizontal corridors per nesting level (bbox + text-aware)
├── quality/ # all-pages clipping/overflow/placeholder/palette/density checks with severity
└── validate/ # MANDATORY final gate — XML well-formedness + maxGraph compile + sanity check
```
Each action exports `run(filePath: string, pageIndex?: number): Record<string, unknown>`.
Each action exports `run(filePath, pageIndex?, outputPath?, options?): Record<string, unknown>`. Analysis actions may ignore lifecycle-only parameters. File-producing actions must require `--output` and use the shared atomic writer.
### Key modules
@@ -129,32 +142,43 @@ Each action exports `run(filePath: string, pageIndex?: number): Record<string, u
**`src/cli/commands.ts`**
- `parseArgs` dispatcher → dynamic imports of action modules
- Supports `--file` / `-f`, `--action` / `-a`, `--page` / `-p` (0-based, default 0), `--help` / `-h`
- Common options: `--file` / `-f`, `--action` / `-a`, `--page` / `-p`, `--output` / `-o`, `--help` / `-h`
- Lifecycle options: `--spec`, `--base`, `--strict`, `--prune`, `--dry-run`, `--fail`, `--views`, `--kind`, repeatable `--property`, `--from`, and `--to`
- `doctor` is the only action that does not require `--file`
- All output serialised to YAML on stdout; exit `0` success, `1` error
---
## Adding a new action
1. Create `src/actions/<name>/action.ts` exporting:
1. Create `src/actions/<name>/action.ts` exporting a **synchronous** `run` (the dispatcher in `commands.ts` reads `result.summary` / `result.failed` without `await`, so an `async` function or a returned `Promise` would break exit-code handling). Copy the signature from an existing action, e.g. `src/actions/page-hierarchy/action.ts`:
```ts
export async function run(filePath: string, pageIndex?: number): Promise<Record<string, unknown>>
export function run(filePath: string, pageIndex: number = 0): Record<string, unknown> {
const pages = parseAllPages(filePath);
const page = pages[pageIndex];
if (!page) {
return { error: true, message: `Page index ${pageIndex} not found` };
}
// ...
return { action: "<name>", /* ... */ };
}
```
The full `ActionModule` contract is `run(filePath: string, pageIndex?: number, outputPath?: string, options?: LifecycleActionOptions): Record<string, unknown>`; declare only the parameters you use. Set `failed: true` in the result to make the CLI exit with code `1`.
2. Register it in `src/cli/commands.ts` under `ACTIONS`:
```ts
"my-action": () => import("../actions/my-action/action.js"),
```
3. Use `parseDiagram(filePath)` (first page) or `parseAllPages(filePath)` (all pages) from the parser
3. Use `parseAllPages(filePath)[pageIndex]` to honour `--page`, or `parseDiagram(filePath)` when the action is deliberately page-0-only; `parseAllPages` for all-page actions. Document the choice in the Scope column of `capabilities.md`
4. Optionally import `buildPageSummary(page)` from `page-summary.ts` for standard shape/edge serialisation
5. Return a plain object — the CLI serialises it to YAML automatically
6. Run `pnpm run build` to compile and verify no TypeScript errors
6. Run `task build` (or `pnpm run build`) to compile and verify no TypeScript errors, then add the action to `capabilities.md`, the `Actions:` list in `commands.ts --help`, and the Taskfile descriptions
### Naming convention
Actions follow `{object}-{action}` naming:
- `page-*` — operates on a single diagram page (uses `--page`, default 0)
- `summary` — operates on all pages
- `page-*` — operates on a single diagram page (`page-summary`, `page-hierarchy`, `page-hierarchy-full`, `page-negative-space-summary` honour `--page`; the remaining `page-*` actions currently analyse page 0 only)
- `summary`, `validate`, `quality` — operate on all pages
---
@@ -162,6 +186,7 @@ Actions follow `{object}-{action}` naming:
```typescript
const ACTIONS: Record<string, () => Promise<ActionModule>> = {
"build": () => import("../actions/build/action.js"),
"summary": () => import("../actions/summary/action.js"),
"page-summary": () => import("../actions/page-summary/action.js"),
"page-hierarchy": () => import("../actions/page-hierarchy/action.js"),
@@ -173,6 +198,16 @@ const ACTIONS: Record<string, () => Promise<ActionModule>> = {
"page-recommendations": () => import("../actions/page-recommendations/action.js"),
"page-hierarchy-full": () => import("../actions/page-hierarchy-full/action.js"),
"page-negative-space-summary":() => import("../actions/page-negative-space-summary/action.js"),
"validate": () => import("../actions/validate/action.js"),
"validate": () => import("../actions/validate/action.js"),
"quality": () => import("../actions/quality/action.js"),
"import": () => import("../actions/import/action.js"),
"edit": () => import("../actions/edit/action.js"),
"views": () => import("../actions/views/action.js"),
"query": () => import("../actions/query/action.js"),
"test": () => import("../actions/test/action.js"),
"what-if": () => import("../actions/what-if/action.js"),
"sync": () => import("../actions/sync/action.js"),
"story": () => import("../actions/story/action.js"),
"doctor": () => import("../actions/doctor/action.js"),
};
```
+1 -1
View File
@@ -1,4 +1,4 @@
# drawio-main — Connector Routing Best Practices
# diagrams-drawio — Connector Routing Best Practices
## Connector Routing Best Practices (Zero-Overlap Guarantee)
+44 -2
View File
@@ -1,4 +1,4 @@
# drawio-main — Layout Rules
# diagrams-drawio — Layout Rules
Connector placement and layout rules for draw.io diagrams generated by this skill.
These rules are **MANDATORY** — apply them when generating any diagram.
@@ -27,7 +27,49 @@ These rules are **MANDATORY** — apply them when generating any diagram.
- `startSize=40`, 1 row h=80: total = 40+40+80+40 = 200 (children at y=80)
- `startSize=40`, 2 rows h=40 each: total = 40+40+40+40+40+40 = 240 (row1 at y=80, row2 at y=160)
After generating or editing a diagram, run `page-spacing-audit` and `page-swimlane-audit` to verify.
After generating or editing a diagram, run `page-shape-bbox-validation`, `page-connectors-validation`, and `page-hierarchy-full` (geometry per nesting level) to verify.
### Page size
- Use page dimensions that fully fit the diagram, **including margins, legends, and connector routing corridors** — never let content clip the page edge
- `page-recommendations` reports the smallest standard page size that fits the content with an 80 px margin
### Parent (container) sizing — MANDATORY
**A parent shape must always be large enough to fully enclose all its children, including inner padding.** When children grow (added, resized, or repositioned), expand the parent using:
```
parent.width = max_child_right + right_padding (right_padding ≥ 40, divisible by 40)
parent.height = max_child_bottom + bottom_padding (bottom_padding ≥ 40, divisible by 40)
```
Where `max_child_right = max(child.x + child.width)` and `max_child_bottom = max(child.y + child.height)` over all children (relative to the parent). Left/top inner padding must also be ≥ 40 pt and divisible by 40 pt.
- **Cascade rule:** parent expansion may force *its* parent to expand. Walk up the containment tree and resize each ancestor until the outermost container fits all descendants
- **Re-check after expansion:** when a parent grows, re-verify sibling spacing at every affected level — all gaps must remain divisible by 40 pt
### Grouping and complexity
- Keep diagram complexity medium: group services into logical zones/layers (swimlanes or containers per major domain) instead of scattering many unrelated services
- Never route dense connector bundles directly through container titles/headers (see label-crossing prohibition below)
### Connector labels and legends
- In dense diagrams keep connector labels **off the connector path**; if labels are needed, place them in a clearly empty corridor, a side note, or a dedicated legend
- Prefer a separate legend for flow explanations whenever connector labels would clutter the diagram
- Keep legends **outside the main routing area** so no connector crosses legend text
- Connector labels must never overlap shapes, cards, icons, or other text
### Final visual inspection
Before completion, re-open or visually inspect the diagram and check specifically for:
- shape overlap (including external actors, cards, icons, notes, legends, containers)
- connector overlap with cards/icons
- connector label overlap
- text overflowing card boundaries
- page clipping
- inconsistent spacing between lanes/columns and between rows
- missing canonical vendor shapes
---
+11 -2
View File
@@ -1,4 +1,4 @@
# drawio-main — Style Rules
# diagrams-drawio — Style Rules
Visual appearance and sizing rules for draw.io diagrams generated by this skill.
These rules are **MANDATORY** — apply them when generating any diagram.
@@ -15,9 +15,17 @@ These rules are **MANDATORY** — apply them when generating any diagram.
- **Swimlane body height (total − startSize) must be divisible by 40** — so total height = 40 + N×40 = a multiple of 40
- Example: `startSize=40`, body=80 → total=120 ✓; body=160 → total=200 ✓
- Use ≤ 3 primary color families; create hierarchy with shades
- Use dashed connectors only for semantically distinct flows (async, backup, admin)
- Keep connector colors simple and consistent; use dashed connectors only for semantically distinct flows (async, backup, admin, private)
- Avoid borders on shapes unless needed for visual separation or canonical styling
- Shape size is driven by **content**, not routing — typically 80–160px tall per label line
### Text fit and cards
- Avoid long text inside narrow shapes; use wider cards or wrap the label in a dedicated text area inside the card
- Service names must fit within the visual card width and never extend beyond card boundaries
- For service-card diagrams use one consistent card pattern: icon on the left, service label on the right, enough padding around both
- When using canonical vendor icons, **preserve the original icon styling**; adjust the surrounding card/container layout instead of modifying the icon style
---
## Connector routing rules — MANDATORY
@@ -27,6 +35,7 @@ These rules are **MANDATORY** — apply them when generating any diagram.
- **Connectors must never attach at a shape corner** — the exit/entry point must lie on the middle of a side: top-center, bottom-center, left-center, or right-center. A point that is simultaneously on both an X-edge (left or right) AND a Y-edge (top or bottom) of the shape bounding box is a corner and is forbidden. Use the midpoint of the chosen side: `x_mid = (x1+x2)/2` for top/bottom sides, `y_mid = (y1+y2)/2` for left/right sides.
- These rules ensure a clean "bus-bar" fan-out/fan-in pattern and prevent connectors from diverging at their source or converging at their target at different positions, which creates visual clutter and increases crossing risk.
- Exception: shapes with only a single outgoing or single incoming connector — the single-port rule trivially holds. The corner rule still applies.
- **Arrowheads stay outside shape interiors** — connectors touch the shape/card edge and never pass through the body.
- When computing waypoints: use `page-negative-space-summary` to find free X corridors per row, then pick an exit/entry coordinate that lies within a free corridor at the next traversed level. This minimises connector-shape overlaps and connector crossings.
- **Waypoints must never be closer than 20px to any shape** — the first waypoint after a shape exit must be at least 20px away from the shape's edge in the direction of travel (e.g., if exiting bottom at y=520, first waypoint y ≥ 540; if exiting right at x=1040, first waypoint x ≥ 1060). The last waypoint before a shape entry must likewise be at least 20px away from the shape's edge. This 20px clearance also applies to any waypoint relative to same-level sibling shapes the connector passes by — the waypoint must not come within 20px of any sibling shape's bounding box side it is adjacent to.
+118
View File
@@ -0,0 +1,118 @@
# Semantic lifecycle workflows
`drawio-tools` keeps editable Draw.io XML and semantic Diagram IR v2 synchronized without requiring Draw.io Desktop, a browser, network access, Python, Graphviz, or ELK.
All examples run from `scripts/` after `pnpm install --frozen-lockfile` and `task build`. Results are YAML on standard output. Commands that write files require an explicit `--output` and use an atomic temporary-file rename. Input files cannot be used as output aliases.
## Import and round trip
```bash
node dist/cli/commands.js --action import \
--file architecture.drawio \
--output architecture.yaml
node dist/cli/commands.js --action build \
--file architecture.yaml \
--output rebuilt.drawio
```
Import accepts bare `mxGraphModel` documents and multi-page `mxfile` wrappers. It preserves stable cell IDs, labels (including deliberately empty labels), parent relationships, styles, geometry, waypoints, semantic metadata, wrapper/page/model attributes, and unknown XML children in `$drawio` preservation envelopes. Dangling endpoints and ambiguous identities fail closed. Import output must use `.yaml`, `.yml`, or `.json`.
## Transactional editing
```bash
node dist/cli/commands.js --action edit \
--file architecture.yaml \
--spec ../examples/edit-batch.yaml \
--dry-run
node dist/cli/commands.js --action edit \
--file architecture.yaml \
--spec ../examples/edit-batch.yaml \
--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`; `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
```bash
node dist/cli/commands.js --action views \
--file architecture.yaml \
--views executive,system,deployment,dataflow,security \
--output linked-views.drawio
```
Views preserve source model identifiers and provenance. A sparse view records an explicit fallback reason and modeling hint rather than pretending complete coverage. Unknown view names fail closed.
## Query and paths
```bash
node dist/cli/commands.js --action query --file architecture.yaml --kind service
node dist/cli/commands.js --action query --file architecture.yaml \
--property owner=platform --property 'production=true'
node dist/cli/commands.js --action query --file architecture.yaml \
--from client --to database
```
Queries filter by semantic kind and exact properties. `--from` plus `--to` returns a deterministic shortest directed path. Semantic operations require globally unambiguous node IDs across pages.
## Architecture policy tests
```bash
node dist/cli/commands.js --action test --file architecture.yaml
node dist/cli/commands.js --action test --file architecture.yaml \
--spec ../examples/policy-rules.yaml --strict
```
Built-in policy identifiers:
- `no-direct-internet-to-database`
- `no-cycles`
- `no-orphans`
- `every-service-has-owner`
- `production-has-observability`
- `external-dependencies-have-timeouts`
- `trust-boundaries-use-protocol`
Errors always fail. With `--strict`, warnings also fail. Unknown policy identifiers fail closed.
## Failure what-if analysis
```bash
node dist/cli/commands.js --action what-if \
--file architecture.yaml --fail api
```
The simulation follows outgoing dependencies and stops propagation at an edge with `properties.isolates_failure: true`. It never mutates the source model.
## Three-way synchronization
```bash
node dist/cli/commands.js --action sync \
--base generated-before.yaml \
--file manually-edited.yaml \
--spec generated-after.yaml \
--output synchronized.drawio
```
The base is the previous generated model, `--file` is the manually edited model, and `--spec` is the newly generated model. Synchronization preserves manual geometry/style, adds incoming identities, and reports semantic conflicts. Removed elements and pages are retained with lifecycle metadata by default; use `--prune` to remove them explicitly. Add `--dry-run` to return a preview without writing.
## Offline story
```bash
node dist/cli/commands.js --action story \
--file architecture.yaml \
--fail api \
--output architecture-story.html
```
The output is one self-contained HTML file with a restrictive Content Security Policy, escaped labels, keyboard navigation, a complete text alternative, provenance, and an optional what-if overlay. It makes no external requests and uses square corners.
## Doctor
```bash
node dist/cli/commands.js --action doctor
```
Doctor reports availability of optional local backends without launching them and confirms that the native core does not require network access.
+3 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://gitea.lego-cloud.eu/home-v1-skills-code-agent/drawio-main/raw/branch/main/schemas/diagram-ir-v2.schema.json",
"title": "drawio-main Diagram IR v2",
"$id": "https://gitea.lego-cloud.eu/home-v1-skills-code-agent/diagrams-drawio/raw/branch/test/schemas/diagram-ir-v2.schema.json",
"title": "diagrams-drawio Diagram IR v2",
"type": "object",
"required": ["version", "pages"],
"properties": {
@@ -22,7 +22,7 @@
"$defs": {
"safeId": {
"type": "string",
"pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$",
"pattern": "^[^\\s\\\"'<>]+$",
"not": { "enum": ["0", "1"] }
},
"point": {
+5 -3
View File
@@ -12,11 +12,13 @@ tasks:
run:
desc: |
Run any drawio-tools action against a .drawio file.
Usage: task cli:run -- --file="/path/to/diagram.drawio" --action=<action> [--page <index>]
Actions: summary, page-summary, page-hierarchy, page-connectors-summary, page-connectors-validation,
Run any drawio-tools action against a .drawio file or Diagram IR model.
Usage: task cli:run -- --file="/path/to/diagram.drawio" --action=<action> [--page <index>] [--output <path>]
Authoring: build
Analysis: summary, page-summary, page-hierarchy, page-connectors-summary, page-connectors-validation,
page-labels-validation, page-shape-bbox-validation, page-orphans, page-recommendations,
page-hierarchy-full, page-negative-space-summary, quality, validate
Lifecycle: import, edit, views, query, test, what-if, sync, story, doctor
cmds:
- |
./.scripts/cli/api/run.sh {{ .CLI_ARGS }}
+84 -2
View File
@@ -20,8 +20,8 @@ tasks:
run:
desc: |
Run any drawio-tools action against a .drawio file.
Usage: task run -- --file="/path/to/diagram.drawio" --action=<action> [--page <index>]
Run any drawio-tools action against a .drawio file or Diagram IR model (see `task cli:run` for the action list).
Usage: task run -- --file="/path/to/diagram.drawio" --action=<action> [--page <index>] [--output <path>]
cmds:
- task: cli:run
vars:
@@ -38,6 +38,88 @@ tasks:
CLI_ARGS: "--action=build {{ .CLI_ARGS }}"
silent: true
import:
desc: Loss-aware Draw.io import to Diagram IR v2; requires --file and --output.
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=import {{ .CLI_ARGS }}"
silent: true
edit:
desc: Apply an atomic stable-ID edit batch; requires --file and --spec.
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=edit {{ .CLI_ARGS }}"
silent: true
views:
desc: Project linked audience views to a multi-page Draw.io file.
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=views {{ .CLI_ARGS }}"
silent: true
query:
desc: |
Filter a Diagram IR v2 model by kind/property, or find a directed path.
Usage: task query -- --file="model.yaml" --kind=service | --property=k=v | --from=<id> --to=<id>
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=query {{ .CLI_ARGS }}"
silent: true
test:
desc: |
Run built-in architecture policy rules against a Diagram IR v2 model.
Usage: task test -- --file="model.yaml" [--spec="policy-rules.yaml"] [--strict]
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=test {{ .CLI_ARGS }}"
silent: true
what-if:
desc: |
Calculate outgoing failure impact from one node; requires --file and --fail.
Usage: task what-if -- --file="model.yaml" --fail=<node-id>
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=what-if {{ .CLI_ARGS }}"
silent: true
sync:
desc: |
Three-way stable-ID synchronization; requires --base, --file, and --spec.
Usage: task sync -- --base="before.yaml" --file="edited.yaml" --spec="after.yaml" --output="synced.drawio" [--prune] [--dry-run]
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=sync {{ .CLI_ARGS }}"
silent: true
doctor:
desc: |
Report optional local backend availability without launching anything; no --file needed.
Usage: task doctor
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=doctor {{ .CLI_ARGS }}"
silent: true
story:
desc: Publish a self-contained accessible offline architecture story.
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=story {{ .CLI_ARGS }}"
silent: true
validate:
desc: |
Validate a .drawio file (XML well-formedness + required structure).
+1 -1
View File
@@ -14,7 +14,7 @@
},
"dependencies": {
"@maxgraph/core": "^0.23.0",
"js-yaml": "^4.1.0",
"js-yaml": "^4.3.1",
"jsdom": "^25.0.0",
"pako": "^2.1.0"
},
+5 -5
View File
@@ -12,8 +12,8 @@ importers:
specifier: ^0.23.0
version: 0.23.0
js-yaml:
specifier: ^4.1.0
version: 4.3.0
specifier: ^4.3.1
version: 4.3.1
jsdom:
specifier: ^25.0.0
version: 25.0.1
@@ -373,8 +373,8 @@ packages:
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
jsdom@25.0.1:
@@ -779,7 +779,7 @@ snapshots:
is-potential-custom-element-name@1.0.1: {}
js-yaml@4.3.0:
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
+44 -1
View File
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { JSDOM } from "jsdom";
import { parseAllPages } from "../../services/drawio-parser/parser.js";
import { run } from "./action.js";
@@ -82,7 +83,7 @@ test("build serializes every v2 page and honors explicit geometry and waypoints"
const input = join(dir, "platform.yaml");
const output = join(dir, "platform.drawio");
try {
writeFileSync(input, `version: 2\ntitle: Platform\ntheme: zinc\nprovenance: { source: test }\nproperties: { audience: engineering }\nextensions: { owner: drawio-main }\npages:\n - id: system\n title: System\n properties: { view: logical }\n layout: { type: manual }\n nodes:\n - id: client\n label: Client\n kind: actor\n properties: { tier: edge }\n provenance: { source: inventory }\n extensions: { critical: true }\n geometry: { x: 40, y: 80, width: 120, height: 60 }\n - id: api\n label: API\n geometry: { x: 300, y: 80, width: 120, height: 60 }\n edges:\n - id: call\n source: client\n target: api\n kind: request\n properties: { protocol: HTTPS }\n waypoints: [{ x: 230, y: 110 }]\n - id: deployment\n title: Deployment\n nodes:\n - { id: server, label: Server }\n edges: []\n`, "utf8");
writeFileSync(input, `version: 2\ntitle: Platform\ntheme: zinc\nprovenance: { source: test }\nproperties: { audience: engineering }\nextensions: { owner: diagrams-drawio }\npages:\n - id: system\n title: System\n properties: { view: logical }\n layout: { type: manual }\n nodes:\n - id: client\n label: Client\n kind: actor\n properties: { tier: edge }\n provenance: { source: inventory }\n extensions: { critical: true }\n geometry: { x: 40, y: 80, width: 120, height: 60 }\n - id: api\n label: API\n geometry: { x: 300, y: 80, width: 120, height: 60 }\n edges:\n - id: call\n source: client\n target: api\n kind: request\n properties: { protocol: HTTPS }\n waypoints: [{ x: 230, y: 110 }]\n - id: deployment\n title: Deployment\n nodes:\n - { id: server, label: Server }\n edges: []\n`, "utf8");
const result = run(input, 0, output);
assert.deepEqual(result.summary, { nodes: 3, edges: 1, pages: 2 });
const xml = readFileSync(output, "utf8");
@@ -101,3 +102,45 @@ test("build serializes every v2 page and honors explicit geometry and waypoints"
rmSync(dir, { recursive: true, force: true });
}
});
test("build emits parseable XML when generic model attributes collide with fixed attributes", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-build-model-attributes-test-"));
const input = join(dir, "model-attributes.yaml");
const output = join(dir, "model-attributes.drawio");
try {
writeFileSync(input, `version: 2
pages:
- id: page
title: Page
layout: { type: manual, gridSize: 20 }
extensions:
$drawio:
attributes:
model:grid: "0"
model:gridSize: "99"
model:page: "0"
model:pageWidth: "1"
model:pageHeight: "2"
model:background: "#ffffff"
nodes:
- { id: node, label: Node, geometry: { x: 0, y: 0, width: 100, height: 60 } }
edges: []
`, "utf8");
run(input, 0, output);
const xml = readFileSync(output, "utf8");
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
const document = new dom.window.DOMParser().parseFromString(xml, "text/xml");
assert.equal(document.querySelector("parsererror"), null);
const model = document.querySelector("mxGraphModel");
assert.ok(model);
assert.equal(model.getAttribute("grid"), "1");
assert.equal(model.getAttribute("gridSize"), "20");
assert.equal(model.getAttribute("page"), "1");
assert.equal(model.getAttribute("pageWidth"), "1169");
assert.equal(model.getAttribute("pageHeight"), "827");
assert.equal(model.getAttribute("background"), "#ffffff");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+3 -4
View File
@@ -1,9 +1,9 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { readFileSync } from "node:fs";
import * as yaml from "js-yaml";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { normalizeDiagramIR, validateDiagramIR } from "../../model/diagram-ir.js";
import { atomicWrite } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _pageIndex = 0, outputPath?: string): Record<string, unknown> {
if (!outputPath) throw new Error("build requires --output <path.drawio>");
@@ -11,8 +11,7 @@ export function run(filePath: string, _pageIndex = 0, outputPath?: string): Reco
const ir = validateDiagramIR(parsed);
const normalized = normalizeDiagramIR(ir);
const xml = diagramIRToDrawio(normalized);
const resolvedOutput = resolve(outputPath);
writeFileSync(resolvedOutput, xml, "utf8");
const resolvedOutput = atomicWrite(outputPath, xml, [filePath]);
return {
action: "build",
file: filePath,
+2
View File
@@ -0,0 +1,2 @@
import { doctorReport } from "../../services/semantic-lifecycle/publishing.js";
export function run(_filePath = "", _page = 0, _output?: string): Record<string, unknown> { return { action: "doctor", ...doctorReport() }; }
+17
View File
@@ -0,0 +1,17 @@
import type { EditBatch } from "../../services/semantic-lifecycle/edit-batch.js";
import { applyEditBatch } from "../../services/semantic-lifecycle/edit-batch.js";
import { assertOutputSafe, atomicWrite, loadIR, loadStructured, structuredText, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
if (!options.spec) throw new Error("edit requires --spec <batch.yaml>");
if (!options.dryRun && !outputPath) throw new Error("edit requires --output unless --dry-run is used");
if (outputPath) assertOutputSafe(outputPath, [filePath, options.spec]);
const source = loadIR(filePath); const batch = loadStructured(options.spec) as EditBatch;
const result = applyEditBatch(source, batch, { dryRun: options.dryRun });
let output: string | undefined;
if (!options.dryRun && outputPath) {
const content = /\.(drawio|xml)$/i.test(outputPath) ? diagramIRToDrawio(result.ir) : structuredText(result.ir, outputPath);
output = atomicWrite(outputPath, content, [filePath, options.spec]);
}
return { action: "edit", output, applied: result.applied, dryRun: result.dryRun, preview: options.dryRun ? result.preview : undefined };
}
+9
View File
@@ -0,0 +1,9 @@
import { importDrawioToIR } from "../../services/semantic-lifecycle/import-drawio.js";
import { atomicWrite, structuredText } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, outputPath?: string): Record<string, unknown> {
if (!outputPath) throw new Error("import requires --output <model.yaml|model.json>");
if (!/\.(?:ya?ml|json)$/i.test(outputPath)) throw new Error("import output must use a .yaml, .yml, or .json extension");
const result = importDrawioToIR(filePath);
const output = atomicWrite(outputPath, structuredText(result.ir, outputPath), [filePath]);
return { action: "import", file: filePath, output, lossReport: result.lossReport };
}
+6
View File
@@ -0,0 +1,6 @@
import { queryDiagram } from "../../services/semantic-lifecycle/analysis.js";
import { loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, _output?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
const properties = Object.fromEntries((options.property ?? []).map((entry) => { const at = entry.indexOf("="); if (at < 1) throw new Error(`--property requires key=value: ${entry}`); const raw = entry.slice(at + 1); let value: unknown = raw; try { value = JSON.parse(raw); } catch {} return [entry.slice(0, at), value]; }));
return { action: "query", ...queryDiagram(loadIR(filePath), { kind: options.kind, properties, from: options.from, to: options.to }) };
}
+4
View File
@@ -0,0 +1,4 @@
import { createStoryHtml } from "../../services/semantic-lifecycle/publishing.js";
import { atomicWrite, loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
import { simulateFailure } from "../../services/semantic-lifecycle/analysis.js";
export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record<string, unknown> { if (!outputPath) throw new Error("story requires --output <story.html>"); const ir = loadIR(filePath); const scenario = options.fail ? simulateFailure(ir, options.fail) : undefined; const output = atomicWrite(outputPath, createStoryHtml(ir, { scenario }), [filePath]); return { action: "story", output, accessible: true, offline: true, scenario: scenario ? { failed: scenario.failed, impacted: scenario.impacted } : undefined }; }
+14
View File
@@ -0,0 +1,14 @@
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { assertOutputSafe, atomicWrite, loadIR, structuredText, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
import { syncDiagramIR } from "../../services/semantic-lifecycle/sync.js";
export type SyncActionOptions = LifecycleActionOptions & { force?: boolean };
export function run(filePath: string, _page = 0, outputPath?: string, options: SyncActionOptions = {}): Record<string, unknown> {
if (!options.base || !options.spec) throw new Error("sync requires --base <base> and --spec <incoming>"); if (!options.dryRun && !outputPath) throw new Error("sync requires --output unless --dry-run is used");
if (outputPath) assertOutputSafe(outputPath, [filePath, options.base, options.spec]);
const result = syncDiagramIR(loadIR(options.base), loadIR(filePath), loadIR(options.spec), { prune: options.prune });
// Unresolved conflicts fail the action (exit code 1) and block writing unless --force accepts the manual-preferred merge.
const failed = result.conflicts.length > 0 && options.force !== true;
const summary = result.conflicts.length === 0 ? "sync merged without conflicts" : failed ? `${result.conflicts.length} unresolved conflict(s); output not written (resolve them or pass --force to write the manual-preferred merge)` : `${result.conflicts.length} conflict(s) overridden by --force; manual values kept`;
let output: string | undefined; if (!options.dryRun && outputPath && !failed) output = atomicWrite(outputPath, /\.(drawio|xml)$/i.test(outputPath) ? diagramIRToDrawio(result.ir) : structuredText(result.ir, outputPath), [filePath, options.base, options.spec]);
return { action: "sync", output, dryRun: options.dryRun === true, failed, summary, added: result.added, removed: result.removed, conflicts: result.conflicts, preview: options.dryRun ? result.ir : undefined };
}
+8
View File
@@ -0,0 +1,8 @@
import { POLICY_IDS, runPolicies } from "../../services/semantic-lifecycle/analysis.js";
import { loadIR, loadStructured, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, _output?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
let rules: readonly string[] = POLICY_IDS; if (options.spec) { const raw = loadStructured(options.spec); rules = Array.isArray(raw) ? raw.map(String) : ((raw as { rules?: unknown[] }).rules ?? []).map((item) => typeof item === "string" ? item : String((item as { id?: unknown }).id)); }
const unknown = rules.filter((rule) => !(POLICY_IDS as readonly string[]).includes(rule));
if (unknown.length) throw new Error(`Unknown policy identifier: ${unknown.join(", ")}`);
const report = runPolicies(loadIR(filePath), rules); return { action: "test", ...report, failed: report.errors > 0 || (options.strict === true && report.warnings > 0) };
}
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { loadGraphStates } from "../../services/maxgraph-loader/graph-loader.js";
import { parseAllPages } from "../../services/drawio-parser/parser.js";
import { projectC4 } from "../../services/profiles/c4.js";
import { run as validate } from "../validate/action.js";
import { run as views } from "./action.js";
const SPEC = `version: 2
pages:
- id: main
title: Main
layout: { type: layered }
nodes:
- { id: zone, label: Zone, kind: container }
- { id: api, label: API, kind: service, parentId: zone, properties: { importance: 9 } }
- { id: worker, label: Worker, kind: service, parentId: zone, properties: { importance: 8 } }
- { id: db, label: DB, kind: database, parentId: zone, properties: { importance: 7 } }
- { id: user, label: User, kind: actor, properties: { importance: 10 } }
edges:
- { id: e1, source: user, target: api }
- { id: e2, source: api, target: worker }
- { id: e3, source: api, target: db, kind: write }
`;
function pageVertexIds(file: string, pageName: string): Set<string> {
const page = parseAllPages(file).find((item) => item.pageName === pageName)!;
return new Set(loadGraphStates(page.graphModelXml).vertexBounds.keys());
}
test("views drop parents that were projected away so every selected vertex renders", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-views-test-"));
const spec = join(dir, "spec.yaml");
const output = join(dir, "views.drawio");
try {
writeFileSync(spec, SPEC, "utf8");
// The security view selects user (actor) and db (database) but not their container "zone".
const result = views(spec, 0, output, { views: "security,dataflow" }) as { views: Array<{ id: string; fallback: boolean }> };
assert.deepEqual(result.views.map((view) => view.id), ["security", "dataflow"]);
assert.deepEqual(validate(output).summary, { pages: 2, valid: true, invalidPages: 0 });
assert.deepEqual([...pageVertexIds(output, "Security")].sort(), ["db", "user"]);
assert.deepEqual([...pageVertexIds(output, "Dataflow")].sort(), ["api", "db"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("views render C4 profile output whose elements span several pages", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-views-c4-test-"));
const source = join(dir, "c4.yaml");
const output = join(dir, "views.drawio");
try {
const c4 = projectC4({
title: "Shop",
elements: [
{ id: "customer", label: "Customer", type: "person" },
{ id: "shop", label: "Shop System", type: "system" },
{ id: "web", label: "Web App", type: "container", parentId: "shop" },
{ id: "api", label: "API", type: "container", parentId: "shop" },
{ id: "ctrl", label: "Controller", type: "component", parentId: "api" },
],
relationships: [
{ id: "r1", source: "customer", target: "web", label: "uses" },
{ id: "r2", source: "web", target: "api", label: "calls" },
{ id: "r3", source: "customer", target: "shop", label: "shops" },
],
});
writeFileSync(source, JSON.stringify(c4), "utf8");
views(source, 0, output, { views: "system,executive" });
assert.deepEqual(validate(output).summary, { pages: 2, valid: true, invalidPages: 0 });
assert.deepEqual([...pageVertexIds(output, "System")].sort(), ["api", "ctrl", "customer", "shop", "web"]);
assert.deepEqual([...pageVertexIds(output, "Executive")].sort(), ["api", "ctrl", "customer", "shop", "web"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+24
View File
@@ -0,0 +1,24 @@
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import type { DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
import { projectLinkedViews, type ViewName } from "../../services/semantic-lifecycle/analysis.js";
import { atomicWrite, loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
if (!outputPath) throw new Error("views requires --output <views.drawio>");
const source = loadIR(filePath);
const names = options.views?.split(",").filter(Boolean) as ViewName[] | undefined;
const allowed = new Set<ViewName>(["executive", "system", "deployment", "dataflow", "security"]);
const unknown = names?.filter((name) => !allowed.has(name)) ?? [];
if (unknown.length) throw new Error(`Unknown linked view: ${unknown.join(", ")}`);
const views = projectLinkedViews(source, names);
// A view unions nodes from several source pages, so their page-relative positions cannot coexist: keep only
// the sizes and let the layered layout place everything (waypoints are dropped for the same reason).
const relayout = (node: DiagramNode): DiagramNode => {
const { geometry, ...rest } = node;
return geometry ? { ...rest, width: geometry.width, height: geometry.height } : rest;
};
const ir: DiagramIRV2 = { version: 2, title: source.title, provenance: source.provenance, pages: views.map((view) => ({ id: view.id, title: view.title, nodes: view.nodes.map(relayout), edges: view.edges.map(({ waypoints: _waypoints, ...edge }) => edge), layout: { type: "layered" }, properties: { linkedView: true, sourcePageIds: view.sourcePageIds, fallback: view.fallback, fallbackReason: view.fallbackReason, hint: view.hint } })) };
validateDiagramIR(ir);
const output = atomicWrite(outputPath, diagramIRToDrawio(ir), [filePath]);
return { action: "views", output, views: views.map(({ id, fallback, fallbackReason, hint }) => ({ id, fallback, fallbackReason, hint })) };
}
+3
View File
@@ -0,0 +1,3 @@
import { simulateFailure } from "../../services/semantic-lifecycle/analysis.js";
import { loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, _output?: string, options: LifecycleActionOptions = {}): Record<string, unknown> { if (!options.fail) throw new Error("what-if requires --fail <node-id>"); return { action: "what-if", ...simulateFailure(loadIR(filePath), options.fail) }; }
+78 -31
View File
@@ -6,14 +6,24 @@ import { layoutPage } from "../services/layout/layout-engine.js";
const DEFAULT_NODE_STYLE = "whiteSpace=wrap;html=1;rounded=0;fillColor=#f5f5f5;strokeColor=#666666;";
const DEFAULT_CONTAINER_STYLE = "swimlane;html=1;rounded=0;startSize=30;fillColor=#f5f5f5;strokeColor=#666666;";
const DEFAULT_EDGE_STYLE = "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;";
const FIXED_MODEL_ATTRIBUTES = new Set(["grid", "gridSize", "page", "pageWidth", "pageHeight"]);
type Preservation = {
host?: string;
attributes?: Record<string, string>;
holderTag?: string;
holderAttributes?: Record<string, string>;
childXml?: string[];
geometryAttributes?: Record<string, string>;
geometryChildXml?: string[];
styleAbsent?: boolean;
modelAttributes?: Record<string, string>;
modelChildXml?: string[];
unknownCells?: string[];
};
function escapeXml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
}
function stableJson(value: unknown): string {
@@ -26,38 +36,72 @@ function stableJson(value: unknown): string {
return JSON.stringify(value);
}
function semanticExtensions(value: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
if (!value) return undefined;
const result = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "$drawio"));
return Object.keys(result).length ? result : undefined;
}
function preservation(value: Record<string, unknown> | undefined): Preservation {
const raw = value?.$drawio;
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Preservation : {};
}
function dataAttributes(values: Record<string, unknown>): string {
return Object.entries(values)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`)
return Object.entries(values).filter(([, value]) => value !== undefined).map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`).join("");
}
function rawAttributes(values: Record<string, string> | undefined, prefix?: string, reserved = new Set<string>()): string {
return Object.entries(values ?? {})
.filter(([key]) => !prefix || key.startsWith(prefix))
.map(([key, value]) => [prefix ? key.slice(prefix.length) : key, value] as const)
.filter(([key]) => !reserved.has(key) && /^[A-Za-z_][\w:.-]*$/.test(key))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => ` ${escapeXml(key)}="${escapeXml(value)}"`)
.join("");
}
function unprefixedAttributes(values: Record<string, string> | undefined): string {
return Object.entries(values ?? {}).filter(([key]) => !key.includes(":")).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` ${escapeXml(key)}="${escapeXml(value)}"`).join("");
}
function geometryForNode(node: DiagramNode, index: number, page: DiagramPage): { x: number; y: number; width: number; height: number } {
if (node.geometry) return node.geometry;
const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal";
return {
x: horizontal ? 40 + index * 200 : 40,
y: horizontal ? 80 : 80 + index * 120,
width: node.width ?? 120,
height: node.height ?? 60,
};
return { x: horizontal ? 40 + index * 200 : 40, y: horizontal ? 80 : 80 + index * 120, width: node.width ?? 120, height: node.height ?? 60 };
}
function wrapHolder(cell: string, id: string, label: string, ext: Record<string, unknown> | undefined, p: Preservation): string {
if (!p.holderTag) return cell;
const metadata = dataAttributes({ kind: undefined, properties: undefined, provenance: undefined, extensions: semanticExtensions(ext) });
return `<${p.holderTag} id="${escapeXml(id)}" label="${escapeXml(label)}"${rawAttributes(p.holderAttributes)}${metadata}>${cell}</${p.holderTag}>`;
}
function serializeNode(node: DiagramNode, index: number, page: DiagramPage): string {
const geometry = geometryForNode(node, index, page);
const style = node.style ?? (node.kind === "container" ? DEFAULT_CONTAINER_STYLE : DEFAULT_NODE_STYLE);
const parent = node.parentId ?? "1";
const metadata = dataAttributes({ kind: node.kind, properties: node.properties, provenance: node.provenance, extensions: node.extensions });
return ` <mxCell id="${escapeXml(node.id)}" value="${escapeXml(node.label)}" style="${escapeXml(style)}" vertex="1" parent="${escapeXml(parent)}"${metadata}><mxGeometry x="${geometry.x}" y="${geometry.y}" width="${geometry.width}" height="${geometry.height}" as="geometry"/></mxCell>`;
const p = preservation(node.extensions);
const renderedLabel = p.attributes?.["semantic:label"] ?? node.label;
const metadata = dataAttributes({ kind: node.kind, properties: node.properties, provenance: node.provenance, extensions: semanticExtensions(node.extensions) });
const geometryChildren = (p.geometryChildXml ?? []).join("");
const relative = p.geometryAttributes?.relative === undefined ? "" : ` relative="${escapeXml(p.geometryAttributes.relative)}"`;
const geometryXml = `<mxGeometry x="${geometry.x}" y="${geometry.y}" width="${geometry.width}" height="${geometry.height}"${relative} as="${escapeXml(p.geometryAttributes?.as ?? "geometry")}"${rawAttributes(p.attributes, "geometry:")}${geometryChildren ? `>${geometryChildren}</mxGeometry>` : "/>"}`;
const styleAttribute = p.styleAbsent === true ? "" : ` style="${escapeXml(style)}"`;
const cell = `<mxCell id="${escapeXml(node.id)}" value="${escapeXml(renderedLabel)}"${styleAttribute} vertex="1" parent="${escapeXml(parent)}"${metadata}${unprefixedAttributes(p.attributes)}>${geometryXml}${(p.childXml ?? []).join("")}</mxCell>`;
return ` ${wrapHolder(cell, node.id, renderedLabel, node.extensions, p)}`;
}
function serializeEdge(edge: DiagramEdge): string {
const points = edge.waypoints?.length
? `<Array as="points">${edge.waypoints.map((point) => `<mxPoint x="${point.x}" y="${point.y}"/>`).join("")}</Array>`
: "";
const metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: edge.extensions });
return ` <mxCell id="${escapeXml(edge.id)}" value="${escapeXml(edge.label ?? "")}" style="${escapeXml(edge.style ?? DEFAULT_EDGE_STYLE)}" edge="1" parent="1" source="${escapeXml(edge.source)}" target="${escapeXml(edge.target)}"${metadata}><mxGeometry relative="1" as="geometry">${points}</mxGeometry></mxCell>`;
const p = preservation(edge.extensions);
const points = edge.waypoints?.length ? `<Array as="points">${edge.waypoints.map((point) => `<mxPoint x="${point.x}" y="${point.y}"/>`).join("")}</Array>` : "";
const metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: semanticExtensions(edge.extensions) });
const geometryChildren = `${points}${(p.geometryChildXml ?? []).join("")}`;
const relative = p.geometryAttributes?.relative === undefined ? "1" : p.geometryAttributes.relative;
const geometry = `<mxGeometry relative="${escapeXml(relative)}" as="${escapeXml(p.geometryAttributes?.as ?? "geometry")}"${rawAttributes(p.attributes, "geometry:")}>${geometryChildren}</mxGeometry>`;
const cell = `<mxCell id="${escapeXml(edge.id)}" value="${escapeXml(edge.label ?? "")}" style="${escapeXml(edge.style ?? DEFAULT_EDGE_STYLE)}" edge="1" parent="1" source="${escapeXml(edge.source)}" target="${escapeXml(edge.target)}"${metadata}${unprefixedAttributes(p.attributes)}>${geometry}${(p.childXml ?? []).join("")}</mxCell>`;
return ` ${wrapHolder(cell, edge.id, edge.label ?? "", edge.extensions, p)}`;
}
function contentExtent(page: DiagramPage): { width: number; height: number } {
@@ -75,14 +119,11 @@ function contentExtent(page: DiagramPage): { width: number; height: number } {
};
const geometries = page.nodes.map((node) => resolve(node.id)).filter((geometry) => geometry !== undefined);
const points = page.edges.flatMap((edge) => edge.waypoints ?? []);
return {
width: Math.max(0, ...geometries.map((geometry) => geometry.x + geometry.width), ...points.map((point) => point.x)),
height: Math.max(0, ...geometries.map((geometry) => geometry.y + geometry.height), ...points.map((point) => point.y)),
};
return { width: Math.max(0, ...geometries.map((geometry) => geometry.x + geometry.width), ...points.map((point) => point.x)), height: Math.max(0, ...geometries.map((geometry) => geometry.y + geometry.height), ...points.map((point) => point.y)) };
}
function serializePage(input: DiagramPage): string {
const page = routePageEdges(layoutPage(input));
const page = input.layout?.type === "manual" ? input : routePageEdges(layoutPage(input));
const nodeCells = page.nodes.map((node, index) => serializeNode(node, index, page));
const edgeCells = page.edges.map(serializeEdge);
const extent = contentExtent(page);
@@ -90,12 +131,18 @@ function serializePage(input: DiagramPage): string {
const fit = (value: number) => Math.ceil((value + 40) / grid) * grid;
const pageWidth = Math.max(page.width ?? 1169, fit(extent.width));
const pageHeight = Math.max(page.height ?? 827, fit(extent.height));
const metadata = dataAttributes({ properties: page.properties, extensions: page.extensions });
return ` <diagram id="${escapeXml(page.id)}" name="${escapeXml(page.title)}"${metadata}>\n <mxGraphModel grid="1" gridSize="${page.layout?.gridSize ?? 10}" page="1" pageWidth="${pageWidth}" pageHeight="${pageHeight}">\n <root>\n <mxCell id="0"/>\n <mxCell id="1" parent="0"/>\n${[...nodeCells, ...edgeCells].join("\n")}\n </root>\n </mxGraphModel>\n </diagram>`;
const p = preservation(page.extensions);
const metadata = dataAttributes({ properties: page.properties, extensions: semanticExtensions(page.extensions) });
const diagramChildren = (p.childXml ?? []).map((xml) => ` ${xml}`);
const modelChildren = (p.modelChildXml ?? []).map((xml) => ` ${xml}`);
const modelAttributes = p.modelAttributes ?? {};
return ` <diagram id="${escapeXml(page.id)}" name="${escapeXml(page.title)}"${metadata}${unprefixedAttributes(p.attributes)}>\n${diagramChildren.length ? `${diagramChildren.join("\n")}\n` : ""} <mxGraphModel grid="${escapeXml(modelAttributes.grid ?? "1")}" gridSize="${escapeXml(modelAttributes.gridSize ?? String(grid))}" page="${escapeXml(modelAttributes.page ?? "1")}" pageWidth="${escapeXml(modelAttributes.pageWidth ?? String(pageWidth))}" pageHeight="${escapeXml(modelAttributes.pageHeight ?? String(pageHeight))}"${rawAttributes(p.attributes, "model:", FIXED_MODEL_ATTRIBUTES)}>\n${modelChildren.length ? `${modelChildren.join("\n")}\n` : ""} <root>\n <mxCell id="0"/>\n <mxCell id="1" parent="0"/>\n${[...nodeCells, ...edgeCells, ...(p.unknownCells ?? []).map((xml) => ` ${xml}`)].join("\n")}\n </root>\n </mxGraphModel>\n </diagram>`;
}
export function diagramIRToDrawio(ir: DiagramIR): string {
const normalized = normalizeDiagramIR(ir);
const metadata = dataAttributes({ version: normalized.version, title: normalized.title, theme: normalized.theme, provenance: normalized.provenance, assets: normalized.assets, properties: normalized.properties, extensions: normalized.extensions });
return `<?xml version="1.0" encoding="UTF-8"?>\n<mxfile host="drawio-tools"${metadata}>\n${normalized.pages.map(serializePage).join("\n")}\n</mxfile>\n`;
const p = preservation(normalized.extensions);
const metadata = dataAttributes({ version: normalized.version, title: normalized.title, theme: normalized.theme, provenance: normalized.provenance, assets: normalized.assets, properties: normalized.properties, extensions: semanticExtensions(normalized.extensions) });
const rootChildren = (p.childXml ?? []).map((xml) => ` ${xml}`);
return `<?xml version="1.0" encoding="UTF-8"?>\n<mxfile host="${escapeXml(p.host ?? "drawio-tools")}"${metadata}${unprefixedAttributes(p.attributes)}>\n${rootChildren.length ? `${rootChildren.join("\n")}\n` : ""}${normalized.pages.map(serializePage).join("\n")}\n</mxfile>\n`;
}
+9
View File
@@ -34,6 +34,15 @@ test("CLI rejects partially numeric page indexes", () => {
assert.match(result.stderr, /non-negative integer/);
});
test("CLI help documents lifecycle options and file-free doctor", () => {
const result = spawnSync(process.execPath, ["--import", "tsx", CLI, "--help"], { cwd: resolve("."), encoding: "utf8" });
assert.equal(result.status, 0, result.stdout + result.stderr);
for (const option of ["--spec", "--base", "--strict", "--prune", "--dry-run", "--fail", "--views", "--property", "--from", "--to"]) {
assert.match(result.stdout, new RegExp(option));
}
assert.match(result.stdout, /doctor.*does not require --file/i);
});
test("CLI validate exits non-zero when the validation summary is invalid", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-cli-validate-test-"));
const input = join(dir, "empty.drawio");
+47 -5
View File
@@ -22,13 +22,14 @@
import { parseArgs } from "node:util";
import * as yaml from "js-yaml";
import type { LifecycleActionOptions } from "../services/semantic-lifecycle/lifecycle-io.js";
// ---------------------------------------------------------------------------
// Action registry
// ---------------------------------------------------------------------------
type ActionModule = {
run: (filePath: string, pageIndex?: number, outputPath?: string) => Record<string, unknown>;
run: (filePath: string, pageIndex?: number, outputPath?: string, options?: LifecycleActionOptions & { force?: boolean }) => Record<string, unknown>;
};
const ACTIONS: Record<string, () => Promise<ActionModule>> = {
@@ -46,6 +47,15 @@ const ACTIONS: Record<string, () => Promise<ActionModule>> = {
"page-negative-space-summary":() => import("../actions/page-negative-space-summary/action.js"),
"validate": () => import("../actions/validate/action.js"),
"quality": () => import("../actions/quality/action.js"),
"import": () => import("../actions/import/action.js"),
"edit": () => import("../actions/edit/action.js"),
"views": () => import("../actions/views/action.js"),
"query": () => import("../actions/query/action.js"),
"test": () => import("../actions/test/action.js"),
"what-if": () => import("../actions/what-if/action.js"),
"sync": () => import("../actions/sync/action.js"),
"story": () => import("../actions/story/action.js"),
"doctor": () => import("../actions/doctor/action.js"),
};
// ---------------------------------------------------------------------------
@@ -53,7 +63,7 @@ const ACTIONS: Record<string, () => Promise<ActionModule>> = {
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
let values: { file?: string; action?: string; page?: string; output?: string; help?: boolean };
let values: { file?: string; action?: string; page?: string; output?: string; help?: boolean; spec?: string; base?: string; strict?: boolean; prune?: boolean; force?: boolean; "dry-run"?: boolean; fail?: string; views?: string; kind?: string; property?: string[]; from?: string; to?: string };
try {
({ values } = parseArgs({
args: process.argv.slice(2),
@@ -62,6 +72,18 @@ async function main(): Promise<void> {
action: { type: "string", short: "a" },
page: { type: "string", short: "p" },
output: { type: "string", short: "o" },
spec: { type: "string" },
base: { type: "string" },
strict: { type: "boolean" },
prune: { type: "boolean" },
force: { type: "boolean" },
"dry-run": { type: "boolean" },
fail: { type: "string" },
views: { type: "string" },
kind: { type: "string" },
property: { type: "string", multiple: true },
from: { type: "string" },
to: { type: "string" },
help: { type: "boolean", short: "h" },
},
strict: true,
@@ -74,11 +96,26 @@ async function main(): Promise<void> {
if (values.help) {
console.log(`drawio-tools --file <path> --action <action> [--page <index>] [--output <path>]
Lifecycle options:
--spec <path> Edit batch, policy list, or incoming sync model
--base <path> Previous generated model for three-way sync
--strict Treat policy warnings as failures
--prune Remove identities absent from incoming sync model
--force Write sync output despite unresolved conflicts (manual values kept)
--dry-run Return edit/sync preview without writing
--fail <node-id> Failed node for what-if or story overlay
--views <csv> executive,system,deployment,dataflow,security
--kind <kind> Query node kind
--property k=v Repeatable exact property query
--from <node-id> Directed path start (use with --to)
--to <node-id> Directed path destination (use with --from)
Doctor does not require --file.
Actions: ${Object.keys(ACTIONS).join(", ")}`);
process.exit(0);
}
if (!values.file) {
if (!values.file && values.action !== "doctor") {
printError("Missing required argument: --file");
process.exit(1);
}
@@ -88,7 +125,7 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
}
const actionName = values.action;
const filePath = values.file;
const filePath = values.file ?? "";
// Parse optional --page argument (0-based index, default 0)
let pageIndex = 0;
@@ -113,7 +150,11 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
try {
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, force: values.force,
dryRun: values["dry-run"], fail: values.fail, views: values.views, kind: values.kind,
property: values.property, from: values.from, to: values.to,
});
process.stdout.write(
yaml.dump(result, { noRefs: true, sortKeys: false, lineWidth: 120 })
);
@@ -121,6 +162,7 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
if (actionName === "validate" && summary && typeof summary === "object" && (summary as Record<string, unknown>).valid === false) {
process.exitCode = 1;
}
if (result.failed === true) process.exitCode = 1;
} catch (err) {
const errorOutput = {
error: true,
+108
View File
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const CLI = resolve("src/cli/commands.ts");
const run = (args: string[]) => spawnSync(process.execPath, ["--import", "tsx", CLI, ...args], { cwd: resolve("."), encoding: "utf8" });
test("CLI imports Draw.io to machine-readable IR only with explicit atomic output", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-import-"));
const source = join(dir, "source.drawio"); const output = join(dir, "model.json");
try {
writeFileSync(source, `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="API" vertex="1" parent="1"><mxGeometry x="10" y="20" width="100" height="50" as="geometry"/></mxCell></root></mxGraphModel>`, "utf8");
const missing = run(["--action", "import", "--file", source]);
assert.equal(missing.status, 1);
assert.match(missing.stdout, /requires --output/i);
const result = run(["--action", "import", "--file", source, "--output", output]);
assert.equal(result.status, 0, result.stdout + result.stderr);
const parsed = JSON.parse(readFileSync(output, "utf8"));
assert.equal(parsed.version, 2);
assert.equal(parsed.pages[0].nodes[0].id, "api");
assert.match(result.stdout, /lossy: false/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("failed CLI edit is atomic and traversal aliases cannot clobber source", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-edit-"));
const source = join(dir, "source.json"); const spec = join(dir, "bad.yaml"); const output = join(dir, "output.json");
const sourceText = JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }, null, 2) + "\n";
try {
writeFileSync(source, sourceText, "utf8"); writeFileSync(spec, "pageId: p\noperations:\n - { type: update, id: api, changes: { parentId: missing } }\n", "utf8"); writeFileSync(output, "DO NOT CLOBBER", "utf8");
const failed = run(["--action", "edit", "--file", source, "--spec", spec, "--output", output]);
assert.equal(failed.status, 1);
assert.equal(readFileSync(source, "utf8"), sourceText);
assert.equal(readFileSync(output, "utf8"), "DO NOT CLOBBER");
const alias = join(dir, "nested", "..", "source.json");
const clobber = run(["--action", "edit", "--file", source, "--spec", spec, "--output", alias]);
assert.equal(clobber.status, 1);
assert.match(clobber.stdout, /overwrite an input/i);
assert.equal(readFileSync(source, "utf8"), sourceText);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("CLI rejects symlink input aliases targeting the real output", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-symlink-alias-"));
const source = join(dir, "source.json"); const inputLink = join(dir, "input.json"); const spec = join(dir, "edit.yaml");
const sourceText = JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }, null, 2) + "\n";
try {
writeFileSync(source, sourceText, "utf8");
symlinkSync(source, inputLink);
writeFileSync(spec, "pageId: p\noperations:\n - { type: update, id: api, changes: { label: Updated } }\n", "utf8");
const result = run(["--action", "edit", "--file", inputLink, "--spec", spec, "--output", source]);
assert.equal(result.status, 1);
assert.match(result.stdout, /overwrite an input/i);
assert.equal(readFileSync(source, "utf8"), sourceText);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("CLI lifecycle flags route deterministic views, policies, what-if, sync, story, and doctor", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-actions-"));
const model = join(dir, "model.json"); const views = join(dir, "views.drawio"); const story = join(dir, "story.html"); const base = join(dir, "base.json"); const incoming = join(dir, "incoming.json"); const synced = join(dir, "synced.json");
const ir = { version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API", kind: "service", properties: { environment: "production" } }, { id: "db", label: "DB", kind: "database" }], edges: [{ id: "write", source: "api", target: "db", properties: { isolates_failure: true } }] }] };
try {
writeFileSync(model, JSON.stringify(ir), "utf8"); writeFileSync(base, JSON.stringify(ir), "utf8"); writeFileSync(incoming, JSON.stringify(ir), "utf8");
const viewResult = run(["--action", "views", "--file", model, "--views", "system,security", "--output", views]);
assert.equal(viewResult.status, 0, viewResult.stdout + viewResult.stderr); assert.equal(existsSync(views), true);
const policy = run(["--action", "test", "--file", model, "--strict"]); assert.equal(policy.status, 1); assert.match(policy.stdout, /warnings:/);
const whatif = run(["--action", "what-if", "--file", model, "--fail", "api"]); assert.equal(whatif.status, 0); assert.match(whatif.stdout, /impacted: \[\]/);
const sync = run(["--action", "sync", "--file", model, "--base", base, "--spec", incoming, "--prune", "--output", synced]); assert.equal(sync.status, 0, sync.stdout + sync.stderr);
const storyResult = run(["--action", "story", "--file", model, "--fail", "api", "--output", story]); assert.equal(storyResult.status, 0); assert.doesNotMatch(readFileSync(story, "utf8"), /https?:\/\//);
const doctor = run(["--action", "doctor"]); assert.equal(doctor.status, 0, doctor.stdout + doctor.stderr); assert.match(doctor.stdout, /networkRequired: false/);
const first = readFileSync(views, "utf8"); run(["--action", "views", "--file", model, "--views", "system,security", "--output", views]); assert.equal(readFileSync(views, "utf8"), first);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("CLI edits serialize Draw.io output and rejects unsupported import extensions", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-formats-"));
const model = join(dir, "model.json"); const spec = join(dir, "edit.yaml"); const drawio = join(dir, "edited.drawio");
const source = join(dir, "source.drawio"); const unsupported = join(dir, "model.txt");
try {
writeFileSync(model, JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }), "utf8");
writeFileSync(spec, "pageId: p\noperations:\n - { type: update, id: api, changes: { label: Updated } }\n", "utf8");
const edited = run(["--action", "edit", "--file", model, "--spec", spec, "--output", drawio]);
assert.equal(edited.status, 0, edited.stdout + edited.stderr);
assert.match(readFileSync(drawio, "utf8"), /^<\?xml[^]*<mxfile/);
writeFileSync(source, `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`, "utf8");
const imported = run(["--action", "import", "--file", source, "--output", unsupported]);
assert.equal(imported.status, 1);
assert.match(imported.stdout, /yaml.*json|json.*yaml/i);
assert.equal(existsSync(unsupported), false);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("CLI rejects unknown linked views and policy identifiers", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-invalid-"));
const model = join(dir, "model.json"); const views = join(dir, "views.drawio"); const rules = join(dir, "rules.yaml");
try {
writeFileSync(model, JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }), "utf8");
writeFileSync(rules, "rules: [not-a-real-policy]\n", "utf8");
const badView = run(["--action", "views", "--file", model, "--views", "not-a-view", "--output", views]);
assert.equal(badView.status, 1); assert.match(badView.stdout, /unknown.*view/i); assert.equal(existsSync(views), false);
const badPolicy = run(["--action", "test", "--file", model, "--spec", rules]);
assert.equal(badPolicy.status, 1); assert.match(badPolicy.stdout, /unknown.*policy/i);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
+1 -1
View File
@@ -79,7 +79,7 @@ export interface DiagramIRV2 {
export type DiagramIR = DiagramIRV1 | DiagramIRV2;
const SAFE_ID = /^[A-Za-z][A-Za-z0-9_.:-]*$/;
const SAFE_ID = /^[^\s"'<>]+$/;
const RESERVED_IDS = new Set(["0", "1"]);
const LAYOUT_TYPES = new Set<DiagramLayoutType>(["linear", "layered", "tree", "grid", "manual"]);
const ROOT_V1_FIELDS = new Set(["version", "title", "direction", "nodes", "edges"]);
@@ -1,6 +1,11 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { run as validateConnectors } from "../../actions/page-connectors-validation/action.js";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import type { DiagramPage, DiagramPoint, DiagramGeometry } from "../../model/diagram-ir.js";
import { routePageEdges } from "./orthogonal-router.js";
@@ -106,3 +111,61 @@ test("routing resolves nested node geometry to page coordinates", () => {
assert.equal(segmentCrossesRect(path[index], path[index + 1], blocker), false);
}
});
test("routes into and out of an enclosing container avoid its header band", () => {
const page: DiagramPage = {
id: "containers",
title: "Containers",
layout: { type: "layered", direction: "horizontal" },
nodes: [
{ id: "vpc", label: "VPC", kind: "container" },
{ id: "web", label: "Web", parentId: "vpc" },
{ id: "app", label: "App", parentId: "vpc" },
{ id: "db", label: "Database", parentId: "vpc" },
{ id: "user", label: "User" },
{ id: "ext", label: "External Payment Provider", properties: {} },
],
edges: [
{ id: "e1", source: "user", target: "web", label: "HTTPS" },
{ id: "e2", source: "web", target: "app" },
{ id: "e3", source: "app", target: "db", label: "SQL" },
{ id: "e4", source: "app", target: "ext", label: "REST" },
{ id: "e5", source: "user", target: "ext", label: "redirect" },
],
};
const xml = diagramIRToDrawio({ version: 2, pages: [page] });
assert.equal(xml, diagramIRToDrawio({ version: 2, pages: [page] }));
const dir = mkdtempSync(join(tmpdir(), "router-header-band-"));
const file = join(dir, "containers.drawio");
try {
writeFileSync(file, xml, "utf8");
const validation = validateConnectors(file) as { summary: { headerEdgeViolations: number; connectorShapeOverlaps: number }; issues: unknown[] };
assert.equal(validation.summary.headerEdgeViolations, 0, JSON.stringify(validation.issues));
assert.equal(validation.summary.connectorShapeOverlaps, 0, JSON.stringify(validation.issues));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("router treats only the header band of an endpoint's ancestor as an obstacle", () => {
const zone: DiagramGeometry = { x: 100, y: 100, width: 400, height: 260 };
const page: DiagramPage = {
id: "band",
title: "Band",
layout: { type: "manual", gridSize: 10 },
nodes: [
{ id: "zone", label: "Zone", kind: "container", geometry: zone },
{ id: "inside", label: "Inside", parentId: "zone", geometry: { x: 140, y: 120, width: 120, height: 60 } },
{ id: "above", label: "Above", geometry: { x: 140, y: 0, width: 120, height: 40 } },
],
edges: [{ id: "down", source: "above", target: "inside" }],
};
const routed = routePageEdges(page);
// "inside" sits at (240, 220) in page coordinates; the edge runs from the bottom of "above" to its top.
const path = [{ x: 200, y: 40 }, ...routed.edges[0].waypoints!, { x: 300, y: 220 }];
const header: DiagramGeometry = { ...zone, height: 30 };
for (let index = 0; index < path.length - 1; index += 1) {
assert.equal(segmentCrossesRect(path[index], path[index + 1], header), false, JSON.stringify(path));
}
assert.ok(path.some((point) => point.y > zone.y + 30 && point.x < zone.x), "route enters the container body from the side rather than through the header");
});
@@ -1,4 +1,4 @@
import type { DiagramEdge, DiagramGeometry, DiagramPage, DiagramPoint } from "../../model/diagram-ir.js";
import type { DiagramEdge, DiagramGeometry, DiagramNode, DiagramPage, DiagramPoint } from "../../model/diagram-ir.js";
interface Obstacle extends DiagramGeometry {
id: string;
@@ -102,6 +102,15 @@ function absoluteGeometryById(page: DiagramPage): Map<string, DiagramGeometry> {
return result;
}
// Height of a container's label band. Mirrors ir-to-drawio's default container style (swimlane with
// startSize=30) and draw.io's own swimlane default (40) when a custom style omits startSize.
function headerHeight(node: DiagramNode): number {
if (node.style === undefined) return node.kind === "container" ? 30 : 0;
const explicit = /(?:^|;)startSize=(\d+(?:\.\d+)?)/.exec(node.style);
if (explicit) return Number(explicit[1]);
return /(?:^|;)swimlane(?:;|$)/.test(node.style) ? 40 : 0;
}
function ancestorIds(page: DiagramPage, id: string): Set<string> {
const nodes = new Map(page.nodes.map((node) => [node.id, node]));
const ancestors = new Set<string>();
@@ -134,9 +143,16 @@ function routeEdge(edge: DiagramEdge, page: DiagramPage, geometries: Map<string,
}
const endpoints = endpointPair(source, target);
const endpointAncestors = new Set([...ancestorIds(page, edge.source), ...ancestorIds(page, edge.target)]);
// Containers enclosing an endpoint may be crossed, except for their header band: a route through the
// label bar (or along its bottom line) merges visually with the container border.
const obstacles: Obstacle[] = page.nodes
.filter((node) => node.id !== edge.source && node.id !== edge.target && !endpointAncestors.has(node.id) && geometries.has(node.id))
.map((node) => ({ id: node.id, ...geometries.get(node.id)! }));
.filter((node) => node.id !== edge.source && node.id !== edge.target && geometries.has(node.id))
.flatMap((node) => {
const geometry = geometries.get(node.id)!;
if (!endpointAncestors.has(node.id)) return [{ id: node.id, ...geometry }];
const header = headerHeight(node);
return header > 0 ? [{ id: node.id, ...geometry, height: header }] : [];
});
const sourceStub = outwardStub(endpoints.source, source, padding);
const targetStub = outwardStub(endpoints.target, target, padding);
const middleX = snap((sourceStub.x + targetStub.x) / 2);
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
import { createBuildup } from "./buildup.js";
const PAGE: DiagramPage = { id: "deploy", title: "Deploy", nodes: [{ id: "db", label: "DB", provenance: { source: "infra" } }, { id: "api", label: "API" }, { id: "web", label: "Web" }], edges: [{ id: "db-api", source: "db", target: "api" }, { id: "api-web", source: "api", target: "web" }] };
test("buildup uses deterministic topological order and valid cumulative IR frames", () => {
const before = structuredClone(PAGE); const result = createBuildup(PAGE);
assert.deepEqual(result, createBuildup(PAGE)); assert.deepEqual(PAGE, before); assert.deepEqual(result.order, ["db", "api", "web"]); assert.deepEqual(result.cycleFallback, []);
result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame), frame));
assert.deepEqual(result.frames.map((frame) => frame.pages[0].nodes.map((node) => node.id)), [["db"], ["db", "api"], ["db", "api", "web"]]);
assert.equal(result.frames[0].pages[0].nodes[0].provenance?.source, "infra");
});
test("buildup explicitly falls back to stable ID order for cycles", () => {
const cyclic: DiagramPage = { id: "cycle", title: "Cycle", nodes: [{ id: "b", label: "B" }, { id: "a", label: "A" }], edges: [{ id: "ab", source: "a", target: "b" }, { id: "ba", source: "b", target: "a" }] };
const result = createBuildup(cyclic); assert.deepEqual(result.order, ["a", "b"]); assert.deepEqual(result.cycleFallback, ["a", "b"]);
result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame), frame));
});
test("buildup cycle fallback keeps containers before children so every frame remains valid", () => {
const cyclic: DiagramPage = { id: "nested", title: "Nested", nodes: [{ id: "a-child", label: "Child", parentId: "z-parent" }, { id: "z-parent", label: "Parent", kind: "container" }], edges: [{ id: "reverse", source: "a-child", target: "z-parent" }] };
const result = createBuildup(cyclic);
assert.deepEqual(result.order, ["z-parent", "a-child"]);
result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame), frame));
});
test("buildup rejects empty and invalid input", () => {
assert.throws(() => createBuildup({ id: "empty", title: "Empty", nodes: [], edges: [] }), /non-empty/i);
assert.throws(() => createBuildup({ id: "bad", title: "Bad", nodes: [{ id: "a", label: "A" }], edges: [{ id: "e", source: "x", target: "a" }] }), /unknown source/i);
});
+36
View File
@@ -0,0 +1,36 @@
import type { DiagramIRV2, DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export interface BuildupResult { order: string[]; cycleFallback: string[]; frames: DiagramIRV2[] }
export function createBuildup(page: DiagramPage): BuildupResult {
validateDiagramIR({ version: 2, pages: [structuredClone(page)] });
const ids = page.nodes.map((node) => node.id);
const adjacency = new Map(ids.map((id) => [id, new Set<string>()]));
const indegree = new Map(ids.map((id) => [id, 0]));
const addDependency = (source: string, target: string) => {
const next = adjacency.get(source)!; if (next.has(target)) return; next.add(target); indegree.set(target, indegree.get(target)! + 1);
};
page.edges.forEach((edge) => addDependency(edge.source, edge.target));
page.nodes.forEach((node) => { if (node.parentId) addDependency(node.parentId, node.id); });
const ready = ids.filter((id) => indegree.get(id) === 0).sort(); const order: string[] = [];
while (ready.length) {
const id = ready.shift()!; order.push(id);
for (const target of [...adjacency.get(id)!].sort()) { indegree.set(target, indegree.get(target)! - 1); if (indegree.get(target) === 0) { ready.push(target); ready.sort(); } }
}
const nodeById = new Map(page.nodes.map((node) => [node.id, node]));
const parentDepth = (id: string): number => {
let depth = 0; let current = nodeById.get(id)?.parentId;
while (current) { depth += 1; current = nodeById.get(current)?.parentId; }
return depth;
};
const cycleFallback = ids.filter((id) => !order.includes(id)).sort((a, b) => parentDepth(a) - parentDepth(b) || a.localeCompare(b));
order.push(...cycleFallback);
const frames = order.map((_, index) => {
const includedOrder = order.slice(0, index + 1); const included = new Set(includedOrder);
const framePage: DiagramPage = { ...structuredClone(page), id: `${page.id}-step-${String(index + 1).padStart(3, "0")}`, title: `${page.title} — Step ${index + 1}`, nodes: includedOrder.map((id) => structuredClone(nodeById.get(id)!)), edges: page.edges.filter((edge) => included.has(edge.source) && included.has(edge.target)).map((edge) => structuredClone(edge)), properties: { ...(page.properties ?? {}), profile: "buildup", step: index + 1, addedNodeId: order[index], cycleFallback: cycleFallback.includes(order[index]) } };
const ir: DiagramIRV2 = { version: 2, title: page.title, pages: [framePage], properties: { profile: "buildup", order: [...order], cycleFallback: [...cycleFallback] } };
return validateDiagramIR(ir) as DiagramIRV2;
});
return { order, cycleFallback, frames };
}
+159
View File
@@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import test from "node:test";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
import { projectC4 } from "./c4.js";
const MODEL = {
title: "Shop",
provenance: { source: "architecture.yaml" },
elements: [
{ id: "buyer", label: "Buyer", type: "person" as const },
{ id: "shop", label: "Shop", type: "system" as const, provenance: { line: 2 } },
{ id: "api", label: "API", type: "container" as const, parentId: "shop" },
{ id: "orders", label: "Orders", type: "component" as const, parentId: "api" },
],
relationships: [
{ id: "uses", source: "buyer", target: "shop", label: "Uses" },
{ id: "contains-api", source: "shop", target: "api", label: "Contains" },
{ id: "contains-orders", source: "api", target: "orders", label: "Contains" },
],
};
test("C4 projects a common model into deterministic linked pages with stable identity", () => {
const before = structuredClone(MODEL);
const first = projectC4(MODEL);
assert.deepEqual(first, projectC4(MODEL));
assert.deepEqual(MODEL, before);
assert.deepEqual(first.pages.map((page) => page.id), ["c4-context", "c4-containers-shop", "c4-components-api"]);
assert.ok(first.pages[0].nodes.some((node) => node.id === "shop"));
assert.ok(first.pages[1].nodes.some((node) => node.id === "api"));
assert.ok(first.pages[2].nodes.some((node) => node.id === "orders"));
assert.deepEqual(first.pages[0].nodes.find((node) => node.id === "shop")?.provenance, { line: 2 });
assert.equal(first.pages[0].nodes.find((node) => node.id === "shop")?.properties?.drillDownPage, "c4-containers-shop");
assert.deepEqual(Object.fromEntries(MODEL.elements.map((element) => [
element.id,
first.pages.filter((page) => page.nodes.some((node) => node.id === element.id)).map((page) => page.id),
])), {
buyer: ["c4-context"],
shop: ["c4-context", "c4-containers-shop"],
api: ["c4-containers-shop", "c4-components-api"],
orders: ["c4-components-api"],
});
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);
});
test("C4 rejects empty and structurally invalid models", () => {
assert.throws(() => projectC4({ title: "Empty", elements: [], relationships: [] }), /element/i);
assert.throws(() => projectC4({ title: "Bad", elements: [{ id: "child", label: "Child", type: "component", parentId: "missing" }], relationships: [] }), /parent/i);
});
test("C4 rejects unsupported runtime element types", () => {
assert.throws(() => projectC4({
title: "Unsupported",
elements: [{ id: "queue", label: "Queue", type: "database" as never }],
relationships: [],
}), /unsupported.*type/i);
});
test("C4 enforces legal parent hierarchy and required parents", () => {
const invalid = [
[{ id: "system", label: "System", type: "system" as const, parentId: "person" }, { id: "person", label: "Person", type: "person" as const }],
[{ id: "person", label: "Person", type: "person" as const, parentId: "system" }, { id: "system", label: "System", type: "system" as const }],
[{ id: "container", label: "Container", type: "container" as const }],
[{ id: "person", label: "Person", type: "person" as const }, { id: "container", label: "Container", type: "container" as const, parentId: "person" }],
[{ id: "component", label: "Component", type: "component" as const }],
[{ id: "system", label: "System", type: "system" as const }, { id: "component", label: "Component", type: "component" as const, parentId: "system" }],
];
for (const elements of invalid) {
assert.throws(() => projectC4({ title: "Invalid", elements, relationships: [] }), /parent|hierarchy/i);
}
});
test("C4 rejects parent cycles", () => {
assert.throws(() => projectC4({
title: "Cycle",
elements: [
{ id: "container", label: "Container", type: "container", parentId: "component" },
{ id: "component", label: "Component", type: "component", parentId: "container" },
],
relationships: [],
}), /cycle/i);
});
test("C4 retains a person-to-container relationship on one projection without dangling endpoints", () => {
const model = {
title: "Cross-level",
elements: [
{ id: "operator", label: "Operator", type: "person" as const },
{ id: "platform", label: "Platform", type: "system" as const },
{ id: "console", label: "Console", type: "container" as const, parentId: "platform" },
],
relationships: [{ id: "administers", source: "operator", target: "console", label: "Administers" }],
};
const ir = projectC4(model);
assert.deepEqual(ir, projectC4(model));
const containingPages = ir.pages.filter((page) => page.edges.some((edge) => edge.id === "administers"));
assert.equal(containingPages.length, 1);
const page = containingPages[0];
const nodeIds = new Set(page.nodes.map((node) => node.id));
assert.ok(nodeIds.has("operator"));
assert.ok(nodeIds.has("console"));
assert.ok(page.edges.every((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target)));
});
test("C4 deterministically retains adversarial cross-level relationships with projected endpoints", () => {
const model = {
title: "Cross-level matrix",
elements: [
{ id: "operator", label: "Operator", type: "person" as const },
{ id: "platform", label: "Platform", type: "system" as const },
{ id: "api", label: "API", type: "container" as const, parentId: "platform" },
{ id: "ui", label: "UI", type: "component" as const, parentId: "api" },
{ id: "service", label: "Service", type: "component" as const, parentId: "api" },
],
relationships: [
{ id: "person-component", source: "operator", target: "ui" },
{ id: "system-component", source: "platform", target: "service" },
{ id: "container-system", source: "api", target: "platform" },
{ id: "component-component", source: "ui", target: "service" },
],
};
const first = projectC4(model);
assert.deepEqual(first, projectC4(model));
for (const relationship of model.relationships) {
const pages = first.pages.filter((page) => page.edges.some((edge) => edge.id === relationship.id));
assert.ok(pages.length >= 1, `${relationship.id} was dropped`);
assert.ok(pages.some((page) => {
const ids = new Set(page.nodes.map((node) => node.id));
return ids.has(relationship.source) && ids.has(relationship.target)
&& page.edges.every((edge) => ids.has(edge.source) && ids.has(edge.target));
}), `${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"/);
});
+121
View File
@@ -0,0 +1,121 @@
import type { DiagramEdge, DiagramIRV2, DiagramNode, DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export type C4ElementType = "person" | "system" | "container" | "component";
export interface C4Element { id: string; label: string; type: C4ElementType; parentId?: string; provenance?: Record<string, unknown>; properties?: Record<string, unknown> }
export interface C4Relationship { id: string; source: string; target: string; label?: string; provenance?: Record<string, unknown> }
export interface C4Model { title: string; elements: readonly C4Element[]; relationships: readonly C4Relationship[]; provenance?: Record<string, unknown> }
function cloneRecord(value: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
return value === undefined ? undefined : structuredClone(value);
}
export function projectC4(model: C4Model): DiagramIRV2 {
if (!model || !Array.isArray(model.elements) || model.elements.length === 0) throw new Error("C4 model requires at least one element");
if (!Array.isArray(model.relationships)) throw new Error("C4 relationships must be an array");
const validTypes = new Set<C4ElementType>(["person", "system", "container", "component"]);
for (const element of model.elements) {
if (!validTypes.has(element.type)) throw new Error(`C4 element ${element.id} has unsupported type: ${String(element.type)}`);
}
const byId = new Map(model.elements.map((element) => [element.id, element]));
if (byId.size !== model.elements.length) throw new Error("C4 element IDs must be unique");
for (const element of model.elements) {
if (element.parentId && !byId.has(element.parentId)) throw new Error(`C4 element ${element.id} has unknown parent ${element.parentId}`);
}
for (const element of model.elements) {
const seen = new Set<string>();
let current: C4Element | undefined = element;
while (current?.parentId) {
if (seen.has(current.id)) throw new Error(`C4 element ${element.id} participates in a parent cycle`);
seen.add(current.id);
current = byId.get(current.parentId);
}
}
for (const element of model.elements) {
const parent = element.parentId ? byId.get(element.parentId) : undefined;
if ((element.type === "person" || element.type === "system") && element.parentId) {
throw new Error(`C4 ${element.type} ${element.id} must not have a parent`);
}
if (element.type === "container" && parent?.type !== "system") {
throw new Error(`C4 container ${element.id} requires a system parent`);
}
if (element.type === "component" && parent?.type !== "container") {
throw new Error(`C4 component ${element.id} requires a container parent`);
}
}
for (const relationship of model.relationships) {
if (!byId.has(relationship.source) || !byId.has(relationship.target)) throw new Error(`C4 relationship ${relationship.id} has an unknown endpoint`);
}
const systems = model.elements.filter((element) => element.type === "system");
const containers = model.elements.filter((element) => element.type === "container");
const pageFor = (prefix: string, id: string) => `${prefix}-${id}`;
const toNode = (element: C4Element, included: Set<string>): DiagramNode => {
const drillDownPage = element.type === "system" && containers.some((item) => item.parentId === element.id)
? pageFor("c4-containers", element.id)
: element.type === "container" && model.elements.some((item) => item.type === "component" && item.parentId === element.id)
? pageFor("c4-components", element.id) : undefined;
const isContainer = model.elements.some((child) => child.parentId === element.id && included.has(child.id));
return {
id: element.id,
label: element.label,
kind: isContainer ? "container" : `c4-${element.type}`,
...(element.parentId && included.has(element.parentId) ? { parentId: element.parentId } : {}),
// Elements shown with their children take the serializer's swimlane default so the label sits in a header band.
...(isContainer ? {} : { style: "rounded=0;whiteSpace=wrap;html=1" }),
properties: { ...cloneRecord(element.properties), c4Type: element.type, ...(drillDownPage ? { drillDownPage } : {}) },
provenance: cloneRecord(element.provenance),
};
};
const makePage = (id: string, title: string, elements: C4Element[]): DiagramPage => {
const included = new Set(elements.map((element) => element.id));
const edges: DiagramEdge[] = model.relationships.filter((edge) => included.has(edge.source) && included.has(edge.target)).map((edge) => ({
id: edge.id, source: edge.source, target: edge.target, label: edge.label, kind: "c4-relationship", provenance: cloneRecord(edge.provenance),
}));
return { id, title, nodes: elements.map((element) => toNode(element, included)), edges, layout: { type: "layered", direction: "horizontal", gridSize: 10, nodeGap: 40, layerGap: 80 }, properties: { c4Level: id.split("-")[1] } };
};
const context = model.elements.filter((element) => element.type === "person" || element.type === "system");
const pageSpecs: Array<{ id: string; title: string; elements: C4Element[] }> = [
{ id: "c4-context", title: `${model.title} — Context`, elements: context.length ? context : model.elements.slice(0, 1) },
];
for (const system of systems) {
const children = containers.filter((element) => element.parentId === system.id);
if (children.length) {
const childIds = new Set(children.map((element) => element.id));
const relatedPeople = model.elements.filter((element) => element.type === "person" && model.relationships.some((relationship) =>
(relationship.source === element.id && childIds.has(relationship.target))
|| (relationship.target === element.id && childIds.has(relationship.source))));
pageSpecs.push({ id: pageFor("c4-containers", system.id), title: `${system.label} — Containers`, elements: [system, ...children, ...relatedPeople] });
}
}
for (const container of containers) {
const children = model.elements.filter((element) => element.type === "component" && element.parentId === container.id);
if (children.length) pageSpecs.push({ id: pageFor("c4-components", container.id), title: `${container.label} — Components`, elements: [container, ...children] });
}
// Cross-level relationships land on the page that natively shows their deepest endpoint (a container's
// relationship to a system belongs on the containers page, not on the component drill-down).
const levelOf = (type: C4ElementType): number => type === "component" ? 2 : type === "container" ? 1 : 0;
const pageLevel = (id: string): number => levelOf(id.startsWith("c4-components-") ? "component" : id.startsWith("c4-containers-") ? "container" : "system");
for (const relationship of model.relationships) {
if (pageSpecs.some((page) => page.elements.some((element) => element.id === relationship.source)
&& page.elements.some((element) => element.id === relationship.target))) continue;
const [source, target] = [byId.get(relationship.source)!, byId.get(relationship.target)!];
const deepest = levelOf(target.type) > levelOf(source.type) ? target : source;
const candidates = pageSpecs.filter((page) => page.elements.some((element) =>
element.id === relationship.source || element.id === relationship.target));
const page = candidates.find((candidate) => pageLevel(candidate.id) === levelOf(deepest.type) && candidate.elements.some((element) => element.id === deepest.id))
?? candidates.at(-1) ?? pageSpecs[0];
const included = new Set(page.elements.map((element) => element.id));
for (const endpoint of [relationship.source, relationship.target]) {
if (!included.has(endpoint)) {
page.elements.push(byId.get(endpoint)!);
included.add(endpoint);
}
}
}
const pages = pageSpecs.map((page) => makePage(page.id, page.title, page.elements));
const ir: DiagramIRV2 = { version: 2, title: model.title, pages, provenance: cloneRecord(model.provenance), properties: { profile: "c4" } };
return validateDiagramIR(ir) as DiagramIRV2;
}
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
import { compressExecutiveView } from "./compression.js";
const PAGE: DiagramPage = { id: "platform", title: "Platform", nodes: [{ id: "a", label: "A", provenance: { source: "a.yaml" } }, { id: "b", label: "B" }, { id: "c", label: "C" }, { id: "d", label: "D" }], edges: [{ id: "ab", source: "a", target: "b" }, { id: "bc", source: "b", target: "c" }, { id: "cd", source: "c", target: "d" }] };
test("compression deterministically clusters topology into linked summary and full pages", () => {
const before = structuredClone(PAGE); const ir = compressExecutiveView(PAGE, { maxClusterSize: 2 });
assert.deepEqual(ir, compressExecutiveView(PAGE, { maxClusterSize: 2 })); assert.deepEqual(PAGE, before); assert.equal(validateDiagramIR(ir), ir);
assert.deepEqual(ir.pages.map((p) => p.id), ["executive-summary", "platform-full"]);
assert.deepEqual(ir.pages[0].nodes.map((n) => n.properties?.memberIds), [["a", "b"], ["c", "d"]]);
assert.equal(ir.pages[0].nodes[0].properties?.drillDownPage, "platform-full");
assert.equal(ir.pages[1].nodes[0].provenance?.source, "a.yaml");
assert.equal(ir.pages[0].edges.length, 1);
});
test("compression rejects invalid and empty pages", () => {
assert.throws(() => compressExecutiveView({ id: "empty", title: "Empty", nodes: [], edges: [] }), /non-empty/i);
assert.throws(() => compressExecutiveView({ id: "bad", title: "Bad", nodes: [{ id: "a", label: "A" }], edges: [{ id: "e", source: "a", target: "missing" }] }), /unknown target/i);
});
@@ -0,0 +1,36 @@
import type { DiagramEdge, DiagramIRV2, DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export interface CompressionOptions { maxClusterSize?: number }
export function compressExecutiveView(page: DiagramPage, options: CompressionOptions = {}): DiagramIRV2 {
validateDiagramIR({ version: 2, pages: [structuredClone(page)] });
const maxSize = options.maxClusterSize ?? 6;
if (!Number.isInteger(maxSize) || maxSize < 1) throw new Error("maxClusterSize must be a positive integer");
const nodeById = new Map(page.nodes.map((node) => [node.id, node]));
const adjacency = new Map(page.nodes.map((node) => [node.id, new Set<string>()]));
for (const edge of page.edges) { adjacency.get(edge.source)!.add(edge.target); adjacency.get(edge.target)!.add(edge.source); }
const remaining = new Set([...nodeById.keys()].sort());
const clusters: string[][] = [];
while (remaining.size) {
const seed = [...remaining][0]; const queue = [seed]; const members: string[] = [];
while (queue.length && members.length < maxSize) {
const id = queue.shift()!; if (!remaining.delete(id)) continue; members.push(id);
const neighbors = [...adjacency.get(id)!].filter((item) => remaining.has(item)).sort();
queue.push(...neighbors);
}
members.sort(); clusters.push(members);
}
const fullPageId = `${page.id}-full`;
const clusterByNode = new Map<string, number>(); clusters.forEach((members, index) => members.forEach((id) => clusterByNode.set(id, index)));
const summaryNodes = clusters.map((members, index) => ({ id: `cluster-${String(index + 1).padStart(3, "0")}`, label: members.map((id) => nodeById.get(id)!.label).join(" + "), kind: "executive-cluster", properties: { memberIds: members, memberCount: members.length, drillDownPage: fullPageId }, provenance: { sources: members.map((id) => nodeById.get(id)!.provenance ?? {}).filter((value) => Object.keys(value).length > 0) } }));
const aggregate = new Map<string, { source: number; target: number; edgeIds: string[] }>();
for (const edge of [...page.edges].sort((a, b) => a.id.localeCompare(b.id))) {
const source = clusterByNode.get(edge.source)!; const target = clusterByNode.get(edge.target)!; if (source === target) continue;
const key = `${source}:${target}`; const item = aggregate.get(key) ?? { source, target, edgeIds: [] }; item.edgeIds.push(edge.id); aggregate.set(key, item);
}
const summaryEdges: DiagramEdge[] = [...aggregate.values()].sort((a, b) => a.source - b.source || a.target - b.target).map((item, index) => ({ id: `cluster-link-${String(index + 1).padStart(3, "0")}`, source: summaryNodes[item.source].id, target: summaryNodes[item.target].id, kind: "executive-aggregate", properties: { edgeIds: item.edgeIds, edgeCount: item.edgeIds.length } }));
const full: DiagramPage = structuredClone(page); full.id = fullPageId; full.properties = { ...(full.properties ?? {}), profile: "compression-full", summaryPage: "executive-summary" };
const ir: DiagramIRV2 = { version: 2, title: page.title, pages: [{ id: "executive-summary", title: `${page.title} — Executive Summary`, nodes: summaryNodes, edges: summaryEdges, layout: { type: "layered", direction: "horizontal", gridSize: 10, nodeGap: 40, layerGap: 80 }, properties: { profile: "executive-compression", fullPage: fullPageId } }, full], properties: { profile: "executive-compression", algorithm: "deterministic-topology-bfs" } };
return validateDiagramIR(ir) as DiagramIRV2;
}
@@ -0,0 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as profiles from "./index.js";
test("specialized profile barrel exposes all seven native profiles", () => {
assert.deepEqual(Object.keys(profiles).filter((key) => key.startsWith("create") || key.startsWith("project") || key.startsWith("compress")).sort(), ["compressExecutiveView", "createArchitectureTimelapse", "createBuildup", "createRunbookHtml", "createSequenceDiagram", "createTubeMap", "projectC4"]);
});
+14
View File
@@ -0,0 +1,14 @@
export { projectC4 } from "./c4.js";
export type { C4Element, C4ElementType, C4Model, C4Relationship } from "./c4.js";
export { createSequenceDiagram } from "./sequence.js";
export type { SequenceInput, SequenceMessage, SequenceParticipant } from "./sequence.js";
export { createTubeMap } from "./tube-map.js";
export type { TubeLine, TubeMapInput, TubeStation } from "./tube-map.js";
export { compressExecutiveView } from "./compression.js";
export type { CompressionOptions } from "./compression.js";
export { createRunbookHtml } from "./runbook.js";
export type { RunbookChoice, RunbookGraph, RunbookNode } from "./runbook.js";
export { createArchitectureTimelapse } from "./timelapse.js";
export type { ArchitectureSnapshot, ArchitectureTimelapse, ChangeSet, TimelapseFrame } from "./timelapse.js";
export { createBuildup } from "./buildup.js";
export type { BuildupResult } from "./buildup.js";
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createRunbookHtml } from "./runbook.js";
const GRAPH = { title: "Ops <Runbook>", startId: "start", nodes: [{ id: "start", label: "Is it healthy? </script><img src=x onerror=alert(1)>", choices: [{ label: "No & investigate", target: "check" }] }, { id: "check", label: "Check logs", choices: [{ label: "Retry", target: "start" }] }] };
test("runbook HTML is deterministic, self-contained and escapes malicious labels", () => {
const before = structuredClone(GRAPH); const html = createRunbookHtml(GRAPH);
assert.equal(html, createRunbookHtml(GRAPH)); assert.deepEqual(GRAPH, before);
assert.ok(html.includes("default-src 'none'")); assert.ok(html.includes("Breadcrumb")); assert.ok(html.includes("Back")); assert.ok(html.includes("Restart")); assert.ok(html.includes("keydown"));
assert.ok(html.includes("border-radius:0")); assert.equal(/border-radius:(?!0(?:[;}]))/.test(html), false);
assert.equal(html.includes("</script><img"), false); assert.equal(html.includes("onerror=alert"), false); assert.equal(/(?:src|href)=["']https?:/i.test(html), false);
});
test("runbook rejects empty graphs and unknown targets", () => {
assert.throws(() => createRunbookHtml({ title: "Empty", startId: "x", nodes: [] }), /node/i);
assert.throws(() => createRunbookHtml({ title: "Bad", startId: "a", nodes: [{ id: "a", label: "A", choices: [{ label: "Go", target: "b" }] }] }), /target/i);
});
+19
View File
@@ -0,0 +1,19 @@
export interface RunbookChoice { label: string; target: string }
export interface RunbookNode { id: string; label: string; detail?: string; choices?: readonly RunbookChoice[] }
export interface RunbookGraph { title: string; startId: string; nodes: readonly RunbookNode[] }
function stripMarkup(value: string): string { return value.replace(/<[^>]*>/g, "").replace(/on\w+\s*=\s*/gi, ""); }
function escapeHtml(value: string): string { return stripMarkup(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;"); }
function safeJson(value: unknown): string { return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); }
export function createRunbookHtml(graph: RunbookGraph): string {
if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) throw new Error("Runbook requires at least one node");
const nodes: readonly RunbookNode[] = graph.nodes;
const ids = new Set(nodes.map((node) => node.id));
if (ids.size !== nodes.length) throw new Error("Runbook node IDs must be unique");
if (!ids.has(graph.startId)) throw new Error("Runbook startId references an unknown node");
for (const node of nodes) for (const choice of node.choices ?? []) if (!ids.has(choice.target)) throw new Error(`Runbook choice has unknown target ${choice.target}`);
const data = { title: stripMarkup(graph.title), startId: graph.startId, nodes: nodes.map((node) => ({ id: node.id, label: stripMarkup(node.label), detail: node.detail === undefined ? undefined : stripMarkup(node.detail), choices: (node.choices ?? []).map((choice) => ({ label: stripMarkup(choice.label), target: choice.target })) })) };
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:"><title>${escapeHtml(graph.title)}</title><style>body{font:16px system-ui;margin:0;background:#f4f6f8;color:#17202a}main{max-width:760px;margin:3rem auto;padding:2rem;background:white;border-radius:0}button{display:block;width:100%;margin:.75rem 0;padding:1rem;text-align:left}nav button{display:inline-block;width:auto;margin-right:.5rem}#crumbs{margin:1rem 0;color:#566573}</style></head><body><main><h1>${escapeHtml(graph.title)}</h1><div id="crumbs" aria-label="Breadcrumb"></div><section aria-live="polite"><h2 id="label"></h2><p id="detail"></p><div id="choices"></div></section><nav><button id="back" type="button">Back</button><button id="restart" type="button">Restart</button></nav></main><script id="runbook-data" type="application/json">${safeJson(data)}</script><script>(()=>{'use strict';const d=JSON.parse(document.getElementById('runbook-data').textContent);const byId=new Map(d.nodes.map(n=>[n.id,n]));const history=[];let current=d.startId;const label=document.getElementById('label'),detail=document.getElementById('detail'),choices=document.getElementById('choices'),crumbs=document.getElementById('crumbs');function render(){const n=byId.get(current);label.textContent=n.label;detail.textContent=n.detail||'';crumbs.textContent='Breadcrumb: '+[...history,current].map(id=>byId.get(id).label).join(' › ');choices.replaceChildren();n.choices.forEach((c,i)=>{const b=document.createElement('button');b.type='button';b.textContent=(i+1)+'. '+c.label;b.addEventListener('click',()=>go(c.target));choices.appendChild(b)});document.getElementById('back').disabled=!history.length}function go(id){history.push(current);current=id;render()}function back(){if(history.length){current=history.pop();render()}}function restart(){history.length=0;current=d.startId;render()}document.getElementById('back').addEventListener('click',back);document.getElementById('restart').addEventListener('click',restart);document.addEventListener('keydown',e=>{if(e.key==='Escape'||e.key==='ArrowLeft')back();else if(e.key.toLowerCase()==='r')restart();else if(/^\\d$/.test(e.key)){const n=byId.get(current),c=n.choices[Number(e.key)-1];if(c)go(c.target)}});render()})();</script></body></html>`;
}
@@ -0,0 +1,167 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { run as validateConnectors } from "../../actions/page-connectors-validation/action.js";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
import { createSequenceDiagram } from "./sequence.js";
const INPUT = {
title: "Checkout",
participants: [{ id: "web", label: "Web" }, { id: "api", label: "API" }, { id: "db", label: "DB" }],
messages: [
{ id: "m1", from: "web", to: "api", label: "submit", type: "call" as const },
{ id: "m2", from: "api", to: "db", label: "save", type: "call" as const },
{ id: "m3", from: "db", to: "api", label: "ok", type: "return" as const },
],
};
test("sequence profile emits deterministic non-overlapping lifelines and activation metadata", () => {
const before = structuredClone(INPUT);
const ir = createSequenceDiagram(INPUT);
assert.deepEqual(ir, createSequenceDiagram(INPUT));
assert.deepEqual(INPUT, before);
assert.equal(validateDiagramIR(ir), ir);
const nodes = ir.pages[0].nodes.filter((node) => node.kind === "sequence-participant");
for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i].geometry!; const b = nodes[j].geometry!;
assert.equal(a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y, false);
}
assert.deepEqual(nodes.find((node) => node.id === "api")?.properties?.activations, [{ start: 1, end: 3, depth: 1 }]);
assert.deepEqual(ir.pages[0].edges.map((edge) => edge.properties?.sequence), [1, 2, 3]);
assert.deepEqual(ir.pages[0].edges.map((edge) => edge.properties?.activationDepth), [1, 1, 1]);
});
test("sequence profile rejects empty participants and unknown message endpoints", () => {
assert.throws(() => createSequenceDiagram({ title: "Empty", participants: [], messages: [] }), /participant/i);
assert.throws(() => createSequenceDiagram({ title: "Bad", participants: [{ id: "a", label: "A" }], messages: [{ id: "m", from: "a", to: "b", label: "bad" }] }), /endpoint/i);
});
test("sequence profile rejects returns that do not match the top active caller and callee", () => {
const participants = [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }];
assert.throws(() => createSequenceDiagram({
title: "Mismatched",
participants,
messages: [
{ id: "one", from: "a", to: "b", label: "one", type: "call" },
{ id: "two", from: "b", to: "c", label: "two", type: "call" },
{ id: "bad", from: "b", to: "a", label: "bad", type: "return" },
],
}), /return.*top active call/i);
assert.throws(() => createSequenceDiagram({
title: "Orphan",
participants,
messages: [{ id: "bad", from: "b", to: "a", label: "bad", type: "return" }],
}), /return.*top active call/i);
});
test("sequence output contains editable dashed lifelines, activation bars, and 120x80 headers", () => {
const ir = createSequenceDiagram(INPUT);
const headers = ir.pages[0].nodes.filter((node) => node.kind === "sequence-participant");
assert.equal(headers.length, INPUT.participants.length);
for (const header of headers) {
assert.ok(header.geometry!.width >= 120);
assert.ok(header.geometry!.height >= 80);
assert.match(header.style ?? "", /rounded=0/);
}
const lifelines = ir.pages[0].nodes.filter((node) => node.kind === "sequence-lifeline");
assert.equal(lifelines.length, INPUT.participants.length);
assert.ok(lifelines.every((node) => /dashed=1/.test(node.style ?? "")));
const activations = ir.pages[0].nodes.filter((node) => node.kind === "sequence-activation");
assert.ok(activations.length > 0);
assert.ok(activations.every((node) => /rounded=0/.test(node.style ?? "")));
const xml = diagramIRToDrawio(ir);
for (const participant of INPUT.participants) assert.match(xml, new RegExp(`id="${participant.id}-lifeline"[^>]*dashed=1`));
assert.match(xml, /id="api-activation-1"[^>]*vertex="1"/);
});
test("sequence messages avoid connector-shape overlaps in rendered editable output", () => {
const dir = mkdtempSync(join(tmpdir(), "sequence-routing-"));
const file = join(dir, "sequence.drawio");
try {
writeFileSync(file, diagramIRToDrawio(createSequenceDiagram(INPUT)), "utf8");
const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number } };
assert.equal(validation.summary.connectorShapeOverlaps, 0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("sequence page width includes the deepest nested activation geometry", () => {
const nestedCalls = Array.from({ length: 15 }, (_, index) => ({ id: `nested-${index + 1}`, from: "worker", to: "worker", label: "recurse", type: "call" as const }));
const nestedReturns = Array.from({ length: 15 }, (_, index) => ({ id: `nested-return-${index + 1}`, from: "worker", to: "worker", label: "return", type: "return" as const }));
const ir = createSequenceDiagram({
title: "Deep recursion",
participants: [{ id: "caller", label: "Caller" }, { id: "worker", label: "Worker" }],
messages: [
{ id: "start", from: "caller", to: "worker", label: "start", type: "call" },
...nestedCalls,
...nestedReturns,
{ id: "done", from: "worker", to: "caller", label: "done", type: "return" },
],
});
const deepestRight = Math.max(...ir.pages[0].nodes
.filter((node) => node.kind === "sequence-activation")
.map((node) => node.geometry!.x + node.geometry!.width));
assert.equal(deepestRight, 455);
assert.ok(ir.pages[0].width! >= deepestRight + 40);
});
test("sequence page width includes nested self-call waypoint extents plus margin", () => {
const calls = Array.from({ length: 15 }, (_, index) => ({
id: `self-${index + 1}`, from: "worker", to: "worker", label: "recurse", type: "call" as const,
}));
const returns = Array.from({ length: 15 }, (_, index) => ({
id: `return-${index + 1}`, from: "worker", to: "worker", label: "return", type: "return" as const,
}));
const ir = createSequenceDiagram({
title: "Waypoint-aware recursion",
participants: [{ id: "caller", label: "Caller" }, { id: "worker", label: "Worker" }],
messages: [
{ id: "start", from: "caller", to: "worker", label: "start", type: "call" },
...calls,
...returns,
{ id: "done", from: "worker", to: "caller", label: "done", type: "return" },
],
});
const waypointRight = Math.max(...ir.pages[0].edges.flatMap((edge) => edge.waypoints?.map((point) => point.x) ?? []));
assert.equal(waypointRight, 530);
assert.ok(ir.pages[0].width! >= waypointRight + 40);
});
test("self-messages loop on the right of the lifeline and never cross the activation bar", () => {
const input = {
title: "Login",
participants: [{ id: "u", label: "User" }, { id: "w", label: "Web" }, { id: "a", label: "Auth" }],
messages: [
{ id: "m1", from: "u", to: "w", label: "login" },
{ id: "m2", from: "w", to: "a", label: "verify" },
{ id: "m3", from: "a", to: "a", label: "hash" },
{ id: "m4", from: "a", to: "a", label: "done", type: "return" as const },
{ id: "m5", from: "a", to: "w", label: "ok", type: "return" as const },
{ id: "m6", from: "w", to: "u", label: "token", type: "return" as const },
],
};
const ir = createSequenceDiagram(input);
const lifelineX = 40 + 2 * 200 + 60;
for (const id of ["m3", "m4"]) {
const source = ir.pages[0].nodes.find((node) => node.id === `${id}-source-anchor`)!.geometry!;
const target = ir.pages[0].nodes.find((node) => node.id === `${id}-target-anchor`)!.geometry!;
const edge = ir.pages[0].edges.find((edge) => edge.id === id)!;
assert.ok(source.x > lifelineX && target.x > lifelineX, `${id} anchors must both sit right of the lifeline`);
assert.equal(target.y, source.y + 30, `${id} returns 30px below its departure`);
assert.ok(edge.waypoints!.every((point) => point.x > lifelineX), `${id} waypoints stay on the right side`);
}
const dir = mkdtempSync(join(tmpdir(), "sequence-self-message-"));
const file = join(dir, "self.drawio");
try {
writeFileSync(file, diagramIRToDrawio(ir), "utf8");
const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number }; issues: unknown[] };
assert.equal(validation.summary.connectorShapeOverlaps, 0, JSON.stringify(validation.issues));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+98
View File
@@ -0,0 +1,98 @@
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export interface SequenceParticipant { id: string; label: string; provenance?: Record<string, unknown> }
export interface SequenceMessage { id: string; from: string; to: string; label: string; type?: "call" | "return" | "async"; provenance?: Record<string, unknown> }
export interface SequenceInput { title: string; participants: readonly SequenceParticipant[]; messages: readonly SequenceMessage[]; provenance?: Record<string, unknown> }
interface Activation { start: number; end: number; depth: number }
export function createSequenceDiagram(input: SequenceInput): DiagramIRV2 {
if (!input || !Array.isArray(input.participants) || input.participants.length === 0) throw new Error("Sequence diagram requires participants");
if (!Array.isArray(input.messages)) throw new Error("Sequence messages must be an array");
const ids = new Set(input.participants.map((participant) => participant.id));
if (ids.size !== input.participants.length) throw new Error("Sequence participant IDs must be unique");
for (const message of input.messages) if (!ids.has(message.from) || !ids.has(message.to)) throw new Error(`Sequence message ${message.id} has unknown endpoint`);
const open = new Map<string, Array<{ start: number; depth: number }>>();
const activeCalls: Array<{ caller: string; callee: string }> = [];
const activations = new Map<string, Activation[]>(input.participants.map((p) => [p.id, []]));
const messageDepths: number[] = [];
input.messages.forEach((message, index) => {
const sequence = index + 1;
if ((message.type ?? "call") === "return") {
const expected = activeCalls.at(-1);
if (!expected || message.from !== expected.callee || message.to !== expected.caller) {
throw new Error(`Sequence return ${message.id} must match the top active call's original callee and caller`);
}
activeCalls.pop();
const stack = open.get(message.from) ?? [];
const active = stack.pop();
messageDepths.push(active?.depth ?? 0);
if (active) activations.get(message.from)!.push({ ...active, end: sequence });
} else if (message.type === "call" || message.type === undefined) {
const stack = open.get(message.to) ?? [];
const depth = stack.length + 1;
stack.push({ start: sequence, depth });
activeCalls.push({ caller: message.from, callee: message.to });
messageDepths.push(depth);
open.set(message.to, stack);
} else {
messageDepths.push(0);
}
});
for (const [participant, stack] of open) for (const active of stack) activations.get(participant)!.push({ ...active, end: input.messages.length || 1 });
for (const list of activations.values()) list.sort((a, b) => a.start - b.start || a.depth - b.depth);
const xById = new Map<string, number>();
const participantNodes = input.participants.map((participant, index) => {
const x = 40 + index * 200; xById.set(participant.id, x);
return { id: participant.id, label: participant.label, kind: "sequence-participant", geometry: { x, y: 40, width: 120, height: 80 }, style: "rounded=0;whiteSpace=wrap;html=1", properties: { lifelineX: x + 60, lifelineStart: 120, lifelineEnd: 180 + input.messages.length * 60, activations: activations.get(participant.id)! }, provenance: participant.provenance === undefined ? undefined : structuredClone(participant.provenance) };
});
const lifelineNodes = input.participants.map((participant) => ({
id: `${participant.id}-lifeline`, label: "\u200B", kind: "sequence-lifeline",
geometry: { x: xById.get(participant.id)! + 59, y: 120, width: 2, height: 60 + input.messages.length * 60 },
style: "shape=line;direction=south;dashed=1;dashPattern=8 8;strokeWidth=2;rounded=0;html=1",
properties: { participantId: participant.id },
}));
const activationNodes = input.participants.flatMap((participant) => activations.get(participant.id)!.map((activation, index) => ({
id: `${participant.id}-activation-${index + 1}`, label: "\u200B", kind: "sequence-activation",
geometry: { x: xById.get(participant.id)! + 55 + (activation.depth - 1) * 10, y: 140 + (activation.start - 1) * 60, width: 10, height: Math.max(20, (activation.end - activation.start) * 60) },
style: "rounded=0;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#000000",
properties: { participantId: participant.id, ...activation },
})));
const maxDepthById = new Map(input.participants.map((participant) => [participant.id, Math.max(0, ...activations.get(participant.id)!.map((activation) => activation.depth))]));
const anchorNodes = input.messages.flatMap((message, index) => {
const y = 140 + index * 60;
const fromX = xById.get(message.from)! + 60;
const toX = xById.get(message.to)! + 60;
const direction = Math.sign(toX - fromX) || 1;
const self = message.from === message.to;
const clearX = (id: string, centerX: number, side: number) => centerX + side * (10 + maxDepthById.get(id)! * 10);
const sourceX = clearX(message.from, fromX, direction);
// A self-message loops out to the right and returns 30px lower on the same side, so its target
// anchor sits right of the lifeline too; otherwise the return leg would cross the activation bar.
const targetX = clearX(message.to, toX, self ? direction : -direction);
const targetY = self ? y + 30 : y;
return [
{ id: `${message.id}-source-anchor`, label: "\u200B", kind: "sequence-message-anchor", geometry: { x: sourceX - 1, y: y - 1, width: 2, height: 2 }, style: "opacity=0;fillOpacity=0;strokeOpacity=0;connectable=1", properties: { participantId: message.from, messageId: message.id, role: "source" } },
{ id: `${message.id}-target-anchor`, label: "\u200B", kind: "sequence-message-anchor", geometry: { x: targetX - 1, y: 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 edges = input.messages.map((message, index) => {
const y = 140 + index * 60;
const fromX = xById.get(message.from)! + 60;
const toX = xById.get(message.to)! + 60;
const direction = Math.sign(toX - fromX) || 1;
const sourceX = fromX + direction * (10 + maxDepthById.get(message.from)! * 10);
const targetX = toX - direction * (10 + maxDepthById.get(message.to)! * 10);
const waypoints = message.from === message.to ? [{ x: sourceX + 60, y }, { x: sourceX + 60, y: y + 30 }] : [{ x: sourceX, y }, { x: targetX, y }];
return { id: message.id, source: `${message.id}-source-anchor`, target: `${message.id}-target-anchor`, label: message.label, kind: `sequence-${message.type ?? "call"}`, waypoints, properties: { sequence: index + 1, activationDepth: messageDepths[index], semanticSource: message.from, semanticTarget: message.to }, provenance: message.provenance === undefined ? undefined : structuredClone(message.provenance) };
});
const activationRight = Math.max(0, ...activationNodes.map((node) => node.geometry.x + node.geometry.width));
const waypointRight = Math.max(0, ...edges.flatMap((edge) => edge.waypoints.map((point) => point.x)));
const contentRight = Math.max(activationRight, waypointRight);
const pageWidth = Math.max(320, 200 * input.participants.length, Math.ceil((contentRight + 40) / 10) * 10);
const ir: DiagramIRV2 = { version: 2, title: input.title, pages: [{ id: "sequence", title: input.title, nodes, edges, layout: { type: "manual", gridSize: 10 }, width: pageWidth, height: 240 + 60 * input.messages.length, properties: { profile: "sequence" } }], provenance: input.provenance === undefined ? undefined : structuredClone(input.provenance), properties: { profile: "sequence" } };
return validateDiagramIR(ir) as DiagramIRV2;
}
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
import { createArchitectureTimelapse } from "./timelapse.js";
const one: DiagramIRV2 = { version: 2, title: "Arch", pages: [{ id: "p", title: "P", nodes: [{ id: "a", label: "A", provenance: { source: "model" } }, { id: "b", label: "B" }], edges: [{ id: "ab", source: "a", target: "b" }] }] };
const two: DiagramIRV2 = { version: 2, title: "Arch", pages: [{ id: "p", title: "P", nodes: [{ id: "a", label: "A2", provenance: { source: "model" } }, { id: "c", label: "C" }], edges: [{ id: "ac", source: "a", target: "c" }] }] };
test("timelapse emits valid immutable frames with stable change classifications", () => {
const input = [{ id: "v1", label: "Before", ir: one }, { id: "v2", label: "After", ir: two }]; const before = structuredClone(input);
const result = createArchitectureTimelapse(input); assert.deepEqual(result, createArchitectureTimelapse(input)); assert.deepEqual(input, before);
result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame.ir), frame.ir));
assert.deepEqual(result.frames[1].changes.nodes, { added: ["p/c"], removed: ["p/b"], modified: ["p/a"], unchanged: [] });
assert.deepEqual(result.frames[1].changes.edges, { added: ["p/ac"], removed: ["p/ab"], modified: [], unchanged: [] });
assert.equal(result.frames[1].ir.pages[0].nodes[0].provenance?.source, "model");
});
test("timelapse rejects empty and invalid snapshots", () => {
assert.throws(() => createArchitectureTimelapse([]), /snapshot/i);
assert.throws(() => createArchitectureTimelapse([{ id: "bad", label: "Bad", ir: { version: 2, pages: [] } as DiagramIRV2 }]), /non-empty/i);
});
@@ -0,0 +1,37 @@
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export interface ArchitectureSnapshot { id: string; label: string; ir: DiagramIRV2; provenance?: Record<string, unknown> }
export interface ChangeSet { added: string[]; removed: string[]; modified: string[]; unchanged: string[] }
export interface TimelapseFrame { id: string; label: string; index: number; ir: DiagramIRV2; changes: { nodes: ChangeSet; edges: ChangeSet }; provenance?: Record<string, unknown> }
export interface ArchitectureTimelapse { frames: TimelapseFrame[] }
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") return `{${Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
return JSON.stringify(value);
}
function inventory(ir: DiagramIRV2, kind: "nodes" | "edges"): Map<string, unknown> {
return new Map(ir.pages.flatMap((page) => page[kind].map((item) => [`${page.id}/${item.id}`, item] as const)));
}
function classify(previous: Map<string, unknown>, current: Map<string, unknown>): ChangeSet {
const added: string[] = [], removed: string[] = [], modified: string[] = [], unchanged: string[] = [];
for (const id of [...current.keys()].sort()) {
if (!previous.has(id)) added.push(id); else if (canonical(previous.get(id)) === canonical(current.get(id))) unchanged.push(id); else modified.push(id);
}
for (const id of [...previous.keys()].sort()) if (!current.has(id)) removed.push(id);
return { added, removed, modified, unchanged };
}
export function createArchitectureTimelapse(snapshots: readonly ArchitectureSnapshot[]): ArchitectureTimelapse {
if (!Array.isArray(snapshots) || snapshots.length === 0) throw new Error("Timelapse requires at least one snapshot");
const ids = new Set(snapshots.map((snapshot) => snapshot.id)); if (ids.size !== snapshots.length) throw new Error("Snapshot IDs must be unique");
let previousNodes = new Map<string, unknown>(), previousEdges = new Map<string, unknown>();
const frames = snapshots.map((snapshot, index) => {
const ir = structuredClone(snapshot.ir); validateDiagramIR(ir);
const currentNodes = inventory(ir, "nodes"), currentEdges = inventory(ir, "edges");
const frame: TimelapseFrame = { id: snapshot.id, label: snapshot.label, index, ir, changes: { nodes: classify(previousNodes, currentNodes), edges: classify(previousEdges, currentEdges) }, provenance: snapshot.provenance === undefined ? undefined : structuredClone(snapshot.provenance) };
previousNodes = currentNodes; previousEdges = currentEdges; return frame;
});
return { frames };
}
@@ -0,0 +1,149 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { run as validateConnectors } from "../../actions/page-connectors-validation/action.js";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
import { createTubeMap } from "./tube-map.js";
const MAP = { title: "Transit", stations: [{ id: "a", label: "A" }, { id: "x", label: "Exchange" }, { id: "b", label: "B" }, { id: "c", label: "C" }], lines: [{ id: "red", label: "Red", color: "#e53935", stations: ["a", "x", "b"] }, { id: "blue", label: "Blue", color: "#1e88e5", stations: ["c", "x"] }] };
test("tube map is deterministic, grid-aligned and octilinear with interchange semantics", () => {
const before = structuredClone(MAP); const ir = createTubeMap(MAP);
assert.deepEqual(ir, createTubeMap(MAP)); assert.deepEqual(MAP, before); assert.equal(validateDiagramIR(ir), ir);
const x = ir.pages[0].nodes.find((node) => node.id === "x")!;
assert.equal(x.properties?.interchange, true); assert.deepEqual(x.properties?.lines, ["blue", "red"]);
const centers = new Map(ir.pages[0].nodes.map((n) => [n.id, { x: n.geometry!.x + n.geometry!.width / 2, y: n.geometry!.y + n.geometry!.height / 2 }]));
for (const edge of ir.pages[0].edges) {
const points = [centers.get(edge.source)!, ...(edge.waypoints ?? []), centers.get(edge.target)!];
for (let i = 1; i < points.length; i++) { const dx = Math.abs(points[i].x - points[i - 1].x); const dy = Math.abs(points[i].y - points[i - 1].y); assert.ok(dx === 0 || dy === 0 || dx === dy); }
}
});
test("tube map rejects empty and invalid routes", () => {
assert.throws(() => createTubeMap({ title: "Empty", stations: [], lines: [] }), /station/i);
assert.throws(() => createTubeMap({ title: "Bad", stations: [{ id: "a", label: "A" }], lines: [{ id: "l", label: "L", color: "red", stations: ["a", "z"] }] }), /unknown station/i);
});
test("tube map rejects colors that could inject draw.io style directives", () => {
const stations = [{ id: "a", label: "A" }, { id: "b", label: "B" }];
for (const color of ["red", "#abc", "#123456;rounded=1", "#123456\nfillColor=#000000"]) {
assert.throws(
() => createTubeMap({ title: "Unsafe", stations, lines: [{ id: "line", label: "Line", color, stations: ["a", "b"] }] }),
/color.*#RRGGBB/i,
);
}
});
test("tube routes avoid unrelated station boxes even when route order is a,c,b", () => {
const ir = createTubeMap({
title: "Nonlinear route",
stations: [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }],
lines: [{ id: "line", label: "Line", color: "#123456", stations: ["a", "c", "b"] }],
});
const dir = mkdtempSync(join(tmpdir(), "tube-map-routing-"));
const file = join(dir, "route.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 });
}
});
test("tube lines use deterministic distinct parallel corridors without station intersections", () => {
const first = createTubeMap(MAP);
assert.deepEqual(first, createTubeMap(MAP));
const corridorByLine = new Map<string, number>();
for (const edge of first.pages[0].edges) {
const lineId = edge.properties!.lineId as string;
const horizontal = edge.waypoints!.find((point, index, points) => index > 0 && point.y === points[index - 1].y);
assert.ok(horizontal);
const previous = corridorByLine.get(lineId);
if (previous === undefined) corridorByLine.set(lineId, horizontal.y);
else assert.equal(horizontal.y, previous);
}
assert.equal(new Set(corridorByLine.values()).size, MAP.lines.length);
const dir = mkdtempSync(join(tmpdir(), "tube-map-corridors-"));
const file = join(dir, "corridors.drawio");
try {
writeFileSync(file, diagramIRToDrawio(first), "utf8");
const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number } };
assert.equal(validation.summary.connectorShapeOverlaps, 0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("tube lines sharing stations have distinct complete routes including station approaches", () => {
const input = {
title: "Shared route",
stations: [{ id: "a", label: "A" }, { id: "unrelated", label: "Unrelated" }, { id: "b", label: "B" }],
lines: [
{ id: "red", label: "Red", color: "#e53935", stations: ["a", "b"] },
{ id: "blue", label: "Blue", color: "#1e88e5", stations: ["a", "b"] },
],
};
const first = createTubeMap(input);
assert.deepEqual(first, createTubeMap(input));
const centers = new Map(first.pages[0].nodes.map((node) => [node.id, {
x: node.geometry!.x + node.geometry!.width / 2,
y: node.geometry!.y + node.geometry!.height / 2,
}]));
const routes = first.pages[0].edges.map((edge) => [centers.get(edge.source)!, ...edge.waypoints!, centers.get(edge.target)!]);
const segments = routes.map((points) => points.slice(1).map((point, index) => [points[index], point] as const));
const overlapsWithPositiveLength = (a: readonly [{ x: number; y: number }, { x: number; y: number }], b: readonly [{ x: number; y: number }, { x: number; y: number }]) => {
const [a1, a2] = a; const [b1, b2] = b;
const adx = a2.x - a1.x; const ady = a2.y - a1.y;
const bdx = b2.x - b1.x; const bdy = b2.y - b1.y;
if (adx * bdy !== ady * bdx || adx * (b1.y - a1.y) !== ady * (b1.x - a1.x)) return false;
const axis = Math.abs(adx) >= Math.abs(ady) ? "x" : "y";
const [aMin, aMax] = [a1[axis], a2[axis]].sort((x, y) => x - y);
const [bMin, bMax] = [b1[axis], b2[axis]].sort((x, y) => x - y);
return Math.min(aMax, bMax) > Math.max(aMin, bMin);
};
for (const redSegment of segments[0]) for (const blueSegment of segments[1]) {
assert.equal(overlapsWithPositiveLength(redSegment, blueSegment), false, "complete routes share a segment");
}
const dir = mkdtempSync(join(tmpdir(), "tube-map-shared-route-"));
const file = join(dir, "shared.drawio");
try {
writeFileSync(file, diagramIRToDrawio(first), "utf8");
const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number } };
assert.equal(validation.summary.connectorShapeOverlaps, 0);
} finally {
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 });
}
});
+53
View File
@@ -0,0 +1,53 @@
import type { DiagramIRV2, DiagramPoint } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export interface TubeStation { id: string; label: string; provenance?: Record<string, unknown> }
export interface TubeLine { id: string; label: string; color: string; stations: readonly string[]; provenance?: Record<string, unknown> }
export interface TubeMapInput { title: string; stations: readonly TubeStation[]; lines: readonly TubeLine[]; provenance?: Record<string, unknown> }
export function createTubeMap(input: TubeMapInput): DiagramIRV2 {
if (!input || !Array.isArray(input.stations) || input.stations.length === 0) throw new Error("Tube map requires stations");
if (!Array.isArray(input.lines)) throw new Error("Tube map lines must be an array");
const stations: readonly TubeStation[] = input.stations;
const lines: readonly TubeLine[] = input.lines;
const stationIds = new Set(stations.map((station) => station.id));
if (stationIds.size !== stations.length) throw new Error("Tube station IDs must be unique");
for (const line of lines) {
if (line.stations.length < 2) throw new Error(`Tube line ${line.id} requires at least two stations`);
for (const id of line.stations) if (!stationIds.has(id)) throw new Error(`Tube line ${line.id} references unknown station ${id}`);
if (!/^#[0-9A-Fa-f]{6}$/.test(line.color)) throw new Error(`Tube line ${line.id} color must use strict #RRGGBB format`);
}
const memberships = new Map(stations.map((station) => [station.id, [] as string[]]));
lines.forEach((line) => line.stations.forEach((id) => memberships.get(id)!.push(line.id)));
memberships.forEach((lines) => lines.sort());
const firstLine = new Map<string, number>();
lines.forEach((line, lineIndex) => line.stations.forEach((id) => { if (!firstLine.has(id)) firstLine.set(id, lineIndex); }));
// Every line owns a horizontal corridor at 40 + 20*i; the first station row sits below all of them
// so corridors never run through station boxes however many lines there are.
const corridorBand = 20 * lines.length;
const centers = new Map<string, DiagramPoint>();
const nodes = stations.map((station, index) => {
const center = { x: 100 + index * 160, y: 100 + corridorBand + (firstLine.get(station.id) ?? lines.length) * 160 }; centers.set(station.id, center);
const stationLines = memberships.get(station.id)!;
return { id: station.id, label: station.label, kind: stationLines.length > 1 ? "tube-interchange" : "tube-station", geometry: { x: center.x - 20, y: center.y - 20, width: 40, height: 40 }, style: `ellipse;whiteSpace=wrap;html=1;strokeWidth=${stationLines.length > 1 ? 6 : 3}`, properties: { interchange: stationLines.length > 1, lines: stationLines }, provenance: station.provenance === undefined ? undefined : structuredClone(station.provenance) };
});
const edges = lines.flatMap((line, lineIndex) => line.stations.slice(0, -1).map((source, index) => {
const routeCorridorY = 40 + lineIndex * 20;
const target = line.stations[index + 1]; const a = centers.get(source)!; const b = centers.get(target)!;
const laneOffset = 30 + Math.floor(lineIndex / 2) * 10;
const approachX = (lineIndex % 2 === 0 ? 1 : -1) * laneOffset;
const sourceApproachY = a.y + Math.sign(routeCorridorY - a.y) * laneOffset;
const targetApproachY = b.y + Math.sign(routeCorridorY - b.y) * laneOffset;
const waypoints = source === target
? [{ x: a.x + 40, y: a.y }, { x: a.x + 40, y: a.y + 40 }, { x: a.x, y: a.y + 40 }]
: [
{ x: a.x + approachX, y: sourceApproachY },
{ x: a.x + approachX, y: routeCorridorY },
{ x: b.x + approachX, y: routeCorridorY },
{ x: b.x + approachX, y: targetApproachY },
];
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 + 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;
}
@@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { projectLinkedViews, queryDiagram, runPolicies, simulateFailure } from "./analysis.js";
const IR: DiagramIRV2 = { version: 2, provenance: { source: "catalog" }, pages: [{ id: "system", title: "System", nodes: [
{ id: "internet", label: "Internet", kind: "external", properties: { importance: 10, trust_boundary: "public" }, provenance: { source: "inventory" } },
{ id: "api", label: "API", kind: "service", properties: { importance: 8, environment: "production", trust_boundary: "private" } },
{ id: "worker", label: "Worker", kind: "service", properties: { owner: "ops", runtime: "node" } },
{ id: "db", label: "DB", kind: "database", properties: { owner: "data", trust_boundary: "data" } },
{ id: "vendor", label: "Vendor", kind: "external" },
{ id: "orphan", label: "Orphan", kind: "service" },
], edges: [
{ id: "bad", source: "internet", target: "db" },
{ id: "request", source: "internet", target: "api", properties: { protocol: "HTTPS" } },
{ id: "dispatch", source: "api", target: "worker", kind: "async", properties: { isolates_failure: true } },
{ id: "write", source: "api", target: "db", kind: "write", properties: { protocol: "TLS" } },
{ id: "external-call", source: "api", target: "vendor" },
{ id: "cycle", source: "worker", target: "api" },
] }] };
test("linked view projection is deterministic, preserves model identity, and explains fallbacks", () => {
const views = projectLinkedViews(IR, ["executive", "system", "deployment", "dataflow", "security"]);
assert.deepEqual(views.map((view) => view.id), ["executive", "system", "deployment", "dataflow", "security"]);
assert.deepEqual(views[0].nodes.slice(0, 2).map((node) => node.id), ["internet", "api"]);
assert.deepEqual(views[0].nodes[0].provenance, { source: "inventory" });
assert.equal(views[1].fallback, false);
assert.ok(views[2].nodes.some((node) => node.id === "worker"));
assert.ok(views[3].nodes.some((node) => node.id === "db"));
assert.equal(projectLinkedViews({ ...IR, pages: [{ ...IR.pages[0], nodes: IR.pages[0].nodes.map((node) => ({ id: node.id, label: node.label })) }] }, ["security"])[0].fallbackReason?.length! > 0, true);
assert.deepEqual(projectLinkedViews(IR), projectLinkedViews(IR));
});
test("semantic query filters kind/properties and chooses deterministic shortest path", () => {
assert.deepEqual(queryDiagram(IR, { kind: "service", properties: { owner: "ops" } }).nodes.map((node) => node.id), ["worker"]);
const path = queryDiagram(IR, { from: "internet", to: "worker" });
assert.deepEqual(path.path, ["internet", "api", "worker"]);
assert.deepEqual(path.edges.map((edge) => edge.id), ["request", "dispatch"]);
});
test("architecture policies distinguish errors and warnings for all lifecycle rules", () => {
const report = runPolicies(IR);
assert.ok(report.findings.some((finding) => finding.rule === "no-direct-internet-to-database" && finding.severity === "error"));
for (const rule of ["no-cycles", "no-orphans", "every-service-has-owner", "production-has-observability", "external-dependencies-have-timeouts", "trust-boundaries-use-protocol"]) {
assert.ok(report.findings.some((finding) => finding.rule === rule && finding.severity === "warning"), rule);
}
assert.equal(report.errors, 1);
assert.ok(report.warnings >= 6);
});
test("what-if outgoing reachability stops at failure-isolating edges", () => {
const result = simulateFailure(IR, "internet");
assert.deepEqual(result.impacted, ["api", "db", "vendor"]);
assert.deepEqual(result.paths.api, ["internet", "api"]);
assert.equal(result.paths.worker, undefined);
assert.throws(() => simulateFailure(IR, "missing"), /unknown node/i);
});
test("semantic operations merge an element projected onto several pages", () => {
const shared: DiagramIRV2 = {
version: 2,
pages: [
{ id: "context", title: "Context", nodes: [
{ id: "shop", label: "Shop", kind: "c4-system", properties: { c4Type: "system" }, provenance: { line: 2 } },
{ id: "buyer", label: "Buyer", kind: "actor" },
], edges: [{ id: "uses", source: "buyer", target: "shop" }] },
{ id: "containers", title: "Containers", nodes: [
{ id: "shop", label: "Shop", kind: "container", properties: { c4Type: "system" }, provenance: { line: 2 } },
{ id: "buyer", label: "Buyer", kind: "actor" },
{ id: "api", label: "API", kind: "service", parentId: "shop" },
], edges: [{ id: "uses", source: "buyer", target: "shop" }, { id: "calls", source: "buyer", target: "api" }] },
],
};
const system = projectLinkedViews(shared, ["system"])[0];
assert.deepEqual(system.nodes.map((node) => node.id), ["shop", "buyer", "api"]);
assert.equal(system.nodes.find((node) => node.id === "shop")?.kind, "c4-system", "specific kind wins over the presentational container promotion");
assert.equal(system.nodes.find((node) => node.id === "api")?.parentId, undefined, "parent that is not a container in the merged model is dropped");
assert.deepEqual(system.edges.map((edge) => edge.id), ["uses", "calls"]);
assert.deepEqual(queryDiagram(shared, { from: "buyer", to: "api" }).path, ["buyer", "api"]);
assert.equal(runPolicies(shared, ["no-orphans"]).findings.length, 0);
assert.deepEqual(simulateFailure(shared, "buyer").impacted, ["api", "shop"]);
assert.throws(() => queryDiagram({ ...shared, pages: [shared.pages[0], { ...shared.pages[1], edges: [{ id: "uses", source: "api", target: "shop" }] }] }, {}), /ambiguous.*edge.*uses/i);
});
test("views keep container parents that are part of the selection and detach the rest", () => {
const nested: DiagramIRV2 = { version: 2, pages: [{ id: "p", title: "P", nodes: [
{ id: "outer", label: "Outer", kind: "container", geometry: { x: 100, y: 100, width: 400, height: 300 }, properties: { importance: 5 } },
{ id: "inner", label: "Inner", kind: "container", parentId: "outer", geometry: { x: 20, y: 40, width: 200, height: 150 } },
{ id: "leaf", label: "Leaf", kind: "service", parentId: "inner", geometry: { x: 10, y: 50, width: 120, height: 60 }, properties: { importance: 9 } },
], edges: [] }] };
const [executive] = projectLinkedViews({ ...nested, pages: [{ ...nested.pages[0], nodes: nested.pages[0].nodes.slice(0, 2).concat(Array.from({ length: 11 }, (_, index) => ({ id: `n${index}`, label: `N${index}`, properties: { importance: 8 } })), nested.pages[0].nodes.slice(2)) }] }, ["executive"]);
const leaf = executive.nodes.find((node) => node.id === "leaf")!;
assert.equal(executive.nodes.some((node) => node.id === "inner"), false);
assert.equal(leaf.parentId, undefined);
assert.deepEqual(leaf.geometry, { x: 130, y: 190, width: 120, height: 60 });
const [system] = projectLinkedViews(nested, ["system"]);
assert.equal(system.nodes.find((node) => node.id === "leaf")?.parentId, "inner");
assert.deepEqual(system.nodes.find((node) => node.id === "leaf")?.geometry, { x: 10, y: 50, width: 120, height: 60 });
});
test("semantic operations fail closed on ambiguous page-local IDs", () => {
const ambiguous: DiagramIRV2 = {
version: 2,
pages: [
{ id: "one", title: "One", nodes: [{ id: "shared", label: "First" }], edges: [] },
{ id: "two", title: "Two", nodes: [{ id: "shared", label: "Second" }], edges: [] },
],
};
assert.throws(() => queryDiagram(ambiguous, { kind: "service" }), /ambiguous.*shared.*one.*two/i);
assert.throws(() => projectLinkedViews(ambiguous), /ambiguous.*shared.*one.*two/i);
assert.throws(() => runPolicies(ambiguous), /ambiguous.*shared.*one.*two/i);
assert.throws(() => simulateFailure(ambiguous, "shared"), /ambiguous.*shared.*one.*two/i);
});
@@ -0,0 +1,185 @@
import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export type ViewName = "executive" | "system" | "deployment" | "dataflow" | "security";
export interface LinkedView { id: ViewName; title: string; nodes: DiagramNode[]; edges: DiagramEdge[]; sourcePageIds: string[]; fallback: boolean; fallbackReason?: string; hint?: string }
export interface QueryOptions { kind?: string; properties?: Record<string, unknown>; from?: string; to?: string }
export interface QueryResult { nodes: DiagramNode[]; edges: DiagramEdge[]; path?: string[] }
export interface PolicyFinding { rule: string; severity: "error" | "warning"; subject: string; message: string; hint: string }
export interface PolicyReport { errors: number; warnings: number; findings: PolicyFinding[] }
function stableJson(value: unknown): string {
if (value === undefined) return "null";
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
// A semantic element may legitimately be projected onto several pages (the C4 profile does this).
// Projections agree on identity (label, properties, provenance, extensions); only presentation may differ:
// a page may promote the element to kind "container" when its children are shown there, and parentId
// depends on whether the parent is projected onto that page. Anything else is a genuine conflict.
function mergeNode(existing: DiagramNode, incoming: DiagramNode, pageIds: string[]): DiagramNode {
const conflict = (): never => { throw new Error(`Ambiguous semantic node ID ${existing.id} appears on pages ${pageIds.join(", ")} with conflicting definitions`); };
if (existing.label !== incoming.label) conflict();
for (const key of ["properties", "provenance", "extensions"] as const) if (stableJson(existing[key]) !== stableJson(incoming[key])) conflict();
let kind = existing.kind;
if (existing.kind !== incoming.kind) {
if (existing.kind === "container") kind = incoming.kind;
else if (incoming.kind !== "container") conflict();
}
return { ...existing, ...(kind === undefined ? {} : { kind }), ...(existing.parentId === undefined && incoming.parentId !== undefined ? { parentId: incoming.parentId } : {}) };
}
function flatten(ir: DiagramIRV2): { nodes: DiagramNode[]; edges: DiagramEdge[] } {
validateDiagramIR(ir);
const pagesByNodeId = new Map<string, string[]>();
const nodes = new Map<string, DiagramNode>();
const edges = new Map<string, DiagramEdge>();
for (const page of ir.pages) {
for (const node of page.nodes) {
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`);
}
}
return { nodes: [...nodes.values()], edges: [...edges.values()] };
}
function induced(nodes: DiagramNode[], edges: DiagramEdge[], selected: Set<string>): { nodes: DiagramNode[]; edges: DiagramEdge[] } {
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[] {
const { nodes, edges } = flatten(ir);
const all = new Set(nodes.map((node) => node.id));
const degree = new Map(nodes.map((node) => [node.id, 0]));
for (const edge of edges) { degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1); degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1); }
return requested.map((name) => {
let selected = new Set<string>();
let reason: string | undefined;
let hint: string | undefined;
if (name === "system") selected = all;
else if (name === "executive") {
const hasImportance = nodes.some((node) => typeof node.properties?.importance === "number");
selected = new Set([...nodes].sort((a, b) => Number(b.properties?.importance ?? 0) - Number(a.properties?.importance ?? 0) || (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0) || a.id.localeCompare(b.id)).slice(0, 12).map((node) => node.id));
if (!hasImportance) { reason = "no properties.importance metadata; ranked by connection degree"; hint = "set properties.importance on executive-significant components"; }
} else if (name === "deployment") {
selected = new Set(nodes.filter((node) => ["environment", "region", "runtime", "host", "deployment"].some((key) => node.properties?.[key] !== undefined)).map((node) => node.id));
if (!selected.size) { selected = all; reason = "no deployment metadata"; hint = "set properties.environment, region, runtime, host, or deployment"; }
} else if (name === "dataflow") {
const relevant = edges.filter((edge) => ["data", "read", "write", "async"].includes(edge.kind ?? "") || /data|event|read|write|publish|consume/i.test(edge.label ?? ""));
selected = new Set(relevant.flatMap((edge) => [edge.source, edge.target]));
if (!selected.size) { selected = all; reason = "no data-flow edges"; hint = "set edge kind to data, read, write, or async"; }
} else {
selected = new Set(nodes.filter((node) => ["external", "gateway", "database", "actor"].includes(node.kind ?? "") || node.properties?.trust_boundary !== undefined).map((node) => node.id));
for (const edge of edges) {
const source = nodes.find((node) => node.id === edge.source)?.properties?.trust_boundary;
const target = nodes.find((node) => node.id === edge.target)?.properties?.trust_boundary;
if (source !== target && (source !== undefined || target !== undefined)) { selected.add(edge.source); selected.add(edge.target); }
}
if (!selected.size) { selected = all; reason = "no trust-boundary or security-kind metadata"; hint = "set properties.trust_boundary or kind external/database/gateway/actor"; }
}
const projection = induced(nodes, edges, selected);
return { id: name, title: name[0].toUpperCase() + name.slice(1), ...projection, sourcePageIds: ir.pages.map((page) => page.id), fallback: reason !== undefined, ...(reason ? { fallbackReason: reason, hint } : {}) };
});
}
export function queryDiagram(ir: DiagramIRV2, options: QueryOptions): QueryResult {
const { nodes, edges } = flatten(ir);
if ((options.from === undefined) !== (options.to === undefined)) throw new Error("Path query requires both from and to");
if (options.from && options.to) {
const ids = new Set(nodes.map((node) => node.id));
if (!ids.has(options.from) || !ids.has(options.to)) throw new Error("Path query references an unknown node");
const queue = [options.from];
const previous = new Map<string, { node: string; edge: DiagramEdge } | null>([[options.from, null]]);
while (queue.length) {
const current = queue.shift()!;
if (current === options.to) break;
for (const edge of edges.filter((item) => item.source === current).sort((a, b) => a.target.localeCompare(b.target) || a.id.localeCompare(b.id))) {
if (!previous.has(edge.target)) { previous.set(edge.target, { node: current, edge }); queue.push(edge.target); }
}
}
if (!previous.has(options.to)) return { nodes: [], edges: [], path: [] };
const path: string[] = [];
const pathEdges: DiagramEdge[] = [];
let current = options.to;
while (true) {
path.push(current);
const prior = previous.get(current)!;
if (!prior) break;
pathEdges.push(prior.edge);
current = prior.node;
}
path.reverse(); pathEdges.reverse();
const selected = new Set(path);
return { nodes: nodes.filter((node) => selected.has(node.id)), edges: pathEdges, path };
}
const selectedNodes = nodes.filter((node) => (!options.kind || node.kind === options.kind) && Object.entries(options.properties ?? {}).every(([key, value]) => JSON.stringify(node.properties?.[key]) === JSON.stringify(value)));
const selected = new Set(selectedNodes.map((node) => node.id));
return { nodes: selectedNodes, edges: edges.filter((edge) => selected.has(edge.source) && selected.has(edge.target)) };
}
function cycles(nodes: DiagramNode[], edges: DiagramEdge[]): string[][] {
const adjacency = new Map<string, string[]>();
for (const edge of edges) adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target].sort());
const visiting = new Set<string>(); const done = new Set<string>(); const stack: string[] = []; const found: string[][] = [];
const visit = (id: string): void => {
if (visiting.has(id)) { const start = stack.indexOf(id); found.push([...stack.slice(start), id]); return; }
if (done.has(id)) return;
visiting.add(id); stack.push(id);
for (const target of adjacency.get(id) ?? []) visit(target);
stack.pop(); visiting.delete(id); done.add(id);
};
for (const node of [...nodes].sort((a, b) => a.id.localeCompare(b.id))) visit(node.id);
return found;
}
export const POLICY_IDS = ["no-direct-internet-to-database", "no-cycles", "no-orphans", "every-service-has-owner", "production-has-observability", "external-dependencies-have-timeouts", "trust-boundaries-use-protocol"] as const;
export function runPolicies(ir: DiagramIRV2, enabled: readonly string[] = POLICY_IDS): PolicyReport {
const { nodes, edges } = flatten(ir); const byId = new Map(nodes.map((node) => [node.id, node])); const findings: PolicyFinding[] = [];
const add = (rule: string, severity: "error" | "warning", subject: string, message: string, hint: string): void => { findings.push({ rule, severity, subject, message, hint }); };
if (enabled.includes("no-direct-internet-to-database")) for (const edge of edges) { const source = byId.get(edge.source); const target = byId.get(edge.target); if ((source?.kind === "external" || source?.kind === "actor" || /internet/i.test(source?.label ?? "")) && target?.kind === "database") add("no-direct-internet-to-database", "error", edge.id, "internet-facing component connects directly to a database", "insert an authenticated service boundary"); }
if (enabled.includes("no-cycles")) for (const cycle of cycles(nodes, edges)) add("no-cycles", "warning", cycle.join(" -> "), "cyclic dependency detected", "break the cycle or add an asynchronous boundary");
if (enabled.includes("no-orphans") && nodes.length > 1) for (const node of nodes) if (!node.properties?.intentional_orphan && !edges.some((edge) => edge.source === node.id || edge.target === node.id)) add("no-orphans", "warning", node.id, "component is disconnected", "connect it or set properties.intentional_orphan=true");
if (enabled.includes("every-service-has-owner")) for (const node of nodes) if (["service", "gateway", "database", "queue"].includes(node.kind ?? "") && !node.properties?.owner) add("every-service-has-owner", "warning", node.id, "service has no owner", "set properties.owner");
if (enabled.includes("production-has-observability")) for (const node of nodes) if (["prod", "production"].includes(String(node.properties?.environment ?? "").toLowerCase()) && !node.properties?.observability) add("production-has-observability", "warning", node.id, "production component lacks observability metadata", "set properties.observability");
if (enabled.includes("external-dependencies-have-timeouts")) for (const edge of edges) if (byId.get(edge.target)?.kind === "external" && !edge.properties?.timeout) add("external-dependencies-have-timeouts", "warning", edge.id, "external dependency has no timeout", "set edge properties.timeout");
if (enabled.includes("trust-boundaries-use-protocol")) for (const edge of edges) { const a = byId.get(edge.source)?.properties?.trust_boundary; const b = byId.get(edge.target)?.properties?.trust_boundary; if (a !== b && !edge.properties?.protocol && !edge.label) add("trust-boundaries-use-protocol", "warning", edge.id, "unlabelled connection crosses a trust boundary", "set edge properties.protocol"); }
findings.sort((a, b) => a.rule.localeCompare(b.rule) || a.subject.localeCompare(b.subject));
return { errors: findings.filter((item) => item.severity === "error").length, warnings: findings.filter((item) => item.severity === "warning").length, findings };
}
export function simulateFailure(ir: DiagramIRV2, failed: string): { failed: string; impacted: string[]; paths: Record<string, string[]> } {
const { nodes, edges } = flatten(ir); if (!nodes.some((node) => node.id === failed)) throw new Error(`Unknown node: ${failed}`);
const queue = [failed]; const paths: Record<string, string[]> = { [failed]: [failed] }; const impacted = new Set<string>();
while (queue.length) { const current = queue.shift()!; for (const edge of edges.filter((item) => item.source === current).sort((a, b) => a.target.localeCompare(b.target) || a.id.localeCompare(b.id))) { if (edge.properties?.isolates_failure === true || paths[edge.target]) continue; paths[edge.target] = [...paths[current], edge.target]; impacted.add(edge.target); queue.push(edge.target); } }
return { failed, impacted: [...impacted].sort(), paths };
}
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { applyEditBatch, type EditBatch } from "./edit-batch.js";
const SOURCE: DiagramIRV2 = { version: 2, pages: [{ id: "main", title: "Main", layout: { type: "manual" }, nodes: [
{ id: "zone", label: "Zone", kind: "container", geometry: { x: 0, y: 0, width: 400, height: 300 } },
{ id: "api", label: "API", kind: "service", parentId: "zone", geometry: { x: 20, y: 40, width: 120, height: 60 } },
{ id: "db", label: "DB", kind: "database", geometry: { x: 500, y: 40, width: 120, height: 60 } },
], edges: [{ id: "query", source: "api", target: "db", label: "SQL" }] }] };
test("transactional edits apply typed add/update/move/connect operations with stable IDs", () => {
const batch: EditBatch = { pageId: "main", preconditions: [{ type: "exists", id: "api" }, { type: "not-exists", id: "worker" }], operations: [
{ type: "add", node: { id: "worker", label: "Worker", kind: "service", geometry: { x: 200, y: 40, width: 120, height: 60 } } },
{ type: "update", id: "api", changes: { label: "Public API", properties: { owner: "platform" } } },
{ type: "move", id: "worker", parentId: "zone", geometry: { x: 180, y: 140, width: 120, height: 60 } },
{ type: "connect", edge: { id: "dispatch", source: "api", target: "worker", kind: "async" } },
] };
const result = applyEditBatch(SOURCE, batch);
assert.equal(result.ir.pages[0].nodes.find((node) => node.id === "api")?.label, "Public API");
assert.equal(result.ir.pages[0].nodes.find((node) => node.id === "worker")?.parentId, "zone");
assert.equal(result.ir.pages[0].edges.find((edge) => edge.id === "dispatch")?.target, "worker");
assert.deepEqual(SOURCE.pages[0].nodes.map((node) => node.id), ["zone", "api", "db"]);
});
test("edit batches reject unknown precondition types", () => {
const batch = { pageId: "main", preconditions: [{ type: "surprise", id: "missing" }], operations: [] } as unknown as EditBatch;
assert.throws(() => applyEditBatch(SOURCE, batch), /unknown precondition type.*surprise/i);
});
test("edit batches reject unknown operation types instead of deleting", () => {
const batch = { pageId: "main", operations: [{ type: "surprise", id: "api" }] } as unknown as EditBatch;
assert.throws(() => applyEditBatch(SOURCE, batch), /unknown operation type.*surprise/i);
assert.ok(SOURCE.pages[0].nodes.some((node) => node.id === "api"));
});
test("edit batches reject id changes in runtime update payloads", () => {
const batch = { pageId: "main", operations: [{ type: "update", id: "api", changes: { id: "renamed" } }] } as unknown as EditBatch;
assert.throws(() => applyEditBatch(SOURCE, batch), /update changes.*id/i);
});
test("edit batches are atomic, support dry-run, and require explicit cascade", () => {
const before = JSON.stringify(SOURCE);
assert.throws(() => applyEditBatch(SOURCE, { pageId: "main", operations: [{ type: "delete", id: "zone" }] }), /cascade/i);
assert.equal(JSON.stringify(SOURCE), before);
assert.throws(() => applyEditBatch(SOURCE, { pageId: "main", operations: [{ type: "update", id: "api", changes: { parentId: "missing" } }] }), /unknown parent/i);
assert.equal(JSON.stringify(SOURCE), before);
const dry = applyEditBatch(SOURCE, { pageId: "main", operations: [{ type: "delete", id: "zone", cascade: true }] }, { dryRun: true });
assert.deepEqual(dry.ir, SOURCE);
assert.deepEqual(dry.preview.pages[0].nodes.map((node) => node.id), ["db"]);
});
@@ -0,0 +1,95 @@
import type { DiagramEdge, DiagramGeometry, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export type EditPrecondition =
| { type: "exists"; id: string }
| { type: "not-exists"; id: string }
| { type: "property-equals"; id: string; property: string; value: unknown };
export type EditOperation =
| { type: "add"; node: DiagramNode }
| { type: "update"; id: string; changes: Partial<Omit<DiagramNode, "id">> }
| { type: "move"; id: string; parentId?: string; geometry: DiagramGeometry }
| { type: "delete"; id: string; cascade?: boolean }
| { type: "connect"; edge: DiagramEdge };
export interface EditBatch {
pageId: string;
preconditions?: EditPrecondition[];
operations: EditOperation[];
}
export interface EditBatchResult {
ir: DiagramIRV2;
preview: DiagramIRV2;
applied: number;
dryRun: boolean;
}
function getItem(page: DiagramIRV2["pages"][number], id: string): DiagramNode | DiagramEdge | undefined {
return page.nodes.find((node) => node.id === id) ?? page.edges.find((edge) => edge.id === id);
}
export function applyEditBatch(source: DiagramIRV2, batch: EditBatch, options: { dryRun?: boolean } = {}): EditBatchResult {
validateDiagramIR(source);
const candidate = structuredClone(source);
const page = candidate.pages.find((item) => item.id === batch.pageId);
if (!page) throw new Error(`Unknown page: ${batch.pageId}`);
for (const condition of batch.preconditions ?? []) {
const item = getItem(page, condition.id);
if (condition.type === "exists" && !item) throw new Error(`Precondition failed: ${condition.id} does not exist`);
if (condition.type === "not-exists" && item) throw new Error(`Precondition failed: ${condition.id} already exists`);
if (condition.type === "property-equals") {
const actual = item?.properties?.[condition.property];
if (!item || JSON.stringify(actual) !== JSON.stringify(condition.value)) throw new Error(`Precondition failed: ${condition.id}.${condition.property} differs`);
} else if (condition.type !== "exists" && condition.type !== "not-exists") {
throw new Error(`Unknown precondition type: ${(condition as { type?: unknown }).type}`);
}
}
for (const operation of batch.operations) {
if (operation.type === "add") {
if (getItem(page, operation.node.id)) throw new Error(`ID already exists: ${operation.node.id}`);
page.nodes.push(structuredClone(operation.node));
} else if (operation.type === "connect") {
if (getItem(page, operation.edge.id)) throw new Error(`ID already exists: ${operation.edge.id}`);
page.edges.push(structuredClone(operation.edge));
} else if (operation.type === "update") {
if (Object.hasOwn(operation.changes, "id")) throw new Error("Update changes must not include id");
const node = page.nodes.find((item) => item.id === operation.id);
if (!node) throw new Error(`Unknown node: ${operation.id}`);
Object.assign(node, structuredClone(operation.changes));
} else if (operation.type === "move") {
const node = page.nodes.find((item) => item.id === operation.id);
if (!node) throw new Error(`Unknown node: ${operation.id}`);
node.geometry = structuredClone(operation.geometry);
if (operation.parentId === undefined) delete node.parentId;
else node.parentId = operation.parentId;
} else if (operation.type === "delete") {
const nodeIndex = page.nodes.findIndex((item) => item.id === operation.id);
const edgeIndex = page.edges.findIndex((item) => item.id === operation.id);
if (nodeIndex < 0 && edgeIndex < 0) throw new Error(`Unknown item: ${operation.id}`);
if (edgeIndex >= 0) {
page.edges.splice(edgeIndex, 1);
continue;
}
const descendants = new Set<string>();
const collect = (parentId: string): void => {
for (const node of page.nodes) if (node.parentId === parentId && !descendants.has(node.id)) { descendants.add(node.id); collect(node.id); }
};
collect(operation.id);
const affected = new Set([operation.id, ...descendants]);
const connected = page.edges.filter((edge) => affected.has(edge.source) || affected.has(edge.target));
if (!operation.cascade && (descendants.size || connected.length)) throw new Error(`Delete ${operation.id} requires cascade for descendants or connected edges`);
page.nodes = page.nodes.filter((node) => !affected.has(node.id));
page.edges = page.edges.filter((edge) => !affected.has(edge.source) && !affected.has(edge.target));
} else {
throw new Error(`Unknown operation type: ${(operation as { type?: unknown }).type}`);
}
}
validateDiagramIR(candidate);
const dryRun = options.dryRun === true;
return { ir: dryRun ? source : candidate, preview: candidate, applied: batch.operations.length, dryRun };
}
@@ -0,0 +1,101 @@
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 { JSDOM } from "jsdom";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { importDrawioToIR } from "./import-drawio.js";
test("loss-aware import preserves multi-page semantics and round-trips deterministically", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-import-"));
const source = join(dir, "source.drawio");
try {
writeFileSync(source, `<?xml version="1.0"?><mxfile host="desktop" custom-root="keep"><diagram id="system" name="System" custom-page="p"><mxGraphModel pageWidth="900" pageHeight="700" custom-model="m"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="zone" value="Zone" vertex="1" parent="1" style="swimlane;startSize=30;" custom-cell="c"><mxGeometry x="20" y="20" width="500" height="260" as="geometry" custom-geo="g"/></mxCell><mxCell id="api" value="API" vertex="1" parent="zone" style="fillColor=#fff;" data-kind="service" data-properties="{&quot;owner&quot;:&quot;team-a&quot;}" data-provenance="{&quot;source&quot;:&quot;catalog&quot;}" data-extensions="{&quot;x&quot;:1}"><mxGeometry x="40" y="60" width="120" height="60" as="geometry"/><Custom payload="keep"/></mxCell><mxCell id="call" value="HTTPS" edge="1" parent="1" source="api" target="zone" style="edgeStyle=orthogonalEdgeStyle;" data-kind="request"><mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="220" y="100"/></Array></mxGeometry></mxCell><mxCell id="note" value="raw" parent="1" custom="yes"/></root></mxGraphModel></diagram><diagram id="deploy" name="Deployment"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="host" value="Host" vertex="1" parent="1"><mxGeometry x="10" y="10" width="100" height="50" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`, "utf8");
const imported = importDrawioToIR(source);
assert.equal(imported.ir.pages.length, 2);
const page = imported.ir.pages[0];
assert.equal(page.id, "system");
assert.equal(page.nodes.find((node) => node.id === "api")?.parentId, "zone");
assert.deepEqual(page.nodes.find((node) => node.id === "api")?.geometry, { x: 40, y: 60, width: 120, height: 60 });
assert.deepEqual(page.nodes.find((node) => node.id === "api")?.properties, { owner: "team-a" });
assert.deepEqual(page.edges[0].waypoints, [{ x: 220, y: 100 }]);
assert.equal(imported.lossReport.lossy, false);
assert.ok(JSON.stringify(imported.ir.extensions).includes("custom-root"));
assert.ok(JSON.stringify(page.extensions).includes("note"));
const rebuilt = diagramIRToDrawio(imported.ir);
assert.match(rebuilt, /<mxfile host="desktop"/);
const roundTrip = importDrawioToIR(rebuilt, { sourceIsXml: true });
assert.deepEqual(roundTrip.ir.pages.map((item) => item.id), ["system", "deploy"]);
assert.deepEqual(roundTrip.ir.pages[0].nodes, page.nodes);
assert.deepEqual(roundTrip.ir.pages[0].edges, page.edges);
assert.equal(diagramIRToDrawio(roundTrip.ir), rebuilt);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("import and serialization preserve model flags, node geometry children, and absent node style without false losslessness", () => {
const xml = `<mxfile><diagram id="p" name="P"><mxGraphModel grid="0" page="0"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="10" y="20" width="100" height="50" relative="1" as="geometry"><mxPoint x="3" y="4" as="offset"/></mxGeometry></mxCell></root></mxGraphModel></diagram></mxfile>`;
const imported = importDrawioToIR(xml, { sourceIsXml: true });
assert.equal(imported.lossReport.lossy, false);
const rebuilt = diagramIRToDrawio(imported.ir);
const document = new JSDOM(rebuilt, { contentType: "text/xml" }).window.document;
const model = document.querySelector("mxGraphModel")!;
const node = document.querySelector("mxCell#n")!;
const geometry = node.querySelector(":scope > mxGeometry")!;
const offset = geometry.querySelector(":scope > mxPoint[as='offset']")!;
assert.equal(model.getAttribute("grid"), "0");
assert.equal(model.getAttribute("page"), "0");
assert.equal(node.hasAttribute("style"), false);
assert.equal(geometry.getAttribute("relative"), "1");
assert.deepEqual([offset.getAttribute("x"), offset.getAttribute("y")], ["3", "4"]);
assert.equal(importDrawioToIR(rebuilt, { sourceIsXml: true }).lossReport.lossy, false);
});
test("import rejects duplicate semantic node IDs across pages", () => {
const xml = `<mxfile><diagram id="one" name="One"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="First" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="two" name="Two"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="Second" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`;
assert.throws(() => importDrawioToIR(xml, { sourceIsXml: true }), /duplicate semantic node id.*api.*pages.*one.*two/i);
});
test("loss-aware import fails closed on dangling endpoints", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="A" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell><mxCell id="bad" edge="1" source="a" target="missing"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel>`;
assert.throws(() => importDrawioToIR(xml, { sourceIsXml: true }), /dangling endpoint.*bad.*missing/i);
});
test("loss-aware import preserves valid Draw.io IDs that begin with digits", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="42-node" value="Numeric" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`;
assert.equal(importDrawioToIR(xml, { sourceIsXml: true }).ir.pages[0].nodes[0].id, "42-node");
});
test("loss-aware import preserves unknown mxfile, diagram, and model children", () => {
const xml = `<mxfile><meta-marker keep="root"/><diagram id="p" name="P"><diagram-marker keep="page"/><mxGraphModel><model-marker keep="model"/><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`;
const imported = importDrawioToIR(xml, { sourceIsXml: true });
assert.equal(imported.lossReport.lossy, false);
const rebuilt = diagramIRToDrawio(imported.ir);
assert.match(rebuilt, /<meta-marker keep="root"\s*\/>/);
assert.match(rebuilt, /<diagram-marker keep="page"\s*\/>/);
assert.match(rebuilt, /<model-marker keep="model"\s*\/>/);
const second = importDrawioToIR(rebuilt, { sourceIsXml: true });
assert.equal(second.lossReport.lossy, false);
assert.equal(diagramIRToDrawio(second.ir), rebuilt);
});
test("loss-aware import preserves deliberately empty labels", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="icon" value="" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`;
const imported = importDrawioToIR(xml, { sourceIsXml: true }).ir;
assert.equal(imported.pages[0].nodes[0].label, "icon");
assert.match(JSON.stringify(imported.pages[0].nodes[0].extensions), /semantic:label/);
assert.match(diagramIRToDrawio(imported), /id="icon" value=""/);
});
test("loss-aware import envelopes malformed semantic attributes instead of dropping them", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="API" vertex="1" parent="1" data-properties="not-json"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`;
const result = importDrawioToIR(xml, { sourceIsXml: true });
assert.match(JSON.stringify(result.ir.pages[0].nodes[0].extensions), /data-properties.*not-json/);
});
@@ -0,0 +1,237 @@
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import type { DiagramEdge, DiagramIRV2, DiagramNode, DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
import { decodeDiagramContent, normalizeDrawioDocument } from "../drawio-parser/parser.js";
export interface PreservationEnvelope {
host?: string;
attributes?: Record<string, string>;
holderTag?: string;
holderAttributes?: Record<string, string>;
childXml?: string[];
geometryAttributes?: Record<string, string>;
geometryChildXml?: string[];
styleAbsent?: boolean;
modelAttributes?: Record<string, string>;
modelChildXml?: string[];
unknownCells?: string[];
}
export interface ImportLossReport {
lossy: boolean;
preserved: string[];
warnings: string[];
}
export interface ImportDrawioOptions {
sourceIsXml?: boolean;
}
const CELL_KNOWN = new Set(["id", "value", "label", "style", "vertex", "edge", "parent", "source", "target", "data-kind", "data-properties", "data-provenance", "data-extensions"]);
const GEOMETRY_KNOWN = new Set(["x", "y", "width", "height", "relative", "as"]);
const PAGE_KNOWN = new Set(["id", "name", "data-properties", "data-extensions"]);
const MODEL_KNOWN = new Set(["pageWidth", "pageHeight", "grid", "gridSize", "page"]);
const ROOT_KNOWN = new Set(["host", "data-version", "data-title", "data-theme", "data-provenance", "data-assets", "data-properties", "data-extensions"]);
function parseDocument(xml: string, expected: "mxfile" | "mxGraphModel"): Document {
if (/<!DOCTYPE/i.test(xml)) throw new Error("Invalid XML: DOCTYPE is not allowed");
const Parser = new JSDOM("<root/>", { contentType: "text/xml" }).window.DOMParser;
const parsed = new Parser().parseFromString(xml, "text/xml");
if (parsed.querySelector("parsererror") || parsed.documentElement.tagName !== expected) {
throw new Error(`Invalid XML: expected complete <${expected}> document`);
}
return parsed;
}
function serializeElement(element: Element): string {
const XMLSerializer = new JSDOM("<root/>", { contentType: "text/xml" }).window.XMLSerializer;
return new XMLSerializer().serializeToString(element);
}
function attrs(element: Element, known: Set<string>): Record<string, string> | undefined {
const result: Record<string, string> = {};
for (const attribute of Array.from(element.attributes).sort((a, b) => a.name.localeCompare(b.name))) {
if (!known.has(attribute.name)) result[attribute.name] = attribute.value;
}
return Object.keys(result).length ? result : undefined;
}
function selectedAttrs(element: Element, names: Set<string>): Record<string, string> | undefined {
const result: Record<string, string> = {};
for (const name of names) if (element.hasAttribute(name)) result[name] = element.getAttribute(name)!;
return Object.keys(result).length ? result : undefined;
}
function jsonObject(element: Element, name: string): Record<string, unknown> | undefined {
const raw = element.getAttribute(name);
if (!raw) return undefined;
try {
const value: unknown = JSON.parse(raw);
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
} catch {
// Invalid semantic JSON remains preserved as an unknown attribute below.
}
return undefined;
}
function numberAttr(element: Element | null, name: string, fallback = 0): number {
const raw = element?.getAttribute(name);
if (raw === null || raw === undefined || raw === "") return fallback;
const value = Number(raw);
if (!Number.isFinite(value)) throw new Error(`Invalid geometry ${name}: ${raw}`);
return value;
}
function mergePreservation(extensions: Record<string, unknown> | undefined, envelope: PreservationEnvelope): Record<string, unknown> | undefined {
const clean = Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined && (!Array.isArray(value) || value.length > 0)));
if (!Object.keys(clean).length) return extensions;
return { ...(extensions ?? {}), $drawio: clean };
}
function cellRows(model: Element): Array<{ holder: Element; cell: Element }> {
const root = Array.from(model.children).find((child) => child.tagName === "root");
if (!root) throw new Error("Draw.io page is missing <root>");
const rows: Array<{ holder: Element; cell: Element }> = [];
for (const child of Array.from(root.children)) {
if (child.tagName === "mxCell") rows.push({ holder: child, cell: child });
else if (child.tagName === "object" || child.tagName === "UserObject") {
const cell = Array.from(child.children).find((nested) => nested.tagName === "mxCell");
if (cell) rows.push({ holder: child, cell });
}
}
return rows;
}
function parsePage(modelXml: string, pageElement: Element | undefined, index: number, report: ImportLossReport): DiagramPage {
const document = parseDocument(modelXml, "mxGraphModel");
const model = document.documentElement;
const rows = cellRows(model);
const nodeIds = new Set(rows.filter(({ cell }) => cell.getAttribute("vertex") === "1").map(({ holder, cell }) => holder.getAttribute("id") ?? cell.getAttribute("id") ?? ""));
const root = Array.from(model.children).find((child) => child.tagName === "root")!;
const knownElements = new Set(rows.map(({ holder }) => holder));
const unknownCells = Array.from(root.children).filter((child) => !knownElements.has(child) && !["0", "1"].includes(child.getAttribute("id") ?? "")).map((child) => serializeElement(child));
const nodes: DiagramNode[] = [];
const edges: DiagramEdge[] = [];
for (const { holder, cell } of rows) {
const id = holder.getAttribute("id") ?? cell.getAttribute("id") ?? "";
if (!id || id === "0" || id === "1") continue;
const semantic = holder === cell ? cell : holder;
const geometry = Array.from(cell.children).find((child) => child.tagName === "mxGeometry") ?? null;
const envelope: PreservationEnvelope = {
attributes: attrs(cell, CELL_KNOWN),
holderTag: holder === cell ? undefined : holder.tagName,
holderAttributes: holder === cell ? undefined : attrs(holder, new Set(["id", "label", "value", "data-kind", "data-properties", "data-provenance", "data-extensions"])),
childXml: Array.from(cell.children).filter((child) => child.tagName !== "mxGeometry").map((child) => serializeElement(child)),
geometryAttributes: geometry ? selectedAttrs(geometry, new Set(["relative", "as"])) : undefined,
geometryChildXml: geometry ? Array.from(geometry.children).filter((child) => !(cell.getAttribute("edge") === "1" && child.matches("Array[as='points']"))).map((child) => serializeElement(child)) : undefined,
styleAbsent: cell.getAttribute("vertex") === "1" && !cell.hasAttribute("style") ? true : undefined,
};
for (const name of ["data-properties", "data-provenance", "data-extensions"]) {
const owner = semantic.hasAttribute(name) ? semantic : cell;
if (owner.hasAttribute(name) && jsonObject(owner, name) === undefined) {
envelope.attributes = { ...(envelope.attributes ?? {}), [name]: owner.getAttribute(name)! };
}
}
const geometryAttrs = geometry ? attrs(geometry, GEOMETRY_KNOWN) : undefined;
if (geometryAttrs) envelope.attributes = { ...(envelope.attributes ?? {}), ...Object.fromEntries(Object.entries(geometryAttrs).map(([key, value]) => [`geometry:${key}`, value])) };
const rawLabel = semantic.getAttribute("label") ?? semantic.getAttribute("value") ?? cell.getAttribute("value") ?? id;
if (rawLabel.trim() === "") envelope.attributes = { ...(envelope.attributes ?? {}), "semantic:label": rawLabel };
const extensions = mergePreservation(jsonObject(semantic, "data-extensions") ?? jsonObject(cell, "data-extensions"), envelope);
const label = rawLabel.trim() === "" ? id : rawLabel;
if (cell.getAttribute("vertex") === "1") {
nodes.push({
id,
label,
kind: semantic.getAttribute("data-kind") ?? cell.getAttribute("data-kind") ?? (nodeIds.has(id) && rows.some((row) => row.cell.getAttribute("parent") === id) ? "container" : "service"),
...(cell.getAttribute("parent") && cell.getAttribute("parent") !== "1" ? { parentId: cell.getAttribute("parent")! } : {}),
geometry: { x: numberAttr(geometry, "x"), y: numberAttr(geometry, "y"), width: numberAttr(geometry, "width"), height: numberAttr(geometry, "height") },
...(cell.hasAttribute("style") ? { style: cell.getAttribute("style")! } : {}),
...(jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") ? { properties: jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") } : {}),
...(jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") ? { provenance: jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") } : {}),
...(extensions ? { extensions } : {}),
});
} else if (cell.getAttribute("edge") === "1") {
const source = cell.getAttribute("source") ?? "";
const target = cell.getAttribute("target") ?? "";
const missing = [source, target].filter((endpoint) => !nodeIds.has(endpoint));
if (missing.length) throw new Error(`Unsupported dangling endpoint on edge ${id}: ${missing.join(", ")}`);
const points = geometry ? Array.from(geometry.querySelectorAll("Array[as='points'] > mxPoint")).map((point) => ({ x: numberAttr(point, "x"), y: numberAttr(point, "y") })) : [];
edges.push({
id, source, target, label: rawLabel,
kind: semantic.getAttribute("data-kind") ?? cell.getAttribute("data-kind") ?? "relation",
...(cell.hasAttribute("style") ? { style: cell.getAttribute("style")! } : {}),
...(points.length ? { waypoints: points } : {}),
...(jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") ? { properties: jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") } : {}),
...(jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") ? { provenance: jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") } : {}),
...(extensions ? { extensions } : {}),
});
} else {
unknownCells.push(serializeElement(holder));
}
}
const pageExtensions = mergePreservation(jsonObject(pageElement ?? model, "data-extensions"), {
attributes: { ...(pageElement ? attrs(pageElement, PAGE_KNOWN) : {}), ...Object.fromEntries(Object.entries(attrs(model, MODEL_KNOWN) ?? {}).map(([key, value]) => [`model:${key}`, value])) },
modelAttributes: selectedAttrs(model, MODEL_KNOWN),
childXml: pageElement ? Array.from(pageElement.children).filter((child) => child.tagName !== "mxGraphModel").map(serializeElement) : undefined,
modelChildXml: Array.from(model.children).filter((child) => child !== root).map(serializeElement),
unknownCells,
});
if (unknownCells.length) report.preserved.push(`page ${index + 1}: ${unknownCells.length} unknown cell(s)`);
return {
id: pageElement?.getAttribute("id") || `page-${index + 1}`,
title: pageElement?.getAttribute("name") || `Page ${index + 1}`,
nodes,
edges,
layout: { type: "manual", gridSize: numberAttr(model, "gridSize", 10) },
...(numberAttr(model, "pageWidth") > 0 ? { width: numberAttr(model, "pageWidth") } : {}),
...(numberAttr(model, "pageHeight") > 0 ? { height: numberAttr(model, "pageHeight") } : {}),
...(jsonObject(pageElement ?? model, "data-properties") ? { properties: jsonObject(pageElement ?? model, "data-properties") } : {}),
...(pageExtensions ? { extensions: pageExtensions } : {}),
};
}
export function importDrawioToIR(source: string, options: ImportDrawioOptions = {}): { ir: DiagramIRV2; lossReport: ImportLossReport } {
const raw = normalizeDrawioDocument(options.sourceIsXml ? source : readFileSync(source, "utf8"));
const report: ImportLossReport = { lossy: false, preserved: [], warnings: [] };
let pages: DiagramPage[];
let root: Element | undefined;
if (/^<mxfile/i.test(raw)) {
const document = parseDocument(raw, "mxfile");
root = document.documentElement;
const diagrams = Array.from(root.children).filter((child) => child.tagName === "diagram");
if (!diagrams.length) throw new Error("No <diagram> elements found in mxfile");
pages = diagrams.map((diagram, index) => {
const model = Array.from(diagram.children).find((child) => child.tagName === "mxGraphModel");
return parsePage(model ? serializeElement(model) : decodeDiagramContent(diagram.textContent ?? ""), diagram, index, report);
});
} else {
pages = [parsePage(raw, undefined, 0, report)];
}
const nodePages = new Map<string, string>();
for (const page of pages) for (const node of page.nodes) {
const priorPage = nodePages.get(node.id);
if (priorPage !== undefined) throw new Error(`Duplicate semantic node ID ${node.id} across pages ${priorPage} and ${page.id}`);
nodePages.set(node.id, page.id);
}
const extensions = root ? mergePreservation(jsonObject(root, "data-extensions"), {
host: root.getAttribute("host") ?? undefined,
attributes: attrs(root, ROOT_KNOWN),
childXml: Array.from(root.children).filter((child) => child.tagName !== "diagram").map(serializeElement),
}) : undefined;
const ir: DiagramIRV2 = {
version: 2,
...(root?.getAttribute("data-title") ? { title: root.getAttribute("data-title")! } : {}),
pages,
...(root ? (jsonObject(root, "data-provenance") ? { provenance: jsonObject(root, "data-provenance") } : {}) : {}),
...(root ? (jsonObject(root, "data-assets") ? { assets: jsonObject(root, "data-assets") } : {}) : {}),
...(root?.getAttribute("data-theme") ? { theme: root.getAttribute("data-theme")! } : {}),
...(root ? (jsonObject(root, "data-properties") ? { properties: jsonObject(root, "data-properties") } : {}) : {}),
...(extensions ? { extensions } : {}),
};
validateDiagramIR(ir);
return { ir, lossReport: report };
}
@@ -0,0 +1,35 @@
import { lstatSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { basename, dirname, extname, resolve } from "node:path";
import * as yaml from "js-yaml";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { normalizeDiagramIR, validateDiagramIR } from "../../model/diagram-ir.js";
import { importDrawioToIR } from "./import-drawio.js";
export interface LifecycleActionOptions { spec?: string; base?: string; strict?: boolean; prune?: boolean; dryRun?: boolean; fail?: string; views?: string; kind?: string; property?: string[]; from?: string; to?: string }
export function loadStructured(path: string): unknown { return yaml.load(readFileSync(path, "utf8"), { schema: yaml.JSON_SCHEMA }); }
export function loadIR(path: string): DiagramIRV2 { return /\.(drawio|xml)$/i.test(path) ? importDrawioToIR(path).ir : normalizeDiagramIR(validateDiagramIR(loadStructured(path))); }
export function structuredText(value: unknown, path: string): string { return extname(path).toLowerCase() === ".json" ? `${JSON.stringify(value, null, 2)}\n` : yaml.dump(value, { noRefs: true, sortKeys: false, lineWidth: 120 }); }
function canonicalExisting(path: string): string {
const absolute = resolve(path);
try { return realpathSync(absolute); } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return absolute;
throw error;
}
}
export function assertOutputSafe(path: string, inputs: string[] = []): string {
const output = resolve(path);
try { if (lstatSync(output).isSymbolicLink()) throw new Error("Refusing to overwrite a symbolic link"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
const canonicalOutput = canonicalExisting(output);
if (inputs.some((input) => canonicalExisting(input) === canonicalOutput)) throw new Error("Refusing to overwrite an input path (including traversal aliases)");
return output;
}
export function atomicWrite(path: string, content: string, inputs: string[] = []): string {
const output = assertOutputSafe(path, inputs);
const temporary = resolve(dirname(output), `.${basename(output)}.${process.pid}.${Date.now()}.tmp`);
try { writeFileSync(temporary, content, { encoding: "utf8", flag: "wx" }); renameSync(temporary, output); } finally { rmSync(temporary, { force: true }); }
return output;
}
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { createStoryHtml, doctorReport } from "./publishing.js";
const ir: DiagramIRV2 = { version: 2, title: "Platform", pages: [{ id: "p", title: "P", nodes: [
{ id: "api", label: "API <primary>", kind: "service", properties: { owner: "team" }, provenance: { source: "catalog" }, geometry: { x: 10, y: 20, width: 120, height: 60 } },
{ id: "db", label: "DB", kind: "database", geometry: { x: 220, y: 20, width: 120, height: 60 } },
], edges: [{ id: "write", source: "api", target: "db", label: "SQL" }] }] };
test("story HTML is deterministic, offline, accessible, keyboard navigable, and supports a what-if overlay", () => {
const html = createStoryHtml(ir, { scenario: { failed: "api", impacted: ["db"] } });
assert.equal(createStoryHtml(ir, { scenario: { failed: "api", impacted: ["db"] } }), html);
assert.match(html, /^<!doctype html>/i);
assert.match(html, /http-equiv="Content-Security-Policy"/i);
assert.match(html, /default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'/i);
assert.doesNotMatch(html, /https?:\/\//i);
assert.doesNotMatch(html, /<script[^>]+src=|<link[^>]+href=/i);
assert.match(html, /aria-labelledby=/);
assert.match(html, /Text alternative/);
assert.match(html, /ArrowRight/);
assert.match(html, /data-status="failed"/);
assert.match(html, /API &lt;primary&gt;/);
assert.match(html, /catalog/);
});
test("doctor reports optional backend availability without launching commands", () => {
let launches = 0;
const report = doctorReport({ path: "/definitely/missing", onLaunch: () => launches++ });
assert.equal(launches, 0);
assert.equal(report.networkRequired, false);
assert.equal(report.backends.drawio.status, "missing");
assert.equal(report.backends.graphviz.mandatory, false);
assert.equal(report.capabilities.semanticLifecycle, true);
});
test("story fails closed on ambiguous multi-page node IDs", () => {
const ambiguous: DiagramIRV2 = {
version: 2,
pages: [
{ id: "one", title: "One", nodes: [{ id: "shared", label: "First" }], edges: [] },
{ id: "two", title: "Two", nodes: [{ id: "shared", label: "Second" }], edges: [] },
],
};
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]);
});
@@ -0,0 +1,50 @@
import { accessSync, constants, existsSync } from "node:fs";
import { delimiter, join } from "node:path";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
function escapeHtml(value: string): string { return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;"); }
function stableJson(value: unknown): string { 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); }
export interface StoryOptions { title?: string; scenario?: { failed: string; impacted: string[] } }
export function createStoryHtml(ir: DiagramIRV2, options: StoryOptions = {}): string {
validateDiagramIR(ir);
const title = options.title ?? ir.title ?? "Architecture Story";
const pagesByNodeId = new Map<string, string[]>();
for (const page of ir.pages) for (const node of page.nodes) {
pagesByNodeId.set(node.id, [...(pagesByNodeId.get(node.id) ?? []), page.id]);
}
for (const [id, pageIds] of pagesByNodeId) {
if (pageIds.length > 1) throw new Error(`Ambiguous story node ID ${id} appears on pages ${pageIds.join(", ")}`);
}
const nodes = ir.pages.flatMap((page) => page.nodes);
const edges = ir.pages.flatMap((page) => page.edges);
const positions = new Map(nodes.map((node, index) => [node.id, { x: node.geometry?.x ?? 60 + (index % 4) * 220, y: node.geometry?.y ?? 70 + Math.floor(index / 4) * 130, width: node.geometry?.width ?? 160, height: node.geometry?.height ?? 70 }]));
const failed = options.scenario?.failed; const impacted = new Set(options.scenario?.impacted ?? []);
const edgeSvg = edges.map((edge) => { const a = positions.get(edge.source); const b = positions.get(edge.target); if (!a || !b) return ""; return `<line x1="${a.x + a.width}" y1="${a.y + a.height / 2}" x2="${b.x}" y2="${b.y + b.height / 2}" marker-end="url(#arrow)"/><text x="${(a.x + a.width + b.x) / 2}" y="${(a.y + b.y) / 2}">${escapeHtml(edge.label ?? "")}</text>`; }).join("");
const steps = nodes.map((node) => ({ id: node.id, label: node.label, detail: `${node.kind ?? "component"}${node.properties?.owner ? ` · owner: ${String(node.properties.owner)}` : ""}${node.provenance ? ` · provenance: ${stableJson(node.provenance)}` : ""}` }));
const nodeSvg = nodes.map((node) => { const p = positions.get(node.id)!; const status = node.id === failed ? "failed" : impacted.has(node.id) ? "impacted" : "normal"; return `<g class="node ${status}" data-id="${escapeHtml(node.id)}" data-status="${status}" tabindex="0" role="button" aria-label="${escapeHtml(`${node.label}, ${node.kind ?? "component"}`)}"><rect x="${p.x}" y="${p.y}" width="${p.width}" height="${p.height}"/><text x="${p.x + p.width / 2}" y="${p.y + p.height / 2}" text-anchor="middle">${escapeHtml(node.label)}</text></g>`; }).join("");
const alternatives = steps.map((step) => `<li><strong>${escapeHtml(step.label)}</strong> — ${escapeHtml(step.detail)}</li>`).join("") + edges.map((edge) => `<li>${escapeHtml(edge.source)} → ${escapeHtml(edge.target)}${edge.label ? ` — ${escapeHtml(edge.label)}` : ""}</li>`).join("");
// 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>`;
}
export interface DoctorBackend { status: "available" | "missing"; path?: string; mandatory: false }
export interface DoctorReport { networkRequired: false; backends: Record<string, DoctorBackend>; capabilities: { semanticLifecycle: true; nativeDrawio: true; storyHtml: true } }
function executable(name: string, pathValue: string): string | undefined {
for (const directory of pathValue.split(delimiter).filter(Boolean)) {
const candidate = join(directory, name);
if (!existsSync(candidate)) continue;
try { accessSync(candidate, constants.X_OK); return candidate; } catch { /* not executable */ }
}
return undefined;
}
export function doctorReport(options: { path?: string; onLaunch?: () => void } = {}): DoctorReport {
const pathValue = options.path ?? process.env.PATH ?? "";
const backend = (name: string): DoctorBackend => { const path = executable(name, pathValue); return path ? { status: "available", path, mandatory: false } : { status: "missing", mandatory: false }; };
return { networkRequired: false, backends: { drawio: backend("drawio"), graphviz: backend("dot") }, capabilities: { semanticLifecycle: true, nativeDrawio: true, storyHtml: true } };
}
@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { syncDiagramIR } from "./sync.js";
const base: DiagramIRV2 = { version: 2, pages: [{ id: "p", title: "P", layout: { type: "manual" }, nodes: [
{ id: "api", label: "API", kind: "service", properties: { owner: "a" }, geometry: { x: 10, y: 10, width: 100, height: 50 }, style: "fillColor=#fff;" },
{ id: "old", label: "Old", kind: "service", geometry: { x: 200, y: 10, width: 100, height: 50 } },
], edges: [{ id: "uses", source: "api", target: "old", label: "uses" }] }] };
test("three-way sync preserves manual geometry/style, adds incoming IDs, and reports semantic conflicts", () => {
const manual = structuredClone(base);
manual.pages[0].nodes[0].geometry = { x: 90, y: 80, width: 140, height: 70 };
manual.pages[0].nodes[0].style = "fillColor=#00ff00;";
manual.pages[0].nodes[0].label = "API manual";
const incoming: DiagramIRV2 = { version: 2, pages: [{ id: "p", title: "P", layout: { type: "manual" }, nodes: [
{ id: "api", label: "API incoming", kind: "service", properties: { owner: "b" } },
{ id: "new", label: "New", kind: "service", geometry: { x: 300, y: 10, width: 100, height: 50 } },
], edges: [{ id: "new-edge", source: "api", target: "new" }] }] };
const result = syncDiagramIR(base, manual, incoming);
const api = result.ir.pages[0].nodes.find((node) => node.id === "api")!;
assert.deepEqual(api.geometry, { x: 90, y: 80, width: 140, height: 70 });
assert.equal(api.style, "fillColor=#00ff00;");
assert.equal(api.label, "API manual");
assert.ok(result.added.includes("new"));
assert.ok(result.conflicts.some((conflict) => conflict.id === "api" && conflict.field === "label"));
const removed = result.ir.pages[0].nodes.find((node) => node.id === "old")!;
assert.equal(removed.properties?.lifecycleStatus, "removed");
assert.match(removed.style!, /opacity=45/);
});
test("sync reports divergent add/add conflicts instead of silently choosing incoming", () => {
const manual = structuredClone(base);
manual.pages[0].nodes.push({ id: "worker", label: "Manual worker", kind: "service" });
const incoming = structuredClone(base);
incoming.pages[0].nodes.push({ id: "worker", label: "Incoming worker", kind: "database" });
const result = syncDiagramIR(base, manual, incoming);
const conflict = result.conflicts.find((item) => item.id === "worker" && item.field === "$add");
assert.ok(conflict);
assert.equal(conflict.base, undefined);
assert.equal((conflict.manual as { label: string }).label, "Manual worker");
assert.equal((conflict.incoming as { label: string }).label, "Incoming worker");
assert.equal(result.ir.pages[0].nodes.find((node) => node.id === "worker")?.label, "Manual worker");
});
test("sync reports divergent edge add/add conflicts", () => {
const manual = structuredClone(base);
manual.pages[0].edges.push({ id: "new-link", source: "api", target: "old", label: "Manual" });
const incoming = structuredClone(base);
incoming.pages[0].edges.push({ id: "new-link", source: "old", target: "api", label: "Incoming" });
const result = syncDiagramIR(base, manual, incoming);
assert.ok(result.conflicts.some((item) => item.id === "new-link" && item.field === "$add"));
assert.equal(result.ir.pages[0].edges.find((edge) => edge.id === "new-link")?.source, "api");
});
test("sync only prunes removals when explicitly requested and is deterministic", () => {
const incoming: DiagramIRV2 = { version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API", kind: "service" }], edges: [] }] };
const kept = syncDiagramIR(base, base, incoming);
assert.ok(kept.ir.pages[0].nodes.some((node) => node.id === "old"));
const pruned = syncDiagramIR(base, base, incoming, { prune: true });
assert.equal(pruned.ir.pages[0].nodes.some((node) => node.id === "old"), false);
assert.equal(pruned.ir.pages[0].edges.length, 0);
assert.deepEqual(syncDiagramIR(base, base, incoming, { prune: true }), pruned);
});
test("sync preserves pages manually added after the base when incoming omits them", () => {
const manualPage = { id: "notes", title: "Notes", nodes: [{ id: "note", label: "Note" }], edges: [] };
const manual: DiagramIRV2 = { ...structuredClone(base), pages: [...structuredClone(base.pages), manualPage] };
const incoming = structuredClone(base);
const result = syncDiagramIR(base, manual, incoming);
assert.deepEqual(result.ir.pages.find((page) => page.id === "notes"), manualPage);
assert.equal(result.removed.includes("notes"), false);
});
test("sync retains removed pages unless pruning is explicit", () => {
const oldPage = { id: "legacy", title: "Legacy", nodes: [{ id: "legacy-node", label: "Legacy" }], edges: [] };
const withPage: DiagramIRV2 = { ...base, pages: [...base.pages, oldPage] };
const incoming: DiagramIRV2 = { version: 2, pages: [structuredClone(base.pages[0])] };
const kept = syncDiagramIR(withPage, withPage, incoming);
const retainedPage = kept.ir.pages.find((page) => page.id === "legacy");
assert.ok(retainedPage);
assert.equal(retainedPage.properties?.lifecycleStatus, "removed");
assert.ok(kept.removed.includes("legacy"));
const pruned = syncDiagramIR(withPage, withPage, incoming, { prune: true });
assert.equal(pruned.ir.pages.some((page) => page.id === "legacy"), false);
assert.ok(pruned.removed.includes("legacy"));
});
const page = (id: string, nodes: DiagramIRV2["pages"][number]["nodes"]): DiagramIRV2["pages"][number] => ({ id, title: id, nodes, edges: [], layout: { type: "manual" } });
test("page-level add/add is reported as an $add conflict and manual content is kept", () => {
const b: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A" }])] };
const manual: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A" }]), page("p2", [{ id: "m1", label: "manual-only work" }])] };
const incoming: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A" }]), page("p2", [{ id: "g1", label: "generated" }])] };
const result = syncDiagramIR(b, manual, incoming);
assert.deepEqual(result.conflicts.map((c) => [c.pageId, c.id, c.field]), [["p2", "p2", "$add"]]);
assert.deepEqual(result.ir.pages.find((p) => p.id === "p2")!.nodes.map((n) => n.id), ["m1"]);
assert.deepEqual(syncDiagramIR(b, manual, manual).conflicts, []);
});
test("manual delete versus incoming modify yields a $delete conflict instead of resurrecting the node", () => {
const b: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A" }, { id: "k", label: "K" }])] };
const manual: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "k", label: "K" }])] };
const incoming: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A-renamed" }, { id: "k", label: "K" }])] };
const result = syncDiagramIR(b, manual, incoming);
assert.deepEqual(result.conflicts.map((c) => [c.id, c.field, c.manual, (c.incoming as { label: string }).label]), [["a", "$delete", undefined, "A-renamed"]]);
assert.deepEqual(result.ir.pages[0].nodes.map((n) => n.id), ["k"]);
assert.deepEqual(syncDiagramIR(b, manual, b).conflicts, []);
});
test("object key order is not a change: equality uses canonical serialization", () => {
const b: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A", properties: { x: 1, y: 2 } }])] };
const manual: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A", properties: { y: 2, x: 1 } }])] };
const incoming: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A", properties: { x: 1, y: 3 } }])] };
const result = syncDiagramIR(b, manual, incoming);
assert.deepEqual(result.conflicts, []);
assert.deepEqual(result.ir.pages[0].nodes[0].properties, { x: 1, y: 3 });
});
test("sync action fails and withholds output on conflicts unless --force is given", async () => {
const { run } = await import("../../actions/sync/action.js");
const dir = mkdtempSync(join(tmpdir(), "sync-action-"));
try {
const write = (name: string, ir: DiagramIRV2) => { const p = join(dir, name); writeFileSync(p, JSON.stringify(ir), "utf8"); return p; };
const b = write("base.json", { version: 2, pages: [page("p1", [{ id: "a", label: "A" }])] });
const manual = write("manual.json", { version: 2, pages: [page("p1", [{ id: "a", label: "A manual" }])] });
const incoming = write("incoming.json", { version: 2, pages: [page("p1", [{ id: "a", label: "A incoming" }])] });
const out = join(dir, "out.json");
const blocked = run(manual, 0, out, { base: b, spec: incoming });
assert.equal(blocked.failed, true); assert.equal(blocked.output, undefined); assert.equal(existsSync(out), false); assert.match(String(blocked.summary), /conflict/);
const forced = run(manual, 0, out, { base: b, spec: incoming, force: true });
assert.equal(forced.failed, false); assert.equal(existsSync(out), true); assert.equal((forced.conflicts as unknown[]).length, 1);
const clean = run(manual, 0, out, { base: b, spec: manual });
assert.equal(clean.failed, false); assert.equal(clean.output, out);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
@@ -0,0 +1,133 @@
import type { DiagramEdge, DiagramIRV2, DiagramNode, DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export interface SyncConflict { pageId: string; id: string; field: string; base: unknown; manual: unknown; incoming: unknown }
export interface SyncResult { ir: DiagramIRV2; added: string[]; removed: string[]; conflicts: SyncConflict[] }
/** Canonical serialization with sorted object keys so key order alone never counts as a change. */
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).filter((key) => record[key] !== undefined).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; }
return String(JSON.stringify(value));
}
const equal = (a: unknown, b: unknown): boolean => canonical(a) === canonical(b);
function conflictValue<T extends DiagramNode | DiagramEdge>(pageId: string, id: string, field: keyof T, base: T, manual: T, incoming: T, conflicts: SyncConflict[]): unknown {
const b = base[field]; const m = manual[field]; const n = incoming[field];
if (!equal(m, b) && !equal(n, b) && !equal(m, n)) { conflicts.push({ pageId, id, field: String(field), base: b, manual: m, incoming: n }); return structuredClone(m); }
return structuredClone(!equal(m, b) ? m : n);
}
function fadedStyle(style: string | undefined, edge = false): string {
const base = style ?? (edge ? "edgeStyle=orthogonalEdgeStyle;" : "whiteSpace=wrap;html=1;");
return `${base}${base.endsWith(";") ? "" : ";"}dashed=1;opacity=${edge ? 35 : 45};strokeColor=#b85450;`;
}
function syncPage(base: DiagramPage, manual: DiagramPage, incoming: DiagramPage, prune: boolean, conflicts: SyncConflict[], added: string[], removed: string[]): DiagramPage {
const baseNodes = new Map(base.nodes.map((item) => [item.id, item]));
const manualNodes = new Map(manual.nodes.map((item) => [item.id, item]));
const incomingNodes = new Map(incoming.nodes.map((item) => [item.id, item]));
const nodes: DiagramNode[] = [];
for (const incomingNode of incoming.nodes) {
const baseNode = baseNodes.get(incomingNode.id); const manualNode = manualNodes.get(incomingNode.id);
if (!baseNode) {
if (manualNode && !equal(manualNode, incomingNode)) {
conflicts.push({ pageId: base.id, id: incomingNode.id, field: "$add", base: undefined, manual: structuredClone(manualNode), incoming: structuredClone(incomingNode) });
nodes.push(structuredClone(manualNode));
} else nodes.push(structuredClone(incomingNode));
added.push(incomingNode.id);
continue;
}
if (!manualNode) {
// Manual deleted a node that incoming changed since base: a delete/modify conflict, not a silent resurrection.
if (!equal(incomingNode, baseNode)) { conflicts.push({ pageId: base.id, id: incomingNode.id, field: "$delete", base: structuredClone(baseNode), manual: undefined, incoming: structuredClone(incomingNode) }); continue; }
nodes.push(structuredClone(incomingNode)); continue;
}
const node = structuredClone(incomingNode);
for (const field of ["label", "kind", "parentId", "properties", "provenance", "extensions"] as const) {
const value = conflictValue<DiagramNode>(base.id, node.id, field, baseNode, manualNode, incomingNode, conflicts);
if (value === undefined) delete node[field]; else (node as unknown as Record<string, unknown>)[field] = value;
}
if (manualNode.geometry) node.geometry = structuredClone(manualNode.geometry); else delete node.geometry;
if (manualNode.style !== undefined) node.style = manualNode.style; else delete node.style;
if (manualNode.width !== undefined) node.width = manualNode.width;
if (manualNode.height !== undefined) node.height = manualNode.height;
nodes.push(node);
}
for (const manualNode of manual.nodes) {
if (incomingNodes.has(manualNode.id)) continue;
if (!baseNodes.has(manualNode.id)) { nodes.push(structuredClone(manualNode)); continue; }
removed.push(manualNode.id);
if (!prune) nodes.push({ ...structuredClone(manualNode), style: fadedStyle(manualNode.style), properties: { ...(manualNode.properties ?? {}), lifecycleStatus: "removed" } });
}
const keptNodeIds = new Set(nodes.map((node) => node.id));
const baseEdges = new Map(base.edges.map((item) => [item.id, item]));
const manualEdges = new Map(manual.edges.map((item) => [item.id, item]));
const incomingEdges = new Map(incoming.edges.map((item) => [item.id, item]));
const edges: DiagramEdge[] = [];
for (const incomingEdge of incoming.edges) {
if (!keptNodeIds.has(incomingEdge.source) || !keptNodeIds.has(incomingEdge.target)) continue;
const baseEdge = baseEdges.get(incomingEdge.id); const manualEdge = manualEdges.get(incomingEdge.id);
if (!baseEdge) {
if (manualEdge && !equal(manualEdge, incomingEdge)) {
conflicts.push({ pageId: base.id, id: incomingEdge.id, field: "$add", base: undefined, manual: structuredClone(manualEdge), incoming: structuredClone(incomingEdge) });
edges.push(structuredClone(manualEdge));
} else edges.push(structuredClone(incomingEdge));
added.push(incomingEdge.id);
continue;
}
if (!manualEdge) {
if (!equal(incomingEdge, baseEdge)) { conflicts.push({ pageId: base.id, id: incomingEdge.id, field: "$delete", base: structuredClone(baseEdge), manual: undefined, incoming: structuredClone(incomingEdge) }); continue; }
edges.push(structuredClone(incomingEdge)); continue;
}
const edge = structuredClone(incomingEdge);
for (const field of ["source", "target", "label", "kind", "properties", "provenance", "extensions"] as const) {
const value = conflictValue<DiagramEdge>(base.id, edge.id, field, baseEdge, manualEdge, incomingEdge, conflicts);
if (value === undefined) delete edge[field]; else (edge as unknown as Record<string, unknown>)[field] = value;
}
if (manualEdge.style !== undefined) edge.style = manualEdge.style; else delete edge.style;
if (manualEdge.waypoints !== undefined) edge.waypoints = structuredClone(manualEdge.waypoints); else delete edge.waypoints;
edges.push(edge);
}
for (const manualEdge of manual.edges) {
if (incomingEdges.has(manualEdge.id)) continue;
if (!baseEdges.has(manualEdge.id)) { if (keptNodeIds.has(manualEdge.source) && keptNodeIds.has(manualEdge.target)) edges.push(structuredClone(manualEdge)); continue; }
removed.push(manualEdge.id);
if (!prune && keptNodeIds.has(manualEdge.source) && keptNodeIds.has(manualEdge.target)) edges.push({ ...structuredClone(manualEdge), style: fadedStyle(manualEdge.style, true), properties: { ...(manualEdge.properties ?? {}), lifecycleStatus: "removed" } });
}
return { ...structuredClone(incoming), nodes, edges, ...(manual.width !== undefined ? { width: manual.width } : {}), ...(manual.height !== undefined ? { height: manual.height } : {}) };
}
export function syncDiagramIR(base: DiagramIRV2, manual: DiagramIRV2, incoming: DiagramIRV2, options: { prune?: boolean } = {}): SyncResult {
validateDiagramIR(base); validateDiagramIR(manual); validateDiagramIR(incoming);
const conflicts: SyncConflict[] = []; const added: string[] = []; const removed: string[] = [];
const basePages = new Map(base.pages.map((page) => [page.id, page])); const manualPages = new Map(manual.pages.map((page) => [page.id, page]));
const pages = incoming.pages.map((page) => {
const basePage = basePages.get(page.id); const manualPage = manualPages.get(page.id);
if (!manualPage) { added.push(page.id); return structuredClone(page); }
if (!basePage) {
// Page add/add: both sides introduced the same page ID independently; keep manual and report it like node-level `$add`.
added.push(page.id);
if (equal(manualPage, page)) return structuredClone(page);
conflicts.push({ pageId: page.id, id: page.id, field: "$add", base: undefined, manual: structuredClone(manualPage), incoming: structuredClone(page) });
return structuredClone(manualPage);
}
return syncPage(basePage, manualPage, page, options.prune === true, conflicts, added, removed);
});
const incomingPageIds = new Set(incoming.pages.map((page) => page.id));
for (const manualPage of manual.pages) {
if (incomingPageIds.has(manualPage.id)) continue;
if (!basePages.has(manualPage.id)) {
if (options.prune !== true) pages.push(structuredClone(manualPage));
continue;
}
removed.push(manualPage.id);
if (options.prune !== true) pages.push({
...structuredClone(manualPage),
properties: { ...(manualPage.properties ?? {}), lifecycleStatus: "removed" },
});
}
const ir: DiagramIRV2 = { ...structuredClone(incoming), pages };
validateDiagramIR(ir);
return { ir, added: [...new Set(added)].sort(), removed: [...new Set(removed)].sort(), conflicts: conflicts.sort((a, b) => a.pageId.localeCompare(b.pageId) || a.id.localeCompare(b.id) || a.field.localeCompare(b.field)) };
}
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { OFFLINE_SHAPE_CATALOG, searchShapes } from "./shape-catalog.js";
test("offline catalog entries carry explicit public provenance and licensing", () => {
assert.ok(OFFLINE_SHAPE_CATALOG.length >= 6);
for (const entry of OFFLINE_SHAPE_CATALOG) {
assert.equal(entry.provenance.license, "Apache-2.0");
assert.match(entry.provenance.sourceUrl, /^https:\/\//);
assert.match(entry.style, /shape=/);
}
});
test("shape search ranks exact, alias, and fuzzy matches deterministically", () => {
assert.deepEqual(searchShapes("database"), searchShapes("database"));
assert.equal(searchShapes("database").matches[0].id, "cylinder");
assert.equal(searchShapes("database").matches[0].matchType, "alias");
assert.equal(searchShapes("rhombus").matches[0].matchType, "exact");
const fuzzy = searchShapes("proces box").matches[0];
assert.equal(fuzzy.id, "rectangle");
assert.equal(fuzzy.matchType, "fuzzy");
assert.ok(fuzzy.confidence > 0 && fuzzy.confidence < 0.9);
});
test("shape search returns an explicit generic fallback for unknown or blank queries", () => {
for (const query of ["quantum-flibbertigibbet", " "]) {
const result = searchShapes(query);
assert.equal(result.matches.length, 0);
assert.equal(result.fallback.id, "rectangle");
assert.equal(result.fallback.matchType, "fallback");
assert.equal(result.fallback.confidence, 0);
}
});
@@ -0,0 +1,98 @@
export interface ShapeProvenance {
sourceName: string;
sourceUrl: string;
license: "Apache-2.0";
note: string;
}
export interface ShapeCatalogEntry {
id: string;
name: string;
aliases: readonly string[];
style: string;
provenance: ShapeProvenance;
}
export type ShapeMatchType = "exact" | "alias" | "fuzzy" | "fallback";
export interface ShapeSearchMatch extends ShapeCatalogEntry {
confidence: number;
matchType: ShapeMatchType;
}
export interface ShapeSearchResult {
query: string;
matches: ShapeSearchMatch[];
fallback: ShapeSearchMatch;
}
const PROVENANCE: ShapeProvenance = {
sourceName: "diagrams.net generic built-in shape vocabulary",
sourceUrl: "https://github.com/jgraph/drawio",
license: "Apache-2.0",
note: "Hand-curated metadata for generic native Draw.io shapes; no third-party assets are bundled.",
};
export const OFFLINE_SHAPE_CATALOG: readonly ShapeCatalogEntry[] = Object.freeze([
entry("actor", "Actor", ["person", "user", "human"], "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;"),
entry("cloud", "Cloud", ["internet", "external cloud"], "shape=cloud;"),
entry("cylinder", "Cylinder", ["database", "data store", "storage"], "shape=cylinder;"),
entry("document", "Document", ["file", "report", "paper"], "shape=document;"),
entry("ellipse", "Ellipse", ["oval", "start", "end"], "shape=ellipse;"),
entry("hexagon", "Hexagon", ["preparation", "compute"], "shape=hexagon;"),
entry("rectangle", "Rectangle", ["process", "box", "generic"], "shape=rectangle;"),
entry("rhombus", "Rhombus", ["decision", "diamond", "choice"], "shape=rhombus;"),
]);
function entry(id: string, name: string, aliases: readonly string[], style: string): ShapeCatalogEntry {
return Object.freeze({ id, name, aliases: Object.freeze([...aliases]), style, provenance: Object.freeze({ ...PROVENANCE }) });
}
function normalize(value: string): string {
return value.trim().toLocaleLowerCase("en-US").replace(/[_-]+/g, " ").replace(/\s+/g, " ");
}
function similarity(left: string, right: string): number {
if (left === right) return 1;
const a = [...left];
const b = [...right];
if (a.length === 0 || b.length === 0) return 0;
const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
for (let row = 1; row <= a.length; row += 1) {
const current = [row];
for (let column = 1; column <= b.length; column += 1) {
current[column] = Math.min(
current[column - 1] + 1,
previous[column] + 1,
previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1),
);
}
previous.splice(0, previous.length, ...current);
}
return 1 - previous[b.length] / Math.max(a.length, b.length);
}
function match(entryValue: ShapeCatalogEntry, query: string): ShapeSearchMatch | undefined {
const id = normalize(entryValue.id);
const name = normalize(entryValue.name);
const aliases = entryValue.aliases.map(normalize);
if (query === id || query === name) return { ...entryValue, confidence: 1, matchType: "exact" };
if (aliases.includes(query)) return { ...entryValue, confidence: 0.95, matchType: "alias" };
const terms = [id, name, ...aliases];
const score = Math.max(...terms.map((term) => similarity(query, term)));
if (score < 0.45) return undefined;
return { ...entryValue, confidence: Number((score * 0.85).toFixed(6)), matchType: "fuzzy" };
}
export function searchShapes(rawQuery: string, limit = 5): ShapeSearchResult {
if (!Number.isInteger(limit) || limit < 1) throw new Error("Shape search limit must be a positive integer");
const query = normalize(rawQuery);
const generic = OFFLINE_SHAPE_CATALOG.find(({ id }) => id === "rectangle")!;
const fallback: ShapeSearchMatch = { ...generic, confidence: 0, matchType: "fallback" };
const matches = query === "" ? [] : OFFLINE_SHAPE_CATALOG
.map((candidate) => match(candidate, query))
.filter((candidate): candidate is ShapeSearchMatch => candidate !== undefined)
.sort((left, right) => right.confidence - left.confidence || left.id.localeCompare(right.id, "en-US"))
.slice(0, limit);
return { query: rawQuery, matches, fallback };
}
export const searchShapeCatalog = searchShapes;
@@ -0,0 +1,405 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { importSource } from "./index.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
const textInput = (sourceKind: Parameters<typeof importSource>[0]["sourceKind"], path: string, text: string) =>
importSource({ sourceKind, path, input: { type: "text", text } });
test("imports Python modules with exact line provenance", async () => {
const result = await textInput("python", "src/app.py", "import os\nfrom pkg.db import Client\n");
assert.deepEqual(result.diagram.pages[0].nodes.map((n) => [n.label, n.kind]), [
["src/app.py", "module"], ["os", "library"], ["pkg.db", "library"],
]);
assert.equal(result.diagram.pages[0].edges[1].provenance?.line, 2);
assert.equal(validateDiagramIR(result.diagram), result.diagram);
});
test("imports JavaScript and TypeScript static and require dependencies", async () => {
const js = await textInput("javascript", "src/app.js", "import express from 'express';\nconst x = require('./local.js');\n");
const ts = await textInput("typescript", "src/app.ts", "export { x } from '@scope/pkg';\nimport type { T } from './types.js';\n");
assert.deepEqual(js.diagram.pages[0].nodes.slice(1).map((n) => n.label), ["express", "./local.js"]);
assert.deepEqual(ts.diagram.pages[0].nodes.slice(1).map((n) => n.label), ["@scope/pkg", "./types.js"]);
assert.ok(js.diagram.pages[0].nodes.every((n) => ["module", "library"].includes(n.kind ?? "")));
});
test("JavaScript scanner ignores comments and strings while preserving real static imports", async () => {
const source = [
"// import commented from 'commented';",
"/* require('blocked');",
" export { x } from 'also-blocked'; */",
"const text = \"import fake from 'inside-string'\";",
"const template = `require('inside-template')`;",
"const pattern = /import regexFake from 'inside-regex'/;",
"loader.require('member-call');",
"const metadata = { import: 'property', from: 'property-from' };",
"import real from 'real';",
"export { thing } from './actual.js';",
"const required = require(\"required\");",
"import type {",
" Value",
"} from './types.js';",
"// const dynamic = import(name);",
].join("\n");
const result = await textInput("typescript", "src/app.ts", source);
assert.deepEqual(result.diagram.pages[0].nodes.slice(1).map((node) => node.label), ["real", "./actual.js", "required", "./types.js"]);
assert.deepEqual(result.diagnostics, []);
});
test("JavaScript scanner treats arrow-expression regex literals as inert without hiding division or imports", async () => {
const source = [
"const matcher = () => /require('phantom')/;",
"const ratio = numerator / denominator;",
"import real from 'real';",
"const actual = require('./actual.js');",
].join("\n");
const result = await textInput("typescript", "src/regex.ts", source);
assert.deepEqual(result.diagram.pages[0].nodes.slice(1).map((node) => node.label), ["real", "./actual.js"]);
assert.deepEqual(result.diagnostics, []);
});
test("JavaScript scanner diagnoses unsupported dynamic and unterminated constructs", async () => {
const result = await textInput("javascript", "src/app.js", "const dynamic = import(name);\nconst template = `${load(name)}`;\n/* unterminated");
assert.ok(result.diagnostics.length >= 3);
assert.ok(result.diagnostics.every((diagnostic) => diagnostic.code === "unknown-construct"));
});
test("imports Go single and grouped imports", async () => {
const result = await textInput("go", "cmd/main.go", "package main\nimport \"fmt\"\nimport (\n alias \"example.com/acme/lib\"\n _ \"net/http/pprof\"\n)\n");
assert.deepEqual(result.diagram.pages[0].nodes.slice(1).map((n) => n.label), ["fmt", "example.com/acme/lib", "net/http/pprof"]);
assert.equal(result.diagram.pages[0].edges[2].provenance?.line, 5);
});
test("imports Rust mod declarations and use roots without guessing macros", async () => {
const result = await textInput("rust", "src/lib.rs", "mod api;\nuse crate::db::Pool;\nuse serde::{Serialize, Deserialize};\ncustom!(unknown);\n");
assert.deepEqual(result.diagram.pages[0].nodes.slice(1).map((n) => n.label), ["api", "crate::db", "serde"]);
assert.equal(result.diagnostics[0].code, "unknown-construct");
});
test("imports Terraform resources and explicit references", async () => {
const result = await textInput("terraform", "main.tf", "resource \"aws_vpc\" \"main\" {}\nresource \"aws_subnet\" \"web\" {\n vpc_id = aws_vpc.main.id\n}\n");
assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["aws_subnet.web", "aws_vpc.main"]);
assert.deepEqual(result.diagram.pages[0].edges.map((e) => [e.source, e.target, e.kind]), [["resource-6177735f7375626e65742e776562", "resource-6177735f7670632e6d61696e", "reference"]]);
assert.equal(result.diagram.pages[0].edges[0].provenance?.line, 3);
});
test("non-JavaScript scanners ignore inert constructs and diagnose unterminated regions", async () => {
const [python, unterminatedPython, go, rust, sql, terraform] = await Promise.all([
textInput("python", "app.py", "'''\nimport phantom\n'''\nimport real\n"),
textInput("python", "broken.py", "\"\"\"unterminated\nimport phantom\n"),
textInput("go", "main.go", "package main\n/*\nimport \"phantom\"\n*/\nimport \"fmt\"\n/* unterminated\n"),
textInput("rust", "lib.rs", "/*\nmod phantom;\nuse fake::Thing;\n*/\nmod api;\nuse serde::Serialize;\n/* unterminated\n"),
textInput("sql", "schema.sql", "/*\nCREATE TABLE phantom (id INT REFERENCES ghost(id));\n*/\nCREATE TABLE real (id INT);\n/* unterminated\n"),
textInput("terraform", "main.tf", [
"/*", "resource \"aws_vpc\" \"phantom\" {}", "*/",
"resource \"aws_vpc\" \"main\" {}",
"resource \"aws_subnet\" \"web\" {",
" description = \"aws_vpc.phantom.id\"",
" # fake = aws_vpc.phantom.id",
" vpc_id = aws_vpc.main.id",
"}", "/* unterminated",
].join("\n")),
]);
assert.deepEqual(python.diagram.pages[0].nodes.map((node) => node.label), ["app.py", "real"]);
assert.deepEqual(unterminatedPython.diagram.pages[0].nodes.map((node) => node.label), ["broken.py"]);
assert.deepEqual(go.diagram.pages[0].nodes.map((node) => node.label), ["main.go", "fmt"]);
assert.deepEqual(rust.diagram.pages[0].nodes.map((node) => node.label), ["lib.rs", "api", "serde"]);
assert.deepEqual(sql.diagram.pages[0].nodes.map((node) => node.label), ["real"]);
assert.deepEqual(terraform.diagram.pages[0].nodes.map((node) => node.label), ["aws_subnet.web", "aws_vpc.main"]);
assert.equal(terraform.diagram.pages[0].edges.length, 1);
for (const result of [unterminatedPython, go, rust, sql, terraform]) {
assert.ok(result.diagnostics.some((diagnostic) => diagnostic.code === "unknown-construct"));
}
});
test("multiline literals mask phantom topology and diagnose only unterminated regions", async () => {
const [go, rust, sql, terraform] = await Promise.all([
textInput("go", "main.go", [
"package main", "var first = `", "import \"phantom\"", "`", "import \"fmt\"",
].join("\n")),
textInput("rust", "lib.rs", [
"const FIRST: &str = r\"", "mod phantom_one;", "\";",
"const SECOND: &str = r##\"", "use phantom_two::Thing;", "\"##;",
"mod api;", "use serde::Serialize;",
].join("\n")),
textInput("sql", "schema.sql", [
"CREATE TABLE real (id INT);", "INSERT INTO real(note) VALUES ('",
"CREATE TABLE phantom (id INT REFERENCES ghost(id));", "escaped '' quote", "');",
].join("\n")),
textInput("terraform", "main.tf", [
"resource \"aws_vpc\" \"main\" {}", "resource \"aws_subnet\" \"web\" {",
" user_data = <<-EOT", " resource \"aws_instance\" \"phantom\" {}",
" aws_vpc.phantom.id", " EOT", " vpc_id = aws_vpc.main.id", "}",
].join("\n")),
]);
assert.deepEqual(go.diagram.pages[0].nodes.map((node) => node.label), ["main.go", "fmt"]);
assert.deepEqual(rust.diagram.pages[0].nodes.map((node) => node.label), ["lib.rs", "api", "serde"]);
assert.deepEqual(sql.diagram.pages[0].nodes.map((node) => node.label), ["real"]);
assert.deepEqual(terraform.diagram.pages[0].nodes.map((node) => node.label), ["aws_subnet.web", "aws_vpc.main"]);
assert.equal(terraform.diagram.pages[0].edges.length, 1);
for (const result of [go, rust, sql, terraform]) assert.deepEqual(result.diagnostics, []);
const unterminated = await Promise.all([
textInput("go", "broken.go", "package main\nvar value = `unterminated\nimport \"phantom\""),
textInput("rust", "broken.rs", "const VALUE: &str = r###\"unterminated\nmod phantom;"),
textInput("sql", "broken.sql", "CREATE TABLE real (id INT);\nINSERT INTO real(note) VALUES ('unterminated\nCREATE TABLE phantom (id INT);"),
textInput("terraform", "broken.tf", "resource \"aws_vpc\" \"main\" {}\nvalue = <<EOF\naws_vpc.phantom.id"),
]);
for (const result of unterminated) {
assert.equal(result.diagram.pages[0].nodes.some((node) => node.label.includes("phantom")), false);
assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["unknown-construct"]);
}
});
test("imports Kubernetes relationships and redacts Secret payloads", async () => {
const objects = [
{ apiVersion: "v1", kind: "Secret", metadata: { name: "db-secret" }, data: { password: "c2VjcmV0" }, stringData: { token: "plain" } },
{ apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "api" }, spec: { selector: { matchLabels: { app: "api" } }, template: { metadata: { labels: { app: "api" } }, spec: { containers: [{ envFrom: [{ secretRef: { name: "db-secret" } }] }] } } } },
{ apiVersion: "v1", kind: "Service", metadata: { name: "api-svc" }, spec: { selector: { app: "api" } } },
];
const result = await importSource({ sourceKind: "kubernetes", path: "k8s.json", input: { type: "object", value: objects } });
assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["Deployment/default/api", "Secret/default/db-secret", "Service/default/api-svc"]);
assert.deepEqual(result.diagram.pages[0].edges.map((e) => e.kind), ["uses-secret", "selects"]);
assert.equal(JSON.stringify(result).includes("c2VjcmV0"), false);
assert.equal(JSON.stringify(result).includes("plain"), false);
assert.equal(result.diagnostics[0].code, "secret-redacted");
});
test("Kubernetes ignores secretRef-shaped data outside supported workload fields", async () => {
const objects = [
{ kind: "Secret", metadata: { name: "real" } },
{ kind: "Secret", metadata: { name: "phantom" } },
{
kind: "Deployment", metadata: { name: "api" }, status: { secretRef: { name: "phantom" } },
spec: { template: { spec: { containers: [{ envFrom: [{ secretRef: { name: "real" } }] }] } } },
},
];
const result = await importSource({ sourceKind: "kubernetes", path: "k8s.json", input: { type: "object", value: objects } });
const labelById = new Map(result.diagram.pages[0].nodes.map((node) => [node.id, node.label]));
assert.deepEqual(result.diagram.pages[0].edges.map((edge) => [edge.kind, labelById.get(edge.target)]), [
["uses-secret", "Secret/default/real"],
]);
});
test("scopes Kubernetes identity, secret references, and non-empty Service selectors by namespace", async () => {
const objects = [
{ kind: "Secret", metadata: { name: "shared" } },
{ kind: "Secret", metadata: { namespace: "team-b", name: "shared" } },
{ kind: "Deployment", metadata: { name: "api" }, spec: { selector: { matchLabels: { selectorOnly: "wrong" } }, template: { metadata: { labels: { app: "api" } }, spec: { containers: [{ envFrom: [{ secretRef: { name: "shared" } }] }] } } } },
{ kind: "Deployment", metadata: { namespace: "team-b", name: "api" }, spec: { template: { metadata: { labels: { app: "api" } }, spec: { containers: [{ envFrom: [{ secretRef: { name: "shared" } }] }] } } } },
{ kind: "Deployment", metadata: { name: "selector-only" }, spec: { selector: { matchLabels: { app: "api" } }, template: { metadata: { labels: { app: "other" } } } } },
{ kind: "Service", metadata: { name: "api" }, spec: { selector: { app: "api" } } },
{ kind: "Service", metadata: { name: "empty" }, spec: { selector: {} } },
];
const result = await importSource({ sourceKind: "kubernetes", path: "k8s.json", input: { type: "object", value: objects } });
const nodes = result.diagram.pages[0].nodes;
assert.equal(new Set(nodes.map((node) => node.id)).size, nodes.length);
assert.ok(nodes.some((node) => node.label === "Secret/default/shared"));
assert.ok(nodes.some((node) => node.label === "Secret/team-b/shared"));
const labelById = new Map(nodes.map((node) => [node.id, node.label]));
const relationships = result.diagram.pages[0].edges.map((edge) => [labelById.get(edge.source), edge.kind, labelById.get(edge.target)]);
assert.deepEqual(relationships, [
["Deployment/default/api", "uses-secret", "Secret/default/shared"],
["Deployment/team-b/api", "uses-secret", "Secret/team-b/shared"],
["Service/default/api", "selects", "Deployment/default/api"],
]);
});
test("imports Docker Compose service dependencies and named volumes", async () => {
const result = await importSource({ sourceKind: "docker-compose", path: "compose.json", input: { type: "object", value: {
services: { api: { depends_on: ["db"], volumes: ["data:/var/lib/app"] }, db: { image: "postgres" } }, volumes: { data: {} },
} } });
assert.deepEqual(result.diagram.pages[0].nodes.map((n) => [n.label, n.kind]), [["api", "service"], ["db", "service"], ["data", "volume"]]);
assert.deepEqual(result.diagram.pages[0].edges.map((e) => e.kind), ["depends-on", "mounts"]);
});
test("imports SQL tables and foreign keys", async () => {
const result = await textInput("sql", "schema.sql", "CREATE TABLE users (id INT PRIMARY KEY);\nCREATE TABLE orders (\n user_id INT,\n FOREIGN KEY (user_id) REFERENCES users(id)\n);\n");
assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["orders", "users"]);
assert.deepEqual(result.diagram.pages[0].edges.map((e) => [e.source, e.target, e.kind]), [["table-6f7264657273", "table-7573657273", "foreign-key"]]);
assert.equal(result.diagram.pages[0].edges[0].provenance?.line, 4);
});
test("orders importer output by Unicode code point without locale-sensitive comparison", async () => {
const sql = await textInput("sql", "schema.sql", "CREATE TABLE a (id INT);\nCREATE TABLE Z (id INT);\n");
assert.deepEqual(sql.diagram.pages[0].nodes.map((node) => node.label), ["Z", "a"]);
const kubernetes = await importSource({ sourceKind: "kubernetes", path: "k8s.json", input: { type: "object", value: [
{ kind: "Service", metadata: { name: "a" }, spec: { selector: {} } },
{ kind: "Service", metadata: { name: "Z" }, spec: { selector: {} } },
{ kind: "Service", metadata: { name: "😀" }, spec: { selector: {} } },
{ kind: "Service", metadata: { name: "\uE000" }, spec: { selector: {} } },
] } });
assert.deepEqual(kubernetes.diagram.pages[0].nodes.map((node) => node.label), ["Service/default/Z", "Service/default/a", "Service/default/\uE000", "Service/default/😀"]);
});
test("imports OpenAPI operations and schema references", async () => {
const result = await importSource({ sourceKind: "openapi", path: "openapi.json", input: { type: "object", value: {
openapi: "3.1.0", paths: { "/pets": { get: { operationId: "listPets", responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Pet" } } } } } } } },
components: { schemas: { Pet: { type: "object" } } },
} } });
assert.deepEqual(result.diagram.pages[0].nodes.map((n) => [n.label, n.kind]), [["GET /pets", "command"], ["Pet", "library"]]);
assert.equal(result.diagram.pages[0].edges[0].kind, "schema-reference");
});
test("OpenAPI inherited schema names remain unknown references", async () => {
const result = await importSource({ sourceKind: "openapi", path: "openapi.json", input: { type: "object", value: {
paths: { "/unsafe": { get: { responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/toString" } } } } } } } },
components: { schemas: {} },
} } });
assert.deepEqual(result.diagram.pages[0].nodes.map((node) => node.label), ["GET /unsafe"]);
assert.deepEqual(result.diagram.pages[0].edges, []);
assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["unknown-reference"]);
});
test("imports CI job DAG needs", async () => {
const result = await importSource({ sourceKind: "ci", path: ".github/workflows/ci.json", input: { type: "object", value: {
jobs: { deploy: { needs: ["build", "test"] }, test: { needs: "build" }, build: { "runs-on": "ubuntu-latest" } },
} } });
assert.deepEqual(result.diagram.pages[0].nodes.map((n) => [n.label, n.kind]), [["build", "command"], ["deploy", "command"], ["test", "command"]]);
assert.deepEqual(result.diagram.pages[0].edges.map((e) => [e.source, e.target]), [["command-6275696c64", "command-6465706c6f79"], ["command-74657374", "command-6465706c6f79"], ["command-6275696c64", "command-74657374"]]);
});
test("keeps generated IDs unique for normalized names, ambiguous composites, and repeated relationships", async () => {
const result = await importSource({ sourceKind: "ci", path: "ci.json", input: { type: "object", value: {
jobs: {
A: {},
"A": {},
a: {},
"a-needs-b": {},
"b-needs-c": { needs: ["a", "a"] },
c: { needs: "a-needs-b" },
},
} } });
const nodes = result.diagram.pages[0].nodes;
const edges = result.diagram.pages[0].edges;
assert.equal(new Set(nodes.map((node) => node.id)).size, nodes.length);
assert.equal(new Set(edges.map((edge) => edge.id)).size, edges.length);
assert.equal(edges.length, 3);
assert.doesNotThrow(() => validateDiagramIR(result.diagram));
});
test("keeps sanitized IDs unique and reports duplicate logical definitions", async () => {
const openapi = await importSource({ sourceKind: "openapi", path: "api.json", input: { type: "object", value: { paths: { "/x": { get: {} } }, components: { schemas: { "A/B": {}, "A-B": {} } } } } });
assert.equal(new Set(openapi.diagram.pages[0].nodes.map((n) => n.id)).size, openapi.diagram.pages[0].nodes.length);
const sql = await textInput("sql", "schema.sql", "CREATE TABLE users (id INT);\nCREATE TABLE users (id INT);\n");
assert.equal(sql.diagnostics[0].code, "duplicate-id");
assert.doesNotThrow(() => validateDiagramIR(openapi.diagram));
});
test("disambiguates every repeated relationship occurrence deterministically", async () => {
const sqlText = "CREATE TABLE users (id INT PRIMARY KEY);\nCREATE TABLE orders (\n a INT REFERENCES users(id),\n b INT REFERENCES users(id)\n);\n";
const terraformText = "resource \"aws_vpc\" \"main\" {}\nresource \"aws_subnet\" \"web\" {\n first = aws_vpc.main.id\n second = aws_vpc.main.id\n}\n";
const composeValue = { services: { api: { depends_on: ["db", "db"], volumes: ["data:/one", "data:/two"] }, db: {} }, volumes: { data: {} } };
const results = await Promise.all([
textInput("sql", "schema.sql", sqlText),
textInput("terraform", "main.tf", terraformText),
importSource({ sourceKind: "docker-compose", path: "compose.json", input: { type: "object", value: composeValue } }),
]);
const expectedEdgeCounts = [2, 2, 4];
results.forEach((result, index) => {
const edges = result.diagram.pages[0].edges;
assert.equal(edges.length, expectedEdgeCounts[index]);
assert.equal(new Set(edges.map((edge) => edge.id)).size, edges.length);
assert.doesNotThrow(() => validateDiagramIR(result.diagram));
});
const again = await textInput("terraform", "main.tf", terraformText);
assert.deepEqual(again.diagram.pages[0].edges.map((edge) => edge.id), results[1].diagram.pages[0].edges.map((edge) => edge.id));
});
test("rejects oversized and malformed inputs without executing object values", async () => {
await assert.rejects(() => importSource({ sourceKind: "python", path: "x.py", maxBytes: 4, input: { type: "text", text: "import os" } }), /exceeds.*4/i);
await assert.rejects(() => importSource({ sourceKind: "ci", path: "ci.json", input: { type: "object", value: { jobs: null } } }), /malformed CI/i);
let accessed = false;
const tagged = Object.create({ yamlTag: "!exec" }) as Record<string, unknown>;
Object.defineProperty(tagged, "jobs", { enumerable: true, get() { accessed = true; return {}; } });
await assert.rejects(() => importSource({ sourceKind: "ci", path: "ci.json", input: { type: "object", value: tagged } }), /plain JSON/i);
assert.equal(accessed, false);
});
test("rejects object input beyond the conservative nesting bound without stack overflow", async () => {
const root: Record<string, unknown> = {};
let cursor = root;
for (let depth = 0; depth < 10_000; depth += 1) {
const child: Record<string, unknown> = {};
cursor.child = child;
cursor = child;
}
await assert.rejects(
() => importSource({ sourceKind: "ci", path: "ci.json", input: { type: "object", value: root } }),
/nesting.*128/i,
);
});
test("rejects file traversal and symlink escapes from the declared root", async () => {
const base = await mkdtemp(join(tmpdir(), "source-import-"));
const root = join(base, "root"); await mkdir(root);
await writeFile(join(base, "outside.py"), "import os\n");
await symlink(join(base, "outside.py"), join(root, "escape.py"));
await assert.rejects(() => importSource({ sourceKind: "python", path: "../outside.py", input: { type: "file", root } }), /outside.*root|escape/i);
await assert.rejects(() => importSource({ sourceKind: "python", path: "escape.py", input: { type: "file", root } }), /outside.*root|symlink.*escape/i);
});
test("refuses even root-confined symlinks so substitution cannot change an opened source", async () => {
const root = await mkdtemp(join(tmpdir(), "source-no-follow-"));
await writeFile(join(root, "real.py"), "import safe\n");
await symlink("real.py", join(root, "substituted.py"));
await assert.rejects(
() => importSource({ sourceKind: "python", path: "substituted.py", input: { type: "file", root } }),
/symlink|symbolic|safe source/i,
);
});
test("ingests directories in deterministic path order with confined provenance", async () => {
const root = await mkdtemp(join(tmpdir(), "source-directory-"));
await mkdir(join(root, "src"));
await writeFile(join(root, "src", "b.py"), "import zlib\n");
await writeFile(join(root, "src", "a.py"), "from pkg import x\n");
const request = { sourceKind: "python" as const, path: "src", input: { type: "file" as const, root } };
const first = await importSource(request); const second = await importSource(request);
assert.equal(JSON.stringify(first), JSON.stringify(second));
assert.deepEqual(first.diagram.pages[0].nodes.filter((n) => n.kind === "module").map((n) => n.label), ["src/a.py", "src/b.py"]);
assert.deepEqual(first.diagram.pages[0].nodes.filter((n) => n.kind === "library").map((n) => n.label), ["pkg", "zlib"]);
assert.equal(first.diagram.pages[0].edges[0].provenance?.path, "src/a.py");
assert.doesNotThrow(() => validateDiagramIR(first.diagram));
});
test("reports dynamic code imports as unknown constructs instead of guessing", async () => {
const python = await textInput("python", "dynamic.py", "module = __import__(name)\n");
const javascript = await textInput("javascript", "dynamic.js", "const module = await import(name);\n");
assert.equal(python.diagnostics[0].code, "unknown-construct");
assert.equal(javascript.diagnostics[0].code, "unknown-construct");
assert.deepEqual(python.diagram.pages[0].nodes.map((n) => n.label), ["dynamic.py"]);
});
test("loads YAML data without permitting custom tags", async () => {
const root = await mkdtemp(join(tmpdir(), "source-yaml-"));
await writeFile(join(root, "objects.yaml"), "apiVersion: v1\nkind: Service\nmetadata:\n name: api\nspec:\n selector:\n app: api\n");
await writeFile(join(root, "tagged.yaml"), "jobs: !exec dangerous\n");
const result = await importSource({ sourceKind: "kubernetes", path: "objects.yaml", input: { type: "file", root } });
assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["Service/default/api"]);
await assert.rejects(() => importSource({ sourceKind: "ci", path: "tagged.yaml", input: { type: "file", root } }), /tag|malformed YAML/i);
});
test("rejects a FIFO inside a source directory promptly instead of blocking on open()", async () => {
const root = await mkdtemp(join(tmpdir(), "source-fifo-"));
await writeFile(join(root, "a.py"), "import os\n");
const made = spawnSync("mkfifo", [join(root, "pipe")]);
if (made.status !== 0) return; // platform without mkfifo: nothing to regress
const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error("importSource hung on the FIFO")), 5_000).unref());
await assert.rejects(() => Promise.race([importSource({ sourceKind: "python", path: ".", input: { type: "file", root } }), timeout]), /unsupported entry type/);
await assert.rejects(() => Promise.race([importSource({ sourceKind: "python", path: "pipe", input: { type: "file", root } }), timeout]), /unsupported entry type/);
});
test("Rust macro detection is linear on long lines and still flags macro invocations", async () => {
const started = performance.now();
const long = await textInput("rust", "long.rs", `fn main() {}\n${"a".repeat(200_000)}\n`);
assert.ok(performance.now() - started < 200, `took ${(performance.now() - started).toFixed(0)}ms`);
assert.deepEqual(long.diagnostics.filter((d) => /macros/.test(d.message)), []);
const macros = await textInput("rust", "macros.rs", "println!(\"hi\");\nfoo! (1);\nlet x = a != b;\n! (not);\n");
assert.deepEqual(macros.diagnostics.filter((d) => /macros/.test(d.message)).map((d) => d.line), [1, 2]);
});
@@ -0,0 +1,791 @@
import { constants } from "node:fs";
import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { isAbsolute, relative, resolve } from "node:path";
import { JSON_SCHEMA, loadAll } from "js-yaml";
import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export type SourceKind = "python" | "javascript" | "typescript" | "go" | "rust" | "terraform" | "kubernetes" | "docker-compose" | "sql" | "openapi" | "ci";
export type SourceInput = { type: "text"; text: string } | { type: "object"; value: unknown } | { type: "file"; root: string };
export interface SourceImportRequest { sourceKind: SourceKind; path: string; input: SourceInput; maxBytes?: number }
export interface ImportDiagnostic { code: string; severity: "warning" | "error"; message: string; path: string; line?: number }
export interface SourceImportResult { diagram: DiagramIRV2; diagnostics: ImportDiagnostic[]; provenance: { sourceKind: SourceKind; path: string } }
function id(prefix: string, ...values: string[]): string {
const encoded = values.map((value) => Buffer.from(value, "utf8").toString("hex") || "0");
return `${prefix}-${encoded.join(".")}`;
}
function codePointCompare(left: string, right: string): number {
const leftPoints = [...left];
const rightPoints = [...right];
const length = Math.min(leftPoints.length, rightPoints.length);
for (let index = 0; index < length; index += 1) {
const difference = leftPoints[index].codePointAt(0)! - rightPoints[index].codePointAt(0)!;
if (difference !== 0) return difference;
}
return leftPoints.length - rightPoints.length;
}
function diagramFor(request: SourceImportRequest, nodes: DiagramNode[], edges: DiagramEdge[]): DiagramIRV2 {
return validateDiagramIR({ version: 2, title: request.path, pages: [{ id: "source-import", title: request.path, nodes, edges, layout: { type: "layered", direction: "horizontal" } }], provenance: { sourceKind: request.sourceKind, path: request.path } }) as DiagramIRV2;
}
function codeImports(kind: "python" | "go", text: string): Array<{ name: string; line: number }> {
const imports: Array<{ name: string; line: number }> = [];
let goBlock = false;
text.split(/\r?\n/).forEach((line, index) => {
let name: string | undefined;
if (kind === "python") {
const match = line.match(/^\s*(?:import\s+([A-Za-z_][\w.]*)|from\s+([A-Za-z_][\w.]*)\s+import\s+)/);
name = match?.[1] ?? match?.[2];
} else if (kind === "go") {
if (/^\s*import\s*\(\s*$/.test(line)) { goBlock = true; return; }
if (goBlock && /^\s*\)/.test(line)) { goBlock = false; return; }
const match = goBlock ? line.match(/^\s*(?:[._A-Za-z]\w*\s+)?"([^"]+)"/) : line.match(/^\s*import\s+(?:[._A-Za-z]\w*\s+)?"([^"]+)"/);
name = match?.[1];
}
if (name) imports.push({ name, line: index + 1 });
});
return imports;
}
interface MaskedSource { text: string; unknownLines: number[] }
function maskRange(characters: string[], start: number, end: number): void {
for (let index = start; index < end; index += 1) if (characters[index] !== "\n" && characters[index] !== "\r") characters[index] = " ";
}
function maskPythonTripleStrings(text: string): MaskedSource {
const characters = text.split("");
const unknownLines: number[] = [];
let offset = 0; let line = 1;
while (offset < text.length) {
if (text[offset] === "\n") { line += 1; offset += 1; continue; }
const quote = text.startsWith("'''", offset) ? "'''" : text.startsWith('"""', offset) ? '"""' : undefined;
if (!quote) { offset += 1; continue; }
const start = offset; const startLine = line;
offset += 3;
let closed = false;
while (offset < text.length) {
if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; }
if (text[offset] === "\n") line += 1;
if (text.startsWith(quote, offset)) { offset += 3; closed = true; break; }
offset += 1;
}
maskRange(characters, start, offset);
if (!closed) unknownLines.push(startLine);
}
return { text: characters.join(""), unknownLines };
}
function maskComments(text: string, lineMarkers: string[], nestedBlock = false, maskedQuotes = "", rustRawStrings = false): MaskedSource {
const characters = text.split("");
const unknownLines: number[] = [];
let offset = 0; let line = 1;
while (offset < text.length) {
if (text[offset] === "\n") { line += 1; offset += 1; continue; }
const marker = lineMarkers.find((candidate) => text.startsWith(candidate, offset));
if (marker) {
const start = offset;
while (offset < text.length && text[offset] !== "\n") offset += 1;
maskRange(characters, start, offset);
continue;
}
if (text.startsWith("/*", offset)) {
const start = offset; const startLine = line;
offset += 2;
let depth = 1;
while (offset < text.length && depth > 0) {
if (text[offset] === "\n") line += 1;
if (nestedBlock && text.startsWith("/*", offset)) { depth += 1; offset += 2; continue; }
if (text.startsWith("*/", offset)) { depth -= 1; offset += 2; continue; }
offset += 1;
}
maskRange(characters, start, offset);
if (depth > 0) unknownLines.push(startLine);
continue;
}
if (rustRawStrings && text[offset] === "r") {
let delimiterEnd = offset + 1;
while (text[delimiterEnd] === "#") delimiterEnd += 1;
if (text[delimiterEnd] === '"') {
const start = offset; const startLine = line;
const terminator = `"${"#".repeat(delimiterEnd - offset - 1)}`;
offset = delimiterEnd + 1;
const rawEnd = text.indexOf(terminator, offset);
const closed = rawEnd >= 0;
const next = closed ? rawEnd + terminator.length : text.length;
line += (text.slice(offset, next).match(/\n/g) ?? []).length;
offset = next;
maskRange(characters, start, offset);
if (!closed) unknownLines.push(startLine);
continue;
}
}
if (text[offset] === "'" || text[offset] === '"' || text[offset] === "`") {
const quote = text[offset]; const start = offset; const startLine = line;
offset += 1;
let closed = false;
while (offset < text.length) {
if (text[offset] === "\\" && quote !== "`" && offset + 1 < text.length) { offset += 2; continue; }
if (quote === "'" && text[offset] === "'" && text[offset + 1] === "'") { offset += 2; continue; }
if (text[offset] === "\n") line += 1;
if (text[offset] === quote) { offset += 1; closed = true; break; }
offset += 1;
}
if (maskedQuotes.includes(quote)) maskRange(characters, start, offset);
if (!closed) unknownLines.push(startLine);
continue;
}
offset += 1;
}
return { text: characters.join(""), unknownLines };
}
function maskTerraformHeredocs(text: string): MaskedSource {
const characters = text.split("");
const unknownLines: number[] = [];
const heredocOpening = /<<(-?)([A-Za-z_]\w*)[ \t]*(?:\r?\n)/y;
let offset = 0; let line = 1;
while (offset < text.length) {
if (text[offset] === "\n") { line += 1; offset += 1; continue; }
if (text[offset] === "#" || text.startsWith("//", offset)) {
while (offset < text.length && text[offset] !== "\n") offset += 1;
continue;
}
if (text.startsWith("/*", offset)) {
const end = text.indexOf("*/", offset + 2);
const next = end < 0 ? text.length : end + 2;
line += (text.slice(offset, next).match(/\n/g) ?? []).length;
offset = next;
continue;
}
if (text[offset] === '"') {
offset += 1;
while (offset < text.length) {
if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; }
if (text[offset] === "\n") line += 1;
if (text[offset] === '"') { offset += 1; break; }
offset += 1;
}
continue;
}
heredocOpening.lastIndex = offset;
const opening = heredocOpening.exec(text);
if (!opening) { offset += 1; continue; }
const start = offset; const startLine = line;
const indented = opening[1] === "-";
const delimiter = opening[2];
offset += opening[0].length;
line += 1;
let closed = false;
while (offset < text.length) {
const newline = text.indexOf("\n", offset);
const end = newline < 0 ? text.length : newline;
const candidate = text.slice(offset, end).replace(/\r$/, "");
const expected = indented ? candidate.trim() : candidate.trimEnd();
if (expected === delimiter && (indented || candidate === expected)) {
offset = end;
closed = true;
break;
}
offset = newline < 0 ? text.length : newline + 1;
if (newline >= 0) line += 1;
}
maskRange(characters, start, offset);
if (!closed) unknownLines.push(startLine);
}
return { text: characters.join(""), unknownLines };
}
function maskTerraformStrings(text: string): MaskedSource {
const characters = text.split("");
const unknownLines: number[] = [];
let offset = 0; let line = 1;
while (offset < text.length) {
if (text[offset] === "\n") { line += 1; offset += 1; continue; }
if (text[offset] !== '"') { offset += 1; continue; }
const start = offset; const startLine = line;
offset += 1;
let closed = false;
while (offset < text.length) {
if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; }
if (text[offset] === "\n") line += 1;
if (text[offset] === '"') { offset += 1; closed = true; break; }
offset += 1;
}
maskRange(characters, start, offset);
if (!closed) unknownLines.push(startLine);
}
return { text: characters.join(""), unknownLines };
}
interface JavaScriptToken { kind: "identifier" | "string" | "punctuation"; value: string; line: number }
function scanJavaScriptImports(text: string): { imports: Array<{ name: string; line: number }>; unknownLines: number[] } {
const tokens: JavaScriptToken[] = [];
const unknownLines = new Set<number>();
let offset = 0;
let line = 1;
while (offset < text.length) {
const character = text[offset];
if (character === "\n") { line += 1; offset += 1; continue; }
if (/\s/.test(character)) { offset += 1; continue; }
if (character === "/" && text[offset + 1] === "/") {
offset += 2;
while (offset < text.length && text[offset] !== "\n") offset += 1;
continue;
}
if (character === "/" && text[offset + 1] === "*") {
const startLine = line;
offset += 2;
let closed = false;
while (offset < text.length) {
if (text[offset] === "\n") line += 1;
if (text[offset] === "*" && text[offset + 1] === "/") { offset += 2; closed = true; break; }
offset += 1;
}
if (!closed) unknownLines.add(startLine);
continue;
}
if (character === "/") {
const previous = tokens[tokens.length - 1];
const followsArrow = previous?.value === ">" && tokens[tokens.length - 2]?.value === "=";
const regexPrefix = !previous || followsArrow || (previous.kind === "punctuation" && "=([{,:;!&|?".includes(previous.value)) || (previous.kind === "identifier" && new Set(["return", "case", "throw", "typeof", "instanceof", "in", "of", "yield", "await"]).has(previous.value));
if (regexPrefix) {
const startLine = line;
let inCharacterClass = false;
let closed = false;
offset += 1;
while (offset < text.length) {
if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; }
if (text[offset] === "\n") break;
if (text[offset] === "[") inCharacterClass = true;
else if (text[offset] === "]") inCharacterClass = false;
else if (text[offset] === "/" && !inCharacterClass) { offset += 1; closed = true; break; }
offset += 1;
}
while (closed && offset < text.length && /[A-Za-z]/.test(text[offset])) offset += 1;
if (!closed) unknownLines.add(startLine);
continue;
}
}
if (character === "'" || character === '"') {
const quote = character;
const startLine = line;
let value = "";
offset += 1;
let closed = false;
while (offset < text.length) {
const child = text[offset];
if (child === "\\" && offset + 1 < text.length) { value += child + text[offset + 1]; offset += 2; continue; }
if (child === quote) { offset += 1; closed = true; break; }
if (child === "\n") line += 1;
value += child;
offset += 1;
}
if (closed) tokens.push({ kind: "string", value, line: startLine });
else unknownLines.add(startLine);
continue;
}
if (character === "`") {
const startLine = line;
let closed = false;
let hasExpression = false;
offset += 1;
while (offset < text.length) {
if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; }
if (text[offset] === "$" && text[offset + 1] === "{") hasExpression = true;
if (text[offset] === "\n") line += 1;
if (text[offset] === "`") { offset += 1; closed = true; break; }
offset += 1;
}
if (hasExpression || !closed) unknownLines.add(startLine);
continue;
}
if (/[A-Za-z_$]/.test(character)) {
const start = offset;
offset += 1;
while (offset < text.length && /[A-Za-z0-9_$]/.test(text[offset])) offset += 1;
tokens.push({ kind: "identifier", value: text.slice(start, offset), line });
continue;
}
tokens.push({ kind: "punctuation", value: character, line });
offset += 1;
}
const imports: Array<{ name: string; line: number }> = [];
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (token.kind !== "identifier") continue;
if (token.value === "require" && tokens[index - 1]?.value !== "." && tokens[index + 1]?.value === "(") {
const argument = tokens[index + 2];
if (argument?.kind === "string" && tokens[index + 3]?.value === ")") imports.push({ name: argument.value, line: token.line });
else unknownLines.add(token.line);
continue;
}
if (token.value !== "import" && token.value !== "export") continue;
if (tokens[index - 1]?.value === ".") continue;
const next = tokens[index + 1];
if (token.value === "import" && next?.value === "(") { unknownLines.add(token.line); continue; }
const previous = tokens[index - 1];
const statementPosition = !previous || previous.value === ";" || previous.value === "}" || previous.line < token.line;
if (!statementPosition) continue;
if (token.value === "import" && next?.kind === "string") { imports.push({ name: next.value, line: token.line }); continue; }
let found = false;
for (let cursor = index + 1; cursor < tokens.length && cursor <= index + 256; cursor += 1) {
if (tokens[cursor].value === ";") break;
if (tokens[cursor].value === "from" && tokens[cursor + 1]?.kind === "string") {
imports.push({ name: tokens[cursor + 1].value, line: token.line });
found = true;
break;
}
}
if (!found && token.value === "import") unknownLines.add(token.line);
}
return { imports, unknownLines: [...unknownLines].sort((a, b) => a - b) };
}
function record(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
}
function objectName(value: unknown): string | undefined {
const name = record(record(value)?.metadata)?.name;
return typeof name === "string" && name.length > 0 ? name : undefined;
}
function collectWorkloadSecretRefs(podSpecValue: unknown, refs: Set<string>): void {
const podSpec = record(podSpecValue);
if (!podSpec) return;
const addName = (value: unknown): void => {
const name = record(value)?.name;
if (typeof name === "string" && name.length > 0) refs.add(name);
};
for (const pullSecret of Array.isArray(podSpec.imagePullSecrets) ? podSpec.imagePullSecrets : []) addName(pullSecret);
for (const containerKey of ["initContainers", "containers", "ephemeralContainers"]) {
for (const containerValue of Array.isArray(podSpec[containerKey]) ? podSpec[containerKey] as unknown[] : []) {
const container = record(containerValue);
if (!container) continue;
for (const source of Array.isArray(container.envFrom) ? container.envFrom : []) addName(record(source)?.secretRef);
for (const environment of Array.isArray(container.env) ? container.env : []) addName(record(record(environment)?.valueFrom)?.secretKeyRef);
}
}
for (const volumeValue of Array.isArray(podSpec.volumes) ? podSpec.volumes : []) {
const volume = record(volumeValue);
const secretName = record(volume?.secret)?.secretName;
if (typeof secretName === "string" && secretName.length > 0) refs.add(secretName);
const sources = record(volume?.projected)?.sources;
for (const source of Array.isArray(sources) ? sources : []) addName(record(source)?.secret);
}
}
function collectSchemaRefs(value: unknown, refs: Set<string>, seen = new WeakSet<object>()): void {
if (value === null || typeof value !== "object" || seen.has(value)) return;
seen.add(value);
if (Array.isArray(value)) { value.forEach((item) => collectSchemaRefs(item, refs, seen)); return; }
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
if (key === "$ref" && typeof child === "string" && child.startsWith("#/components/schemas/")) refs.add(child.slice("#/components/schemas/".length));
else collectSchemaRefs(child, refs, seen);
}
}
function validateBoundedInput(request: SourceImportRequest): void {
const limit = request.maxBytes ?? 1_048_576;
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 8_388_608) throw new Error("maxBytes must be an integer between 1 and 8388608");
if (!request.path || request.path.includes("\0")) throw new Error("Source path must be a non-empty safe string");
if (request.input.type === "file") return;
if (request.input.type === "text") {
const size = new TextEncoder().encode(request.input.text).byteLength;
if (size > limit) throw new Error(`Source input exceeds maxBytes ${limit}`);
return;
}
let size = 0;
const seen = new WeakSet<object>();
const pending: Array<{ value: unknown; depth: number }> = [{ value: request.input.value, depth: 0 }];
while (pending.length > 0) {
const { value, depth } = pending.pop()!;
if (depth > 128) throw new Error("Object input nesting exceeds maximum depth 128");
if (value === null || typeof value === "boolean") size += 4;
else if (typeof value === "number") {
if (!Number.isFinite(value)) throw new Error("Object input must contain finite JSON values");
size += 8;
} else if (typeof value === "string") size += new TextEncoder().encode(value).byteLength;
else {
if (typeof value !== "object") throw new Error("Object input must contain plain JSON values");
if (seen.has(value)) throw new Error("Object input must contain acyclic plain JSON values");
seen.add(value);
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error("Object input must contain plain JSON objects");
const descriptors = Object.getOwnPropertyDescriptors(value);
for (const [key, descriptor] of Object.entries(descriptors)) {
if (key === "length") continue;
if (!("value" in descriptor)) throw new Error("Object input must contain plain JSON data properties");
size += new TextEncoder().encode(key).byteLength;
if (size > limit) throw new Error(`Source input exceeds maxBytes ${limit}`);
pending.push({ value: descriptor.value, depth: depth + 1 });
}
}
if (size > limit) throw new Error(`Source input exceeds maxBytes ${limit}`);
}
}
async function importInline(request: SourceImportRequest): Promise<SourceImportResult> {
validateBoundedInput(request);
if (request.sourceKind === "ci" && request.input.type === "object") {
const jobs = record(record(request.input.value)?.jobs);
if (!jobs || Object.keys(jobs).length === 0) throw new Error("Malformed CI input: jobs must be a non-empty object");
const names = Object.keys(jobs).sort(codePointCompare);
const nodes: DiagramNode[] = names.map((name) => ({ id: id("command", name), label: name, kind: "command", provenance: { path: request.path } }));
const edges: DiagramEdge[] = []; const diagnostics: ImportDiagnostic[] = [];
for (const name of names) {
const rawNeeds = record(jobs[name])?.needs;
const needs = (Array.isArray(rawNeeds) ? rawNeeds : rawNeeds === undefined ? [] : [rawNeeds]).filter((value): value is string => typeof value === "string").sort(codePointCompare);
for (const dependency of needs) {
if (!names.includes(dependency)) { diagnostics.push({ code: "unknown-reference", severity: "warning", message: `Unknown CI job: ${dependency}`, path: request.path }); continue; }
edges.push({ id: id("edge", dependency, "needs", name, String(edges.length)), source: id("command", dependency), target: id("command", name), kind: "needs", provenance: { path: request.path } });
}
}
return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } };
}
if (request.sourceKind === "openapi" && request.input.type === "object") {
const root = record(request.input.value); const paths = record(root?.paths); const schemas = record(record(root?.components)?.schemas) ?? {};
if (!root || !paths) throw new Error("Malformed OpenAPI input: paths must be an object");
const nodes: DiagramNode[] = []; const edges: DiagramEdge[] = []; const diagnostics: ImportDiagnostic[] = [];
const methods = new Set(["get", "put", "post", "delete", "patch", "options", "head", "trace"]);
for (const path of Object.keys(paths).sort(codePointCompare)) {
const pathItem = record(paths[path]) ?? {};
for (const method of Object.keys(pathItem).filter((key) => methods.has(key.toLowerCase())).sort(codePointCompare)) {
const operationId = id("command", method, path);
nodes.push({ id: operationId, label: `${method.toUpperCase()} ${path}`, kind: "command", provenance: { path: request.path } });
const refs = new Set<string>(); collectSchemaRefs(pathItem[method], refs);
for (const schema of [...refs].sort(codePointCompare)) {
if (!Object.prototype.hasOwnProperty.call(schemas, schema)) { diagnostics.push({ code: "unknown-reference", severity: "warning", message: `Unknown schema: ${schema}`, path: request.path }); continue; }
edges.push({ id: id("edge", method, path, schema, String(edges.length)), source: operationId, target: id("schema", schema), kind: "schema-reference", provenance: { path: request.path } });
}
}
}
for (const schema of Object.keys(schemas).sort(codePointCompare)) nodes.push({ id: id("schema", schema), label: schema, kind: "library", provenance: { path: request.path } });
return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } };
}
if (request.sourceKind === "sql" && request.input.type === "text") {
const scanned = maskComments(request.input.text, ["--"], false, "'");
const tables = new Map<string, number>(); const refs: Array<{ source: string; target: string; line: number }> = [];
const diagnostics: ImportDiagnostic[] = scanned.unknownLines.map((line) => ({ code: "unknown-construct", severity: "warning", message: "SQL construct is outside the bounded static subset", path: request.path, line }));
let current: string | undefined;
scanned.text.split(/\r?\n/).forEach((line, index) => {
const declaration = line.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`\[]?([A-Za-z_]\w*)/i)?.[1];
if (declaration) {
if (tables.has(declaration)) diagnostics.push({ code: "duplicate-id", severity: "warning", message: `Duplicate table: ${declaration}`, path: request.path, line: index + 1 });
else tables.set(declaration, index + 1);
current = declaration;
}
const target = line.match(/\bREFERENCES\s+["`\[]?([A-Za-z_]\w*)/i)?.[1];
if (current && target) refs.push({ source: current, target, line: index + 1 });
if (/\)\s*;/.test(line)) current = undefined;
});
const nodes: DiagramNode[] = [...tables].sort(([a], [b]) => codePointCompare(a, b)).map(([name, line]) => ({ id: id("table", name), label: name, kind: "table", provenance: { path: request.path, line } }));
const edges: DiagramEdge[] = refs.filter((ref) => tables.has(ref.target)).map((ref, occurrence) => ({ id: id("edge", ref.source, "fk", ref.target, String(occurrence)), source: id("table", ref.source), target: id("table", ref.target), kind: "foreign-key", provenance: { path: request.path, line: ref.line } }));
return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } };
}
if (request.sourceKind === "docker-compose" && request.input.type === "object") {
const root = record(request.input.value);
const services = record(root?.services);
const volumes = record(root?.volumes) ?? {};
if (!services || Object.keys(services).length === 0) throw new Error("Malformed Docker Compose input: services must be a non-empty object");
const serviceNames = Object.keys(services).sort(codePointCompare); const volumeNames = Object.keys(volumes).sort(codePointCompare);
const nodes: DiagramNode[] = [
...serviceNames.map((name) => ({ id: id("service", name), label: name, kind: "service", provenance: { path: request.path } })),
...volumeNames.map((name) => ({ id: id("volume", name), label: name, kind: "volume", provenance: { path: request.path } })),
];
const edges: DiagramEdge[] = []; const diagnostics: ImportDiagnostic[] = [];
for (const name of serviceNames) {
const service = record(services[name]) ?? {};
const depends = Array.isArray(service.depends_on) ? service.depends_on : Object.keys(record(service.depends_on) ?? {});
for (const dependency of depends.filter((value): value is string => typeof value === "string").sort(codePointCompare)) {
if (!serviceNames.includes(dependency)) { diagnostics.push({ code: "unknown-reference", severity: "warning", message: `Unknown service: ${dependency}`, path: request.path }); continue; }
edges.push({ id: id("edge", name, "depends", dependency, String(edges.length)), source: id("service", name), target: id("service", dependency), kind: "depends-on", provenance: { path: request.path } });
}
for (const mount of (Array.isArray(service.volumes) ? service.volumes : []).filter((value): value is string => typeof value === "string").sort(codePointCompare)) {
const volume = mount.split(":", 1)[0];
if (volumeNames.includes(volume)) edges.push({ id: id("edge", name, "mounts", volume, String(edges.length)), source: id("service", name), target: id("volume", volume), kind: "mounts", provenance: { path: request.path } });
}
}
return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } };
}
if (request.sourceKind === "kubernetes" && request.input.type === "object") {
const values = Array.isArray(request.input.value) ? request.input.value : [request.input.value];
const rawObjects = values.map(record).filter((value): value is Record<string, unknown> => Boolean(value && typeof value.kind === "string" && objectName(value)));
const occurrences = new Map<string, number>();
const objects = rawObjects.map((value) => {
const kind = String(value.kind);
const name = objectName(value)!;
const rawNamespace = record(value.metadata)?.namespace;
const namespace = typeof rawNamespace === "string" && rawNamespace.length > 0 ? rawNamespace : "default";
const key = JSON.stringify([kind, namespace, name]);
const occurrence = occurrences.get(key) ?? 0;
occurrences.set(key, occurrence + 1);
return { value, kind, name, namespace, nodeId: id("k8s", kind, namespace, name, String(occurrence)) };
});
const diagnostics: ImportDiagnostic[] = objects.filter((object) => object.kind === "Secret").map(() => ({ code: "secret-redacted", severity: "warning", message: "Kubernetes Secret payload omitted", path: request.path }));
const nodes: DiagramNode[] = objects.map((object) => ({
id: object.nodeId,
label: `${object.kind}/${object.namespace}/${object.name}`,
kind: object.kind.toLowerCase(),
provenance: { path: request.path },
})).sort((a, b) => codePointCompare(a.label, b.label));
const byKey = new Map<string, typeof objects[number]>();
for (const object of objects) {
const key = JSON.stringify([object.kind, object.namespace, object.name]);
if (!byKey.has(key)) byKey.set(key, object);
}
const workloadKinds = new Set(["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet", "Job"]);
const edges: DiagramEdge[] = [];
for (const object of objects) {
const secretRefs = new Set<string>();
if (workloadKinds.has(object.kind)) collectWorkloadSecretRefs(record(record(object.value.spec)?.template)?.spec, secretRefs);
for (const target of [...secretRefs].sort(codePointCompare)) {
const secret = byKey.get(JSON.stringify(["Secret", object.namespace, target]));
if (secret) edges.push({ id: id("edge", object.nodeId, "secret", secret.nodeId, String(edges.length)), source: object.nodeId, target: secret.nodeId, kind: "uses-secret", provenance: { path: request.path } });
}
if (object.kind === "Service") {
const selector = record(record(object.value.spec)?.selector);
if (!selector || Object.keys(selector).length === 0) continue;
for (const workload of objects.filter((candidate) => workloadKinds.has(candidate.kind) && candidate.namespace === object.namespace)) {
const labels = record(record(record(record(workload.value.spec)?.template)?.metadata)?.labels);
if (labels && Object.entries(selector).every(([key, selected]) => labels[key] === selected)) {
edges.push({ id: id("edge", object.nodeId, "selects", workload.nodeId, String(edges.length)), source: object.nodeId, target: workload.nodeId, kind: "selects", provenance: { path: request.path } });
}
}
}
}
return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } };
}
if (request.sourceKind === "terraform" && request.input.type === "text") {
const heredocs = maskTerraformHeredocs(request.input.text);
const structural = maskComments(heredocs.text, ["#", "//"]);
const referencesOnly = maskTerraformStrings(structural.text);
const unknownLines = [...new Set([...heredocs.unknownLines, ...structural.unknownLines, ...referencesOnly.unknownLines])].sort((a, b) => a - b);
const diagnostics: ImportDiagnostic[] = unknownLines.map((line) => ({ code: "unknown-construct", severity: "warning", message: "Terraform construct is outside the bounded static subset", path: request.path, line }));
const resources = new Map<string, number>();
const references: Array<{ source: string; target: string; line: number }> = [];
const referenceLines = referencesOnly.text.split(/\r?\n/);
let current: string | undefined;
structural.text.split(/\r?\n/).forEach((line, index) => {
const declaration = line.match(/^\s*resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/);
if (declaration) {
current = `${declaration[1]}.${declaration[2]}`;
resources.set(current, index + 1);
if (/\{\s*\}/.test(line)) current = undefined;
return;
}
if (current) {
for (const match of referenceLines[index].matchAll(/\b([A-Za-z_]\w*\.[A-Za-z_]\w*)\.[A-Za-z_]\w*/g)) references.push({ source: current, target: match[1], line: index + 1 });
if (/^\s*}/.test(line)) current = undefined;
}
});
const nodes = [...resources].sort(([a], [b]) => codePointCompare(a, b)).map(([name, line]) => ({ id: id("resource", name), label: name, kind: "resource", provenance: { path: request.path, line } }));
const edges = references.filter((ref) => resources.has(ref.target)).sort((a, b) => codePointCompare(a.source, b.source) || codePointCompare(a.target, b.target) || a.line - b.line).map((ref, occurrence) => ({ id: id("edge", ref.source, ref.target, String(occurrence)), source: id("resource", ref.source), target: id("resource", ref.target), kind: "reference", provenance: { path: request.path, line: ref.line } }));
return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } };
}
if (!(["python", "javascript", "typescript", "go", "rust"] as SourceKind[]).includes(request.sourceKind) || request.input.type !== "text") throw new Error("Unsupported source input");
const nodes: DiagramNode[] = [{ id: id("module", request.path), label: request.path, kind: "module", provenance: { path: request.path, line: 1 } }];
const edges: DiagramEdge[] = [];
const diagnostics: ImportDiagnostic[] = [];
const javaScriptScan = request.sourceKind === "javascript" || request.sourceKind === "typescript" ? scanJavaScriptImports(request.input.text) : undefined;
const nonJavaScriptScan = request.sourceKind === "python" ? maskPythonTripleStrings(request.input.text)
: request.sourceKind === "go" ? maskComments(request.input.text, ["//"], false, "`")
: request.sourceKind === "rust" ? maskComments(request.input.text, ["//"], true, "", true)
: { text: request.input.text, unknownLines: [] };
for (const line of javaScriptScan?.unknownLines ?? nonJavaScriptScan.unknownLines) diagnostics.push({ code: "unknown-construct", severity: "warning", message: `${request.sourceKind} construct is outside the bounded static-import subset`, path: request.path, line });
nonJavaScriptScan.text.split(/\r?\n/).forEach((line, index) => {
const dynamicPython = request.sourceKind === "python" && /\b(?:__import__|importlib\.import_module)\s*\(\s*[^"']/.test(line);
if (dynamicPython) diagnostics.push({ code: "unknown-construct", severity: "warning", message: "Dynamic imports are not evaluated", path: request.path, line: index + 1 });
});
const seen = new Set<string>();
const foundImports = request.sourceKind === "rust"
? nonJavaScriptScan.text.split(/\r?\n/).flatMap((line, index) => {
const mod = line.match(/^\s*mod\s+([A-Za-z_]\w*)\s*;/)?.[1];
const use = line.match(/^\s*use\s+([^;]+);/)?.[1];
if (hasRustMacroCall(line)) diagnostics.push({ code: "unknown-construct", severity: "warning", message: "Rust macros are not expanded", path: request.path, line: index + 1 });
if (mod) return [{ name: mod, line: index + 1 }];
if (use) {
const root = use.includes("::{") ? use.slice(0, use.indexOf("::{")) : use.split("::").slice(0, -1).join("::") || use;
return [{ name: root, line: index + 1 }];
}
return [];
})
: javaScriptScan?.imports ?? codeImports(request.sourceKind as "python" | "go", nonJavaScriptScan.text);
for (const found of foundImports) {
if (seen.has(found.name)) continue;
seen.add(found.name);
const targetId = id("library", found.name);
nodes.push({ id: targetId, label: found.name, kind: "library", provenance: { path: request.path, line: found.line } });
edges.push({ id: id("edge", request.path, found.name, String(edges.length)), source: nodes[0].id, target: targetId, kind: "import", provenance: { path: request.path, line: found.line } });
}
return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } };
}
/** Linear-time equivalent of `/\w+!\s*\(/` (the regex backtracks quadratically on long identifier runs). */
function hasRustMacroCall(line: string): boolean {
for (let bang = line.indexOf("!"); bang >= 0; bang = line.indexOf("!", bang + 1)) {
if (bang === 0 || !/\w/.test(line[bang - 1])) continue;
let next = bang + 1;
while (next < line.length && /\s/.test(line[next])) next++;
if (line[next] === "(") return true;
}
return false;
}
function isConfined(root: string, candidate: string): boolean {
const pathFromRoot = relative(root, candidate);
return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot));
}
async function openConfined(candidate: string, root: string): Promise<{ handle: FileHandle; canonical: string }> {
// Inspect before open(): opening a FIFO (or a device) blocks indefinitely, so only regular files and
// directories may reach open(); O_NONBLOCK additionally keeps a FIFO swapped in after lstat from hanging.
const kind = await lstat(candidate);
if (kind.isSymbolicLink()) throw new Error("Source symlink escape risk cannot be opened safely");
if (!kind.isFile() && !kind.isDirectory()) throw new Error("Source directory contains an unsupported entry type");
let handle: FileHandle;
try {
handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("Source symlink escape risk cannot be opened safely");
throw error;
}
try {
const canonical = await canonicalPathOfOpenHandle(handle, candidate);
if (!isConfined(root, canonical)) throw new Error("Opened source escapes outside declared root");
return { handle, canonical };
} catch (error) {
await handle.close();
throw error;
}
}
let procFdAvailable: boolean | undefined;
/** Path to address an open directory by: its `/proc/self/fd/N` entry on Linux, else its verified canonical path. */
async function descriptorPath(handle: FileHandle, canonical: string): Promise<string> {
if (procFdAvailable === undefined) {
try { await realpath(`/proc/self/fd/${handle.fd}`); procFdAvailable = true; } catch { procFdAvailable = false; }
}
return procFdAvailable ? `/proc/self/fd/${handle.fd}` : canonical;
}
/**
* Resolve the canonical path of an already-opened file. On Linux `/proc/self/fd/N` answers for the
* exact descriptor; on macOS/BSD (no procfs) fall back to resolving the candidate path and proving it
* names the same inode as the open handle, so a swap between open() and realpath() is still rejected.
*/
async function canonicalPathOfOpenHandle(handle: FileHandle, candidate: string): Promise<string> {
try {
return await realpath(`/proc/self/fd/${handle.fd}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const canonical = await realpath(candidate);
const [opened, resolved] = await Promise.all([handle.stat(), stat(canonical)]);
if (opened.dev !== resolved.dev || opened.ino !== resolved.ino) {
throw new Error("Opened source does not match its canonical path");
}
return canonical;
}
async function readBounded(handle: FileHandle, limit: number, directory: boolean): Promise<Buffer> {
const initial = await handle.stat();
if (!initial.isFile()) throw new Error("Source entry must be a regular file");
if (initial.size > limit) throw new Error(`${directory ? "Directory source input" : "Source input"} exceeds maxBytes ${limit}`);
const chunks: Buffer[] = [];
let consumed = 0;
while (consumed < limit) {
const chunk = Buffer.allocUnsafe(Math.min(65_536, limit - consumed));
const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, null);
if (bytesRead === 0) break;
chunks.push(chunk.subarray(0, bytesRead));
consumed += bytesRead;
}
const final = await handle.stat();
if (initial.dev !== final.dev || initial.ino !== final.ino) throw new Error("Opened source identity changed during read");
if (final.size > limit || consumed < initial.size) throw new Error(`${directory ? "Directory source input" : "Source input"} exceeds maxBytes ${limit}`);
return Buffer.concat(chunks, consumed);
}
export async function importSource(request: SourceImportRequest): Promise<SourceImportResult> {
validateBoundedInput(request);
if (request.input.type !== "file") return importInline(request);
if (isAbsolute(request.path)) throw new Error("Source path escapes outside declared root");
const rootPath = await realpath(request.input.root);
const lexicalPath = resolve(rootPath, request.path);
if (!isConfined(rootPath, lexicalPath)) throw new Error("Source path escapes outside declared root");
const limit = request.maxBytes ?? 1_048_576;
const objectKinds = new Set<SourceKind>(["kubernetes", "docker-compose", "openapi", "ci"]);
const extensions: Record<SourceKind, Set<string>> = {
python: new Set([".py"]), javascript: new Set([".js", ".jsx", ".mjs", ".cjs"]), typescript: new Set([".ts", ".tsx", ".mts", ".cts"]),
go: new Set([".go"]), rust: new Set([".rs"]), terraform: new Set([".tf"]), sql: new Set([".sql"]),
kubernetes: new Set([".json", ".yaml", ".yml"]), "docker-compose": new Set([".json", ".yaml", ".yml"]), openapi: new Set([".json", ".yaml", ".yml"]), ci: new Set([".json", ".yaml", ".yml"]),
};
const parse = async (buffer: Buffer, provenancePath: string): Promise<SourceImportResult> => {
const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
let input: SourceInput;
if (objectKinds.has(request.sourceKind)) {
const yaml = /\.ya?ml$/i.test(provenancePath);
try {
if (yaml) {
const documents = loadAll(text, undefined, { schema: JSON_SCHEMA, json: false }).filter((value) => value !== undefined);
if (documents.length === 0) throw new Error("empty");
if (request.sourceKind !== "kubernetes" && documents.length !== 1) throw new Error("multiple documents");
input = { type: "object", value: request.sourceKind === "kubernetes" ? documents : documents[0] };
} else input = { type: "object", value: JSON.parse(text) as unknown };
} catch { throw new Error(`Malformed ${yaml ? "YAML or disallowed tag" : "JSON"} source: ${provenancePath}`); }
} else input = { type: "text", text };
return importInline({ ...request, path: provenancePath, input });
};
const source = await openConfined(lexicalPath, rootPath);
const info = await source.handle.stat();
if (info.isFile()) {
try { return await parse(await readBounded(source.handle, limit, false), request.path); }
finally { await source.handle.close(); }
}
if (!info.isDirectory()) { await source.handle.close(); throw new Error("Source path must be a file or directory"); }
const results: SourceImportResult[] = [];
let consumed = 0;
const walk = async (directory: { handle: FileHandle; canonical: string }): Promise<void> => {
const base = await descriptorPath(directory.handle, directory.canonical);
for (const name of (await readdir(base)).sort(codePointCompare)) {
const opened = await openConfined(resolve(base, name), rootPath);
try {
const entryInfo = await opened.handle.stat();
if (entryInfo.isDirectory()) await walk(opened);
else if (entryInfo.isFile()) {
const dot = name.lastIndexOf("."); const extension = dot >= 0 ? name.slice(dot).toLowerCase() : "";
if (extensions[request.sourceKind].has(extension)) {
const provenancePath = relative(rootPath, opened.canonical).split("\\").join("/");
const buffer = await readBounded(opened.handle, limit - consumed, true);
consumed += buffer.byteLength;
results.push(await parse(buffer, provenancePath));
}
} else throw new Error("Source directory contains an unsupported entry type");
} finally { await opened.handle.close(); }
}
};
try { await walk(source); }
finally { await source.handle.close(); }
if (results.length === 0) throw new Error(`No supported ${request.sourceKind} source files found`);
const nodeById = new Map<string, DiagramNode>(); const edgeById = new Map<string, DiagramEdge>(); const diagnostics: ImportDiagnostic[] = [];
for (const result of results) {
diagnostics.push(...result.diagnostics);
for (const node of result.diagram.pages[0].nodes) if (!nodeById.has(node.id)) nodeById.set(node.id, node);
for (const edge of result.diagram.pages[0].edges) {
if (edgeById.has(edge.id)) diagnostics.push({ code: "duplicate-id", severity: "warning", message: `Duplicate edge omitted: ${edge.id}`, path: String(edge.provenance?.path ?? request.path) });
else edgeById.set(edge.id, edge);
}
}
return { diagram: diagramFor(request, [...nodeById.values()], [...edgeById.values()]), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } };
}
@@ -0,0 +1,149 @@
import assert from "node:assert/strict";
import test from "node:test";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { BUILT_IN_THEMES, applyTheme, contrastRatio, validateTheme } from "./theme-service.js";
const IR: DiagramIRV2 = {
version: 2,
pages: [{
id: "p", title: "Page",
nodes: [
{ id: "service", label: "Service", kind: "service", style: "shape=hexagon;fillColor=#123456;rounded=1;" },
{ id: "transparent", label: "Transparent", kind: "container", style: "swimlane;fillColor=none;horizontal=0;" },
],
edges: [{ id: "edge", source: "service", target: "transparent", style: "edgeStyle=orthogonalEdgeStyle;dashed=1;" }],
}],
};
test("built-in themes validate and meet role contrast requirements", () => {
assert.deepEqual(Object.keys(BUILT_IN_THEMES), ["default", "corporate", "dark", "colorblind-safe", "handdrawn"]);
for (const theme of Object.values(BUILT_IN_THEMES)) {
assert.doesNotThrow(() => validateTheme(theme));
assert.ok(contrastRatio(theme.node.fontColor, theme.node.fillColor) >= 4.5, theme.name);
}
});
test("built-in theme registry and nested role styles reject mutation", () => {
const dark = BUILT_IN_THEMES.dark;
assert.ok(Object.isFrozen(dark));
assert.ok(Object.isFrozen(dark.node));
assert.ok(Object.isFrozen(dark.container));
assert.ok(Object.isFrozen(dark.edge));
assert.throws(() => { (dark.node as { fillColor: string }).fillColor = "#FFFFFF"; }, TypeError);
assert.equal(BUILT_IN_THEMES.dark.node.fillColor, "#243447");
});
test("theme application is deterministic, immutable, role based, and preserves structural style", () => {
const snapshot = structuredClone(IR);
const first = applyTheme(IR, "corporate");
assert.deepEqual(first, applyTheme(IR, "corporate"));
assert.deepEqual(IR, snapshot);
assert.match(first.pages[0].nodes[0].style!, /shape=hexagon/);
assert.match(first.pages[0].nodes[0].style!, /rounded=1/);
assert.match(first.pages[0].nodes[1].style!, /fillColor=none/);
assert.match(first.pages[0].nodes[1].style!, /swimlane/);
assert.match(first.pages[0].edges[0].style!, /edgeStyle=orthogonalEdgeStyle/);
assert.doesNotMatch(first.pages[0].nodes[0].style!, /rounded=0/);
});
test("theme result is deeply isolated from source objects", () => {
const source = structuredClone(IR);
source.assets = { icons: [{ name: "api" }] };
source.pages[0].nodes[0].properties = { config: { retries: 3 } };
source.pages[0].edges[0].extensions = { route: { owner: "network" } };
const snapshot = structuredClone(source);
const themed = applyTheme(source, "corporate");
((themed.assets!.icons as Array<{ name: string }>)[0]).name = "changed";
((themed.pages[0].nodes[0].properties!.config as { retries: number })).retries = 9;
((themed.pages[0].edges[0].extensions!.route as { owner: string })).owner = "changed";
assert.deepEqual(source, snapshot);
});
test("theme application emits each visual style key exactly once", () => {
const source = structuredClone(IR);
source.pages[0].nodes[0].style = "shape=hexagon;fillColor=#000000;fillColor=#FFFFFF;strokeColor=#000000;strokeColor=#FFFFFF;fontColor=#000000;fontColor=#FFFFFF;sketch=0;sketch=0;";
const style = applyTheme(source, "handdrawn").pages[0].nodes[0].style!;
for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) {
assert.equal(style.split(";").filter((token) => token.startsWith(`${key}=`)).length, 1, style);
}
assert.match(style, /fillColor=#FFFDF5;/);
assert.match(style, /strokeColor=#444444;/);
assert.match(style, /fontColor=#222222;/);
assert.match(style, /sketch=1;/);
});
test("theme application preserves page extensions and serializes its canvas background", () => {
const source = structuredClone(IR);
source.pages[0].extensions = { owner: "platform", $drawio: { attributes: { custom: "kept", "model:pageScale": "1.25" } } };
const themed = applyTheme(source, "dark");
const xml = diagramIRToDrawio(themed);
assert.deepEqual(themed.pages[0].extensions, {
owner: "platform",
$drawio: { attributes: { custom: "kept", "model:pageScale": "1.25", "model:background": "#111827" } },
});
assert.match(xml, /<diagram[^>]* data-extensions="\{&quot;owner&quot;:&quot;platform&quot;\}"[^>]* custom="kept"/);
assert.match(xml, /<mxGraphModel[^>]* background="#111827"/);
assert.match(xml, /<mxGraphModel[^>]* pageScale="1.25"/);
assert.doesNotMatch(xml, /data-extensions="[^"]*\$drawio/);
});
test("transparent nodes use a readable dark font on a light canvas", () => {
const custom = structuredClone(BUILT_IN_THEMES.dark);
custom.name = "custom-light-canvas";
custom.backgroundColor = "#FFFFFF";
custom.edge.fontColor = "#000000";
const style = applyTheme(IR, custom).pages[0].nodes[1].style!;
assert.match(style, /fillColor=none;/);
assert.match(style, /fontColor=#000000;/);
assert.ok(contrastRatio("#000000", custom.backgroundColor) >= 4.5);
});
test("transparent nodes use a readable light font on a dark canvas", () => {
const custom = structuredClone(BUILT_IN_THEMES.default);
custom.name = "custom-dark-canvas";
custom.backgroundColor = "#000000";
custom.edge.fontColor = "#FFFFFF";
const source = structuredClone(IR);
source.pages[0].nodes[0].style = "shape=hexagon;fillColor=none;rounded=1;";
const style = applyTheme(source, custom).pages[0].nodes[0].style!;
assert.match(style, /fillColor=none;/);
assert.match(style, /fontColor=#FFFFFF;/);
assert.ok(contrastRatio("#FFFFFF", custom.backgroundColor) >= 4.5);
});
test("theme validation rejects invalid colors and insufficient contrast", () => {
const invalidColor = structuredClone(BUILT_IN_THEMES.default);
invalidColor.node.fillColor = "red";
assert.throws(() => validateTheme(invalidColor), /color/i);
const lowContrast = structuredClone(BUILT_IN_THEMES.default);
lowContrast.node.fontColor = "#777777";
lowContrast.node.fillColor = "#777777";
assert.throws(() => validateTheme(lowContrast), /contrast/i);
});
test("themed edges carry the canvas colour as label background exactly once so labels do not render as boxes", () => {
const source = structuredClone(IR);
source.pages[0].edges[0].style = "edgeStyle=orthogonalEdgeStyle;labelBackgroundColor=#FFFFFF;labelBackgroundColor=#EEEEEE;";
for (const name of ["dark", "corporate"] as const) {
const themed = applyTheme(source, name);
const edgeStyle = themed.pages[0].edges[0].style!;
const tokens = edgeStyle.split(";").filter((token) => token.startsWith("labelBackgroundColor="));
assert.deepEqual(tokens, [`labelBackgroundColor=${BUILT_IN_THEMES[name].backgroundColor}`], edgeStyle);
for (const node of themed.pages[0].nodes) assert.doesNotMatch(node.style!, /labelBackgroundColor/);
}
const untouched = applyTheme(IR, "dark").pages[0].edges[0].style!;
assert.equal(untouched.split(";").filter((token) => token.startsWith("labelBackgroundColor=")).length, 1, untouched);
assert.match(untouched, /labelBackgroundColor=#111827;/);
});
@@ -0,0 +1,150 @@
import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
export type BuiltInThemeName = "default" | "corporate" | "dark" | "colorblind-safe" | "handdrawn";
export interface ThemeRoleStyle {
fillColor: string;
strokeColor: string;
fontColor: string;
}
export interface ThemeDefinition {
name: BuiltInThemeName | (string & {});
backgroundColor: string;
node: ThemeRoleStyle;
container: ThemeRoleStyle;
edge: Pick<ThemeRoleStyle, "strokeColor" | "fontColor">;
sketch?: boolean;
}
function deepFreeze<T>(value: T): T {
if (value && typeof value === "object" && !Object.isFrozen(value)) {
for (const nested of Object.values(value as Record<string, unknown>)) deepFreeze(nested);
Object.freeze(value);
}
return value;
}
export const BUILT_IN_THEMES: Readonly<Record<BuiltInThemeName, ThemeDefinition>> = deepFreeze({
default: theme("default", "#FFFFFF", ["#FFFFFF", "#4A5568", "#1A202C"], ["#F7FAFC", "#718096", "#1A202C"], ["#4A5568", "#1A202C"]),
corporate: theme("corporate", "#FFFFFF", ["#E8F0FE", "#1F5AA6", "#102A43"], ["#F5F8FC", "#526D82", "#102A43"], ["#1F5AA6", "#102A43"]),
dark: theme("dark", "#111827", ["#243447", "#94A3B8", "#FFFFFF"], ["#1F2937", "#64748B", "#FFFFFF"], ["#CBD5E1", "#FFFFFF"]),
"colorblind-safe": theme("colorblind-safe", "#FFFFFF", ["#FFF4CC", "#0072B2", "#1A1A1A"], ["#E8F3F8", "#009E73", "#1A1A1A"], ["#0072B2", "#1A1A1A"]),
handdrawn: { ...theme("handdrawn", "#FFFDF5", ["#FFFDF5", "#444444", "#222222"], ["#F6F1E5", "#555555", "#222222"], ["#444444", "#222222"]), sketch: true },
});
function theme(name: BuiltInThemeName, backgroundColor: string, node: [string, string, string], container: [string, string, string], edge: [string, string]): ThemeDefinition {
return {
name,
backgroundColor,
node: { fillColor: node[0], strokeColor: node[1], fontColor: node[2] },
container: { fillColor: container[0], strokeColor: container[1], fontColor: container[2] },
edge: { strokeColor: edge[0], fontColor: edge[1] },
};
}
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
export function contrastRatio(left: string, right: string): number {
const luminance = (color: string): number => {
if (!HEX_COLOR.test(color)) throw new Error(`Invalid color: ${color}`);
const values = [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255)
.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722;
};
const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a);
return (high + 0.05) / (low + 0.05);
}
export function validateTheme(themeDefinition: ThemeDefinition): ThemeDefinition {
if (!themeDefinition || typeof themeDefinition.name !== "string" || themeDefinition.name.trim() === "") throw new Error("Theme requires a name");
const colors: Array<[string, string]> = [
["backgroundColor", themeDefinition.backgroundColor],
["node.fillColor", themeDefinition.node?.fillColor], ["node.strokeColor", themeDefinition.node?.strokeColor], ["node.fontColor", themeDefinition.node?.fontColor],
["container.fillColor", themeDefinition.container?.fillColor], ["container.strokeColor", themeDefinition.container?.strokeColor], ["container.fontColor", themeDefinition.container?.fontColor],
["edge.strokeColor", themeDefinition.edge?.strokeColor], ["edge.fontColor", themeDefinition.edge?.fontColor],
];
for (const [path, color] of colors) if (typeof color !== "string" || !HEX_COLOR.test(color)) throw new Error(`Theme ${path} must be a #RRGGBB color`);
for (const role of ["node", "container"] as const) {
if (contrastRatio(themeDefinition[role].fontColor, themeDefinition[role].fillColor) < 4.5) throw new Error(`Theme ${role} text has insufficient contrast`);
}
if (contrastRatio(themeDefinition.edge.fontColor, themeDefinition.backgroundColor) < 4.5) throw new Error("Theme edge text has insufficient contrast");
return themeDefinition;
}
interface StyleEntry { key: string; value?: string }
function parseStyle(style = ""): StyleEntry[] {
return style.split(";").filter(Boolean).map((token) => {
const separator = token.indexOf("=");
return separator < 0 ? { key: token } : { key: token.slice(0, separator), value: token.slice(separator + 1) };
});
}
function themedStyle(style: string | undefined, colors: Partial<ThemeRoleStyle>, sketch: boolean, backgroundColor?: string, labelBackgroundColor?: string): string {
const entries = parseStyle(style);
const originalFillNone = entries.some(({ key, value }) => key === "fillColor" && value?.toLowerCase() === "none");
const visual = new Map<string, string>();
if (colors.fillColor) visual.set("fillColor", originalFillNone ? "none" : colors.fillColor);
// Edge labels are drawn on the canvas, so their backdrop must follow the canvas colour or they render as boxes.
if (labelBackgroundColor) visual.set("labelBackgroundColor", labelBackgroundColor);
if (colors.strokeColor) visual.set("strokeColor", colors.strokeColor);
if (colors.fontColor) {
let fontColor = colors.fontColor;
if (originalFillNone && backgroundColor && contrastRatio(fontColor, backgroundColor) < 4.5) {
fontColor = contrastRatio("#000000", backgroundColor) >= 4.5 ? "#000000" : "#FFFFFF";
}
visual.set("fontColor", fontColor);
}
visual.set("sketch", sketch ? "1" : "0");
const visualKeys = new Set(["fillColor", "strokeColor", "fontColor", "labelBackgroundColor", "sketch"]);
const handled = new Set<string>();
const output = entries.flatMap((entry) => {
if (!visualKeys.has(entry.key)) return [entry];
if (handled.has(entry.key)) return [];
handled.add(entry.key);
const replacement = visual.get(entry.key);
if (replacement === undefined) return [entry];
visual.delete(entry.key);
return [{ key: entry.key, value: replacement }];
});
for (const key of ["fillColor", "strokeColor", "fontColor", "labelBackgroundColor", "sketch"]) {
const value = visual.get(key);
if (value !== undefined) output.push({ key, value });
}
return output.map(({ key, value }) => value === undefined ? key : `${key}=${value}`).join(";") + ";";
}
export function applyTheme(ir: DiagramIRV2, selected: BuiltInThemeName | ThemeDefinition): DiagramIRV2 {
const selectedTheme = typeof selected === "string" ? BUILT_IN_THEMES[selected] : selected;
if (!selectedTheme) throw new Error(`Unknown built-in theme: ${selected}`);
validateTheme(selectedTheme);
const isolated = structuredClone(ir);
const applyNode = (node: DiagramNode): DiagramNode => ({
...node,
style: themedStyle(node.style, node.kind === "container" ? selectedTheme.container : selectedTheme.node, selectedTheme.sketch === true, selectedTheme.backgroundColor),
});
const applyEdge = (edge: DiagramEdge): DiagramEdge => ({ ...edge, style: themedStyle(edge.style, selectedTheme.edge, selectedTheme.sketch === true, undefined, selectedTheme.backgroundColor) });
return {
...isolated,
theme: selectedTheme.name,
pages: isolated.pages.map((page) => {
const extensions = page.extensions ?? {};
const drawio = extensions.$drawio && typeof extensions.$drawio === "object" && !Array.isArray(extensions.$drawio)
? extensions.$drawio as Record<string, unknown>
: {};
const attributes = drawio.attributes && typeof drawio.attributes === "object" && !Array.isArray(drawio.attributes)
? drawio.attributes as Record<string, unknown>
: {};
return {
...page,
extensions: {
...extensions,
$drawio: { ...drawio, attributes: { ...attributes, "model:background": selectedTheme.backgroundColor } },
},
nodes: page.nodes.map(applyNode),
edges: page.edges.map(applyEdge),
};
}),
};
}
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { applyMetricsHeatmap } from "./heatmap.js";
import { contrastRatio } from "../themes/theme-service.js";
const IR: DiagramIRV2 = {
version: 2,
pages: [{ id: "p", title: "Page", nodes: [
{ id: "low", label: "Low", style: "shape=hexagon;strokeColor=#000000;", geometry: { x: 0, y: 0, width: 100, height: 60 } },
{ id: "high", label: "High", style: "shape=ellipse;", geometry: { x: 200, y: 0, width: 100, height: 60 } },
], edges: [{ id: "flow", source: "low", target: "high", waypoints: [{ x: 150, y: 30 }] }] }],
};
test("metrics heatmap is deterministic with legend metadata and preserves topology", () => {
const request = { metric: "latency_ms", min: 0, max: 100, values: { "p/low": 0, "p/high": 100 } };
const snapshot = structuredClone(IR);
const first = applyMetricsHeatmap(IR, request);
assert.deepEqual(first, applyMetricsHeatmap(IR, request));
assert.deepEqual(IR, snapshot);
assert.deepEqual(first.legend, {
metric: "latency_ms", min: 0, max: 100,
stops: [
{ value: 0, color: "#440154", fontColor: "#FFFFFF" },
{ value: 25, color: "#3B528B", fontColor: "#FFFFFF" },
{ value: 50, color: "#21918C", fontColor: "#1A1A1A" },
{ value: 75, color: "#5EC962", fontColor: "#1A1A1A" },
{ value: 100, color: "#FDE725", fontColor: "#1A1A1A" },
],
});
assert.match(first.diagram.pages[0].nodes[0].style!, /shape=hexagon/);
assert.match(first.diagram.pages[0].nodes[0].style!, /fillColor=#440154/);
assert.match(first.diagram.pages[0].nodes[1].style!, /fillColor=#FDE725/);
assert.deepEqual(first.diagram.pages[0].edges, IR.pages[0].edges);
assert.deepEqual(first.diagram.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry })), IR.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry })));
});
test("metrics heatmap sets readable text for every palette stop", () => {
const result = applyMetricsHeatmap(IR, {
metric: "latency_ms", min: 0, max: 100,
values: { "p/low": 0, "p/high": 100 },
});
for (const stop of result.legend.stops) {
assert.ok(contrastRatio(stop.fontColor, stop.color) >= 4.5, `${stop.fontColor} on ${stop.color}`);
}
assert.match(result.diagram.pages[0].nodes[0].style!, /fontColor=#FFFFFF/);
assert.match(result.diagram.pages[0].nodes[1].style!, /fontColor=#1A1A1A/);
});
test("metrics heatmap switches colors at the legend stop boundaries", () => {
const colorsAt = (low: number, high: number): string[] => applyMetricsHeatmap(IR, {
metric: "latency_ms", min: 0, max: 100, values: { "p/low": low, "p/high": high },
}).diagram.pages[0].nodes.map((node) => /fillColor=(#[0-9A-F]{6})/i.exec(node.style!)?.[1] ?? "");
assert.deepEqual(colorsAt(24.999, 25), ["#440154", "#3B528B"]);
assert.deepEqual(colorsAt(49.999, 50), ["#3B528B", "#21918C"]);
assert.deepEqual(colorsAt(74.999, 75), ["#21918C", "#5EC962"]);
assert.deepEqual(colorsAt(99.999, 100), ["#5EC962", "#FDE725"]);
});
test("metrics heatmap rejects palettes without a readable text color", () => {
assert.throws(() => applyMetricsHeatmap(IR, {
metric: "x", min: 0, max: 1, values: {}, colors: ["#777777", "#777777"],
}), /no readable font color/i);
});
test("metrics heatmap rejects invalid bounds, colors, keys, and out-of-range values", () => {
assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 1, max: 1, values: {} }), /min.*max/i);
assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { "p/low": 2 } }), /bounded/i);
assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { missing: 0 } }), /unknown/i);
assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: {}, colors: ["red"] }), /color/i);
});
@@ -0,0 +1,85 @@
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
export interface HeatmapRequest {
metric: string;
min: number;
max: number;
values: Readonly<Record<string, number>>;
colors?: readonly string[];
}
export interface HeatmapLegendStop { value: number; color: string; fontColor: string }
export interface HeatmapLegend { metric: string; min: number; max: number; stops: HeatmapLegendStop[] }
export interface HeatmapResult { diagram: DiagramIRV2; legend: HeatmapLegend }
export const DEFAULT_HEATMAP_COLORS = Object.freeze(["#440154", "#3B528B", "#21918C", "#5EC962", "#FDE725"]);
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
function setHeatmapColors(style: string | undefined, color: string, fontColor: string): string {
const entries = (style ?? "").split(";").filter(Boolean);
const replacements = new Map([["fillColor", color], ["fontColor", fontColor]]);
const targetKeys = new Set(replacements.keys());
const output = entries.map((entry) => {
const key = entry.split("=", 1)[0];
if (!targetKeys.has(key)) return entry;
const replacement = replacements.get(key);
if (replacement === undefined) return undefined;
replacements.delete(key);
return `${key}=${replacement}`;
}).filter((entry): entry is string => entry !== undefined);
for (const [key, value] of replacements) output.push(`${key}=${value}`);
return `${output.join(";")};`;
}
function contrastRatio(left: string, right: string): number {
const luminance = (color: string): number => [1, 3, 5]
.map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255)
.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)
.reduce((total, value, index) => total + value * [0.2126, 0.7152, 0.0722][index], 0);
const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a);
return (high + 0.05) / (low + 0.05);
}
const READABLE_FONT_COLORS = ["#FFFFFF", "#1A1A1A"] as const;
function readableFontColor(background: string): string {
const color = READABLE_FONT_COLORS.find((candidate) => contrastRatio(candidate, background) >= 4.5);
if (!color) throw new Error(`Heatmap palette color ${background} has no readable font color`);
return color;
}
function exactNumber(value: number): number {
return Number(value.toFixed(12));
}
export function applyMetricsHeatmap(ir: DiagramIRV2, request: HeatmapRequest): HeatmapResult {
if (typeof request.metric !== "string" || request.metric.trim() === "") throw new Error("Heatmap metric must be a non-empty string");
if (!Number.isFinite(request.min) || !Number.isFinite(request.max) || request.min >= request.max) throw new Error("Heatmap min must be less than max");
if (!request.values || typeof request.values !== "object" || Array.isArray(request.values)) throw new Error("Heatmap values must be an object");
const colors = [...(request.colors ?? DEFAULT_HEATMAP_COLORS)];
if (colors.length < 2 || colors.some((color) => typeof color !== "string" || !HEX_COLOR.test(color))) throw new Error("Heatmap colors must contain at least two #RRGGBB colors");
const nodeKeys = new Set(ir.pages.flatMap((page) => page.nodes.map((node) => `${page.id}/${node.id}`)));
for (const [key, value] of Object.entries(request.values)) {
if (!nodeKeys.has(key)) throw new Error(`Heatmap value has unknown node: ${key}`);
if (typeof value !== "number" || !Number.isFinite(value) || value < request.min || value > request.max) {
throw new Error(`Heatmap value for ${key} must be numeric and bounded by min and max`);
}
}
const stops = colors.map((color, index) => ({
value: exactNumber(request.min + (request.max - request.min) * index / (colors.length - 1)),
color,
fontColor: readableFontColor(color),
}));
const legend: HeatmapLegend = { metric: request.metric, min: request.min, max: request.max, stops };
const diagram = structuredClone(ir);
for (const page of diagram.pages) for (const node of page.nodes) {
const value = request.values[`${page.id}/${node.id}`];
if (value === undefined) continue;
const nextStopIndex = stops.findIndex((stop) => value < stop.value);
const colorIndex = nextStopIndex === -1 ? stops.length - 1 : Math.max(0, nextStopIndex - 1);
node.style = setHeatmapColors(node.style, colors[colorIndex], stops[colorIndex].fontColor);
}
diagram.extensions = { ...(diagram.extensions ?? {}), heatmap: legend };
return { diagram, legend };
}
export const transformMetricsHeatmap = applyMetricsHeatmap;
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { relabelDiagram } from "./relabel.js";
const IR: DiagramIRV2 = {
version: 2,
title: "Original",
pages: [{ id: "p", title: "Page", layout: { type: "manual" }, nodes: [
{ id: "a", label: "A", style: "shape=hexagon;", geometry: { x: 10, y: 20, width: 100, height: 60 } },
{ id: "b", label: "B", style: "shape=ellipse;", geometry: { x: 200, y: 20, width: 100, height: 60 } },
], edges: [{ id: "flow", source: "a", target: "b", label: "old", style: "dashed=1;", waypoints: [{ x: 150, y: 50 }] }] }],
};
test("complete relabel changes only labels while preserving IDs, topology, layout, and style", () => {
const snapshot = structuredClone(IR);
const result = relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new" } });
assert.deepEqual(result.pages[0].nodes.map(({ id, label }) => ({ id, label })), [{ id: "a", label: "Alpha" }, { id: "b", label: "Beta" }]);
assert.equal(result.pages[0].edges[0].label, "new");
const restored = structuredClone(result);
restored.pages[0].nodes[0].label = "A";
restored.pages[0].nodes[1].label = "B";
restored.pages[0].edges[0].label = "old";
assert.deepEqual(restored, IR);
assert.deepEqual(IR, snapshot);
});
test("relabel requires a complete exact explicit map", () => {
assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha" } }), /missing.*p\/b.*p\/flow/i);
assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new", extra: "no" } }), /unknown.*extra/i);
assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "", "p/b": "Beta", "p/flow": "new" } }), /non-empty/i);
});
@@ -0,0 +1,35 @@
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
export interface CompleteRelabelRequest {
mode: "complete";
labels: Readonly<Record<string, string>>;
}
export function relabelDiagram(ir: DiagramIRV2, request: CompleteRelabelRequest): DiagramIRV2 {
if (!request || request.mode !== "complete" || !request.labels || typeof request.labels !== "object" || Array.isArray(request.labels)) {
throw new Error("Relabel requires an explicit complete map mode");
}
const required: string[] = [];
for (const page of ir.pages) {
for (const node of page.nodes) required.push(`${page.id}/${node.id}`);
for (const edge of page.edges) if (edge.label !== undefined) required.push(`${page.id}/${edge.id}`);
}
required.sort((left, right) => left.localeCompare(right, "en-US"));
const provided = Object.keys(request.labels).sort((left, right) => left.localeCompare(right, "en-US"));
const missing = required.filter((key) => !Object.hasOwn(request.labels, key));
if (missing.length > 0) throw new Error(`Relabel map is missing: ${missing.join(", ")}`);
const unknown = provided.filter((key) => !required.includes(key));
if (unknown.length > 0) throw new Error(`Relabel map has unknown keys: ${unknown.join(", ")}`);
for (const key of required) {
const label = request.labels[key];
if (typeof label !== "string" || label.trim() === "") throw new Error(`Relabel value for ${key} must be a non-empty string`);
}
const output = structuredClone(ir);
for (const page of output.pages) {
for (const node of page.nodes) node.label = request.labels[`${page.id}/${node.id}`];
for (const edge of page.edges) if (edge.label !== undefined) edge.label = request.labels[`${page.id}/${edge.id}`];
}
return output;
}
export const relabelDiagramIR = relabelDiagram;
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { diagramIRToMermaid, diagramIRToStructuredMarkdown } from "./reverse.js";
const IR: DiagramIRV2 = {
version: 2,
title: "Platform",
pages: [{
id: "system", title: "System",
nodes: [
{ id: "a", label: "Client" },
{ id: "b", label: "API \"danger\"]\n%%{init: {}}%% <script>alert(1)</script> [click](javascript:alert(1))" },
],
edges: [{ id: "call", source: "a", target: "b", label: "calls | inject" }],
}],
};
test("reverse Mermaid flowchart is deterministic and escapes untrusted labels", () => {
const output = diagramIRToMermaid(IR);
assert.equal(output, diagramIRToMermaid(IR));
assert.match(output, /^flowchart LR/m);
assert.match(output, /n0\["Client"\]/);
assert.match(output, /n0 -->\|"calls &#124; inject"\| n1/);
assert.doesNotMatch(output, /<script>|%%\{init|danger"\]/);
assert.match(output, /&#37;&#37;/);
});
test("reverse Markdown explains pages, nodes, and flows without executable HTML or broken tables", () => {
const output = diagramIRToStructuredMarkdown(IR);
assert.equal(output, diagramIRToStructuredMarkdown(IR));
assert.match(output, /## System/);
assert.match(output, /### Nodes/);
assert.match(output, /### Flows/);
assert.match(output, /calls \\| inject/);
assert.doesNotMatch(output, /<script>/);
assert.doesNotMatch(output, /\]\(javascript:/);
assert.match(output, /&lt;script&gt;/);
});
@@ -0,0 +1,62 @@
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
function mermaidEscape(value: string): string {
const entities: Record<string, string> = {
"&": "&#38;", "<": "&#60;", ">": "&#62;", "\"": "&#34;", "'": "&#39;",
"[": "&#91;", "]": "&#93;", "{": "&#123;", "}": "&#125;", "(": "&#40;", ")": "&#41;",
"|": "&#124;", "`": "&#96;", "%": "&#37;", "\n": "&#10;", "\r": "",
};
return [...value].map((character) => entities[character] ?? character).join("");
}
function markdownEscape(value: string): string {
return value
.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
.replaceAll("\\", "\\\\").replace(/([`*_{}\[\]()#+.!-])/g, "\\$1").replaceAll("|", "\\|").replace(/[\r\n]+/g, " ");
}
export function diagramIRToMermaid(ir: DiagramIRV2): string {
const direction = ir.pages[0]?.layout?.direction === "vertical" ? "TD" : "LR";
const lines = [`flowchart ${direction}`];
let nextNode = 0;
const nodeIds = new Map<string, string>();
for (const [pageIndex, page] of ir.pages.entries()) {
if (ir.pages.length > 1) lines.push(` subgraph page${pageIndex}["${mermaidEscape(page.title)}"]`);
const indent = ir.pages.length > 1 ? " " : " ";
for (const node of page.nodes) {
const safeId = `n${nextNode}`;
nextNode += 1;
nodeIds.set(`${pageIndex}:${node.id}`, safeId);
lines.push(`${indent}${safeId}["${mermaidEscape(node.label)}"]`);
}
if (ir.pages.length > 1) lines.push(" end");
for (const edge of page.edges) {
const source = nodeIds.get(`${pageIndex}:${edge.source}`);
const target = nodeIds.get(`${pageIndex}:${edge.target}`);
if (!source || !target) throw new Error(`Edge ${edge.id} references an unknown endpoint`);
const label = edge.label === undefined ? "" : `|"${mermaidEscape(edge.label)}"|`;
lines.push(` ${source} -->${label} ${target}`);
}
}
return `${lines.join("\n")}\n`;
}
export function diagramIRToStructuredMarkdown(ir: DiagramIRV2): string {
const lines = [`# ${markdownEscape(ir.title ?? "Diagram")}`, ""];
for (const page of ir.pages) {
lines.push(`## ${markdownEscape(page.title)}`, "", "### Nodes", "", "| ID | Label | Kind | Parent |", "| --- | --- | --- | --- |");
for (const node of page.nodes) {
lines.push(`| ${markdownEscape(node.id)} | ${markdownEscape(node.label)} | ${markdownEscape(node.kind ?? "node")} | ${markdownEscape(node.parentId ?? "—")} |`);
}
lines.push("", "### Flows", "", "| ID | From | To | Label |", "| --- | --- | --- | --- |");
if (page.edges.length === 0) lines.push("| — | — | — | No flows |" );
for (const edge of page.edges) {
lines.push(`| ${markdownEscape(edge.id)} | ${markdownEscape(edge.source)} | ${markdownEscape(edge.target)} | ${markdownEscape(edge.label ?? "—")} |`);
}
lines.push("");
}
return lines.join("\n");
}
export const toMermaidFlowchart = diagramIRToMermaid;
export const toMarkdownExplanation = diagramIRToStructuredMarkdown;
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
import { semanticDiff } from "./semantic-diff.js";
const before: DiagramIRV2 = {
version: 2,
pages: [{ id: "p", title: "Page", nodes: [
{ id: "removed", label: "Old" },
{ id: "moved", label: "Move", geometry: { x: 0, y: 0, width: 100, height: 60 } },
{ id: "changed", label: "Before", style: "shape=rectangle;" },
{ id: "target", label: "Target" },
], edges: [
{ id: "route", source: "moved", target: "target", waypoints: [{ x: 20, y: 10 }] },
{ id: "gone", source: "removed", target: "target" },
] }],
};
const after: DiagramIRV2 = {
version: 2,
pages: [{ id: "p", title: "Page", nodes: [
{ id: "moved", label: "Move", geometry: { x: 40, y: 0, width: 100, height: 60 } },
{ id: "changed", label: "After", style: "shape=ellipse;" },
{ id: "target", label: "Target" },
{ id: "added", label: "New" },
], edges: [
{ id: "route", source: "moved", target: "target", waypoints: [{ x: 30, y: 10 }] },
{ id: "new-edge", source: "added", target: "target" },
] }],
};
test("semantic diff classifies added, removed, changed, moved, and rerouted elements", () => {
const diff = semanticDiff(before, after);
assert.deepEqual(diff.added.map((item) => item.id), ["p/added", "p/new-edge"]);
assert.deepEqual(diff.removed.map((item) => item.id), ["p/gone", "p/removed"]);
assert.deepEqual(diff.changed.map((item) => item.id), ["p/changed"]);
assert.deepEqual(diff.moved.map((item) => item.id), ["p/moved"]);
assert.deepEqual(diff.rerouted.map((item) => item.id), ["p/route"]);
});
test("semantic diff values are deeply isolated from both inputs", () => {
const oldDiagram = structuredClone(before);
const newDiagram = structuredClone(after);
newDiagram.pages[0].nodes.find(({ id }) => id === "added")!.extensions = { details: { owner: "platform" } };
const oldSnapshot = structuredClone(oldDiagram);
const newSnapshot = structuredClone(newDiagram);
const diff = semanticDiff(oldDiagram, newDiagram);
(diff.moved[0].before as DiagramNode).geometry!.x = 999;
((diff.added.find(({ id }) => id === "p/added")!.after!.extensions!.details as { owner: string })).owner = "changed";
diff.removed.find(({ id }) => id === "p/removed")!.before!.label = "changed";
assert.deepEqual(oldDiagram, oldSnapshot);
assert.deepEqual(newDiagram, newSnapshot);
});
test("semantic diff is deterministic and does not mutate either input", () => {
const beforeSnapshot = structuredClone(before);
const afterSnapshot = structuredClone(after);
assert.deepEqual(semanticDiff(before, after), semanticDiff(before, after));
assert.deepEqual(before, beforeSnapshot);
assert.deepEqual(after, afterSnapshot);
});
@@ -0,0 +1,100 @@
import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
export type SemanticEntity = "node" | "edge";
export interface SemanticDiffItem {
id: string;
entity: SemanticEntity;
before?: DiagramNode | DiagramEdge;
after?: DiagramNode | DiagramEdge;
}
export interface SemanticDiffResult {
added: SemanticDiffItem[];
removed: SemanticDiffItem[];
changed: SemanticDiffItem[];
moved: SemanticDiffItem[];
rerouted: SemanticDiffItem[];
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") {
const object = value as Record<string, unknown>;
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function equal(left: unknown, right: unknown): boolean {
return canonical(left) === canonical(right);
}
function without<T extends object>(value: T, omitted: readonly string[]): Record<string, unknown> {
return Object.fromEntries(Object.entries(value).filter(([key]) => !omitted.includes(key)));
}
function position(node: DiagramNode): unknown {
return { parentId: node.parentId, x: node.geometry?.x, y: node.geometry?.y };
}
function nodeMeaning(node: DiagramNode): unknown {
const semantic = without(node, ["geometry", "parentId"]);
if (node.geometry) semantic.geometrySize = { width: node.geometry.width, height: node.geometry.height };
return semantic;
}
function edgeRoute(edge: DiagramEdge): unknown {
return { source: edge.source, target: edge.target, waypoints: edge.waypoints ?? [] };
}
function edgeMeaning(edge: DiagramEdge): unknown {
return without(edge, ["source", "target", "waypoints"]);
}
function index(ir: DiagramIRV2): Map<string, { entity: SemanticEntity; value: DiagramNode | DiagramEdge }> {
const result = new Map<string, { entity: SemanticEntity; value: DiagramNode | DiagramEdge }>();
for (const page of ir.pages) {
for (const node of page.nodes) result.set(`${page.id}/${node.id}`, { entity: "node", value: node });
for (const edge of page.edges) result.set(`${page.id}/${edge.id}`, { entity: "edge", value: edge });
}
return result;
}
function ordered(items: SemanticDiffItem[]): SemanticDiffItem[] {
return items.sort((left, right) => left.id.localeCompare(right.id, "en-US") || left.entity.localeCompare(right.entity, "en-US"));
}
function clone<T>(value: T): T {
return structuredClone(value);
}
export function semanticDiff(before: DiagramIRV2, after: DiagramIRV2): SemanticDiffResult {
const oldItems = index(before);
const newItems = index(after);
const result: SemanticDiffResult = { added: [], removed: [], changed: [], moved: [], rerouted: [] };
for (const [id, current] of newItems) {
const previous = oldItems.get(id);
if (!previous || previous.entity !== current.entity) {
result.added.push({ id, entity: current.entity, after: clone(current.value) });
continue;
}
if (current.entity === "node") {
const oldNode = previous.value as DiagramNode;
const newNode = current.value as DiagramNode;
if (!equal(position(oldNode), position(newNode))) result.moved.push({ id, entity: "node", before: clone(oldNode), after: clone(newNode) });
if (!equal(nodeMeaning(oldNode), nodeMeaning(newNode))) result.changed.push({ id, entity: "node", before: clone(oldNode), after: clone(newNode) });
} else {
const oldEdge = previous.value as DiagramEdge;
const newEdge = current.value as DiagramEdge;
if (!equal(edgeRoute(oldEdge), edgeRoute(newEdge))) result.rerouted.push({ id, entity: "edge", before: clone(oldEdge), after: clone(newEdge) });
if (!equal(edgeMeaning(oldEdge), edgeMeaning(newEdge))) result.changed.push({ id, entity: "edge", before: clone(oldEdge), after: clone(newEdge) });
}
}
for (const [id, previous] of oldItems) {
const current = newItems.get(id);
if (!current || current.entity !== previous.entity) result.removed.push({ id, entity: previous.entity, before: clone(previous.value) });
}
for (const values of Object.values(result)) ordered(values);
return result;
}
export const diffDiagramIR = semanticDiff;