Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb837295a0 | ||
|
|
6584df0666 | ||
|
|
7610235781 | ||
|
|
9b84db4b0a | ||
|
|
9809cf73e9 | ||
|
|
1550609e5b | ||
|
|
aec73fdfe8 | ||
|
|
1db0ab2885 | ||
|
|
8bc406d84e | ||
|
|
9ae0be3565 | ||
|
|
2be287aeb9 | ||
|
|
ced42f02f9 | ||
|
|
bb378ebd63 | ||
|
|
db4129168b | ||
|
|
fa62a5fc4c | ||
|
|
5146b14d43 |
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
version: 2
|
||||
title: Platform views
|
||||
provenance:
|
||||
source: drawio-main example
|
||||
source: diagrams-drawio example
|
||||
pages:
|
||||
- id: system
|
||||
title: System
|
||||
|
||||
@@ -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
@@ -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
@@ -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,4 +1,4 @@
|
||||
# drawio-main — Connector Routing Best Practices
|
||||
# diagrams-drawio — Connector Routing Best Practices
|
||||
|
||||
## Connector Routing Best Practices (Zero-Overlap Guarantee)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+5
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }; }
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }) };
|
||||
}
|
||||
@@ -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 }; }
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 })) };
|
||||
}
|
||||
@@ -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) }; }
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"/);
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); }
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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="{"owner":"team-a"}" data-provenance="{"source":"catalog"}" data-extensions="{"x":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 <primary>/);
|
||||
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); }
|
||||
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="\{"owner":"platform"\}"[^>]* 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 | inject"\| n1/);
|
||||
assert.doesNotMatch(output, /<script>|%%\{init|danger"\]/);
|
||||
assert.match(output, /%%/);
|
||||
});
|
||||
|
||||
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, /<script>/);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
|
||||
|
||||
function mermaidEscape(value: string): string {
|
||||
const entities: Record<string, string> = {
|
||||
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'",
|
||||
"[": "[", "]": "]", "{": "{", "}": "}", "(": "(", ")": ")",
|
||||
"|": "|", "`": "`", "%": "%", "\n": " ", "\r": "",
|
||||
};
|
||||
return [...value].map((character) => entities[character] ?? character).join("");
|
||||
}
|
||||
|
||||
function markdownEscape(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
||||
.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;
|
||||
Reference in New Issue
Block a user