69 changed files with 4710 additions and 57 deletions
+39 -2
View File
@@ -1,6 +1,6 @@
# drawio-main
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.
@@ -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
+19 -1
View File
@@ -13,11 +13,15 @@ compatibility: Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, an
# Draw.io Diagram Skill
This skill covers three capabilities:
This skill covers seven capability families:
1. **Deterministic YAML generation** — validate v1/v2 semantic Diagram IR and build native multi-page `.drawio` XML with stable IDs, dependency-aware layout, and obstacle-aware routing
2. **Direct XML generation** — create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements
3. **Diagram analysis** — run the `drawio-tools` CLI to analyse an existing `.drawio` file: inventory shapes and connectors, validate layout quality, detect overlaps/orphans, and recommend page sizes
4. **Semantic lifecycle** — loss-aware Draw.io import, transactional stable-ID edits, linked views, semantic query/policy/what-if analysis, three-way synchronization, and self-contained offline story publishing
5. **Safe source importers** — bounded native TypeScript subsets for Python, JavaScript/TypeScript, Go, Rust, Terraform, Kubernetes, Docker Compose, SQL, OpenAPI, and CI dependency graphs
6. **Toolbox transforms** — themes, offline generic shape search, reverse Mermaid/Markdown, semantic diff, strict relabeling, and accessible heatmaps
7. **Specialized profiles** — C4, sequence, tube map, compression, runbook, timelapse, and dependency-ordered build-up services
**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,6 +31,8 @@ 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) — strict evidence matrix for all 42 compared peer tools, including partial and deferred scope
## Available scripts
@@ -48,12 +54,24 @@ 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
task edit -- --file="/path/to/model.yaml" --spec="/path/to/edit-batch.yaml" --output="/path/to/edited.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"
# Build TypeScript to dist/
task build
```
See [references/capabilities.md](./references/capabilities.md) for what every action outputs.
Source importers, toolbox transforms, and specialized profiles are currently native TypeScript service APIs, not additional CLI actions. Do not invent action names for them; use the exported services or the documented lifecycle actions.
---
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).
+30
View File
@@ -0,0 +1,30 @@
pageId: system
preconditions:
- type: exists
id: api
operations:
- type: update
id: api
changes:
label: Public API
properties:
owner: platform
production: true
observability: true
- type: add
node:
id: cache
label: Cache
kind: database
geometry:
x: 520
y: 200
width: 120
height: 80
- type: connect
edge:
id: api-cache
source: api
target: cache
label: Read-through
kind: data
+8
View File
@@ -0,0 +1,8 @@
rules:
- no-direct-internet-to-database
- no-cycles
- no-orphans
- every-service-has-owner
- production-has-observability
- external-dependencies-have-timeouts
- trust-boundaries-use-protocol
@@ -0,0 +1,77 @@
# Agents365 capability coverage
> Evidence basis: clean-room behavioral comparison of 42 peer tools. Peer source and bundled assets were not copied because the inspected mirror had no complete license file.
## 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>clean-room 42-tool mapping audit | No licensed Databricks manifest, aliases, variants, pinned-ref embedding, refresh operation, host allowlist, or CLI action was delivered. |
| `diagram_ir.py` | **partial** | `scripts/src/model/diagram-ir.ts`<br>`scripts/src/services/semantic-lifecycle/import-drawio.ts`<br>`scripts/src/services/semantic-lifecycle/analysis.ts`<br>`scripts/src/services/semantic-lifecycle/sync.ts`<br>`scripts/src/services/semantic-lifecycle/publishing.ts` | Versioned IR, loss-aware import, views, query, policies, failure impact, sync, and story publishing exist, but articulation analysis, a unified architecture-review contract, contrast analysis, multilingual labeling, and explicit peer-IR-v1 compatibility are incomplete. |
| `diagramctl.py` | **partial** | `scripts/src/cli/commands.ts`<br>`scripts/src/cli/semantic-lifecycle.test.ts`<br>`scripts/src/actions/doctor/action.ts`<br>`scripts/src/actions/sync/action.ts` | Lifecycle actions are registered in the existing --action CLI, but integrated importer, profile, transform, and reverse-export services are not registered. There is no uniform peer-equivalent result envelope. |
| `diagramctl_mcp.py` | **optional-deferred** | `scripts/src/cli/commands.ts`<br>clean-room 42-tool mapping audit | No optional MCP package, JSON-RPC initialization, tools/list, tools/call bridge, closed schemas, timeout handling, or MCP entrypoint exists. |
| `dockerimports.py` | **optional-deferred** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | The importer registry has Docker Compose but no Docker inspect snapshot kind, container/network/volume instance normalization, redaction contract, stdin parity, or action. |
| `drawio2mermaid.py` | **partial** | `scripts/src/services/transforms/reverse.ts`<br>`scripts/src/services/transforms/reverse.test.ts`<br>`scripts/src/services/semantic-lifecycle/import-drawio.ts` | A deterministic IR-to-Mermaid service exists, but there is no Draw.io-to-Mermaid CLI/action, shape-form mapping, direction/fence controls, or lossy-conversion report. |
| `drawio2pptx.py` | **optional-deferred** | `scripts/src/cli/commands.ts`<br>clean-room 42-tool mapping audit | No optional Draw.io renderer adapter, per-page raster loop, PPTX writer, slide sizing, scale option, or structured unavailable result exists. |
| `drawiodiff.py` | **partial** | `scripts/src/services/transforms/semantic-diff.ts`<br>`scripts/src/services/transforms/semantic-diff.test.ts`<br>`scripts/src/services/semantic-lifecycle/import-drawio.ts` | The IR service classifies added, removed, changed, moved, and rerouted entities, but no CLI/action composes Draw.io import with diffing, no by-label ambiguity mode exists, and no color-coded graph output or summary diagram is emitted. |
| `drawiohtml.py` | **optional-deferred** | `scripts/src/services/semantic-lifecycle/publishing.ts`<br>`scripts/src/actions/story/action.ts` | Story HTML is a different semantic publisher; there is no page-to-SVG export adapter, SVG sanitizer, tabbed viewer, pan/zoom/search UI, drill-down link rewrite, or publish-viewer action. |
| `edgeports.py` | **rejected** | `scripts/src/services/authoring-router/orthogonal-router.ts`<br>`scripts/src/services/authoring-router/orthogonal-router.test.ts`<br>clean-room 42-tool mapping audit | Only routing during authoring exists. The post-import boundary-port editor, pinned-port preservation, dry-run, idempotence, and transactional Draw.io write path were not selected for this post-phase implementation. |
| `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>clean-room 42-tool mapping audit | No PNG chunk validator, signature-specific repair, atomic in-place replacement, idempotence gate, version gate, or optional action exists. |
| `restyle.py` | **partial** | `scripts/src/services/themes/theme-service.ts`<br>`scripts/src/services/themes/theme-service.test.ts` | Five validated built-in themes and immutable IR application exist, but the peer-style palette-slot schema, user preset loader, hue/neutral color remapping, global extras, versioned JSON schema, Draw.io transactional action, and CLI exposure are incomplete. |
| `runbook.py` | **partial** | `scripts/src/services/profiles/runbook.ts`<br>`scripts/src/services/profiles/runbook.test.ts` | The service emits escaped self-contained interactive HTML from an explicit RunbookGraph, but it does not parse Draw.io, infer node types/start nodes/choices, report fallback selection, or expose a publish-runbook action. |
| `rustimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | A bounded subset recognizes mod and simple use roots and diagnoses macros, but crate/self/super resolution, module-file discovery, complete brace expansion, external-crate exclusion, grouping, reduction, and CLI exposure are absent. |
| `seqlayout.py` | **partial** | `scripts/src/services/profiles/sequence.ts`<br>`scripts/src/services/profiles/sequence.test.ts` | Participants, ordered messages, lifelines, activations, return validation, and editable geometry are implemented as a service, but notes are unsupported and no sequence schema file, input action, direction/options contract, or CLI registration exists. |
| `shapesearch.py` | **partial** | `scripts/src/services/shape-catalog/shape-catalog.ts`<br>`scripts/src/services/shape-catalog/shape-catalog.test.ts` | Deterministic exact/alias/fuzzy search exists for eight hand-curated generic shapes, not the licensed 10k+ palette index; compound/tag/Soundex ranking, dimensions, gzip integrity controls, expected ecosystem queries, and CLI output are missing. |
| `sqlerd.py` | **partial** | `scripts/src/services/source-importers/index.ts`<br>`scripts/src/services/source-importers/index.test.ts` | A narrow line-oriented subset finds simple tables and REFERENCES edges, but columns/types, PK/FK markers, quoted/schema identifiers, composite keys, schema grouping, crow's-foot styles, unsupported-syntax diagnostics, and CLI exposure are missing. |
| `svgflow.py` | **optional-deferred** | `scripts/src/cli/commands.ts`<br>clean-room 42-tool mapping audit | No optional Draw.io SVG export adapter, SVG parser/sanitizer, connector detection, animation injection, reduced-motion handling, or export-flow-svg action exists. |
| `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.
## Delivery note
This matrix describes the combined integrated candidate and deliberate scope decisions. Gitea publication and Hermes runtime installation are separate gates and must not be inferred from this document.
+36 -1
View File
@@ -25,7 +25,15 @@ See [rules-layout.md](./rules-layout.md) for mandatory connector and layout rule
---
## Capability 3 — Diagram analysis (drawio-tools CLI)
## Capability 3 — Semantic lifecycle
The native TypeScript core supports loss-aware Draw.io import and deterministic round trips; transactional stable-ID edit batches; linked audience views; semantic query, architecture policy, and failure what-if analysis; three-way synchronization; accessible self-contained story HTML; and a non-launching doctor report.
See [semantic-lifecycle.md](./semantic-lifecycle.md) for command contracts, safety behavior, built-in policy identifiers, and examples.
---
## Capability 4 — Diagram analysis (drawio-tools CLI)
A **TypeScript / Node.js** CLI tool for programmatic analysis of `.drawio` files.
Entry point: `node dist/cli/commands.js` (run from the skill's `scripts/` directory), or `task run -- --file=… --action=…`.
@@ -49,6 +57,20 @@ Always prints YAML to stdout. Exit code `0` on success, `1` on error.
|---|---|---|
| `build` | YAML Diagram IR | Validate and convert a YAML specification to native `.drawio`; requires `--output` |
#### Semantic lifecycle
| 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 |
#### Inventory
| Action | Scope | Description |
@@ -77,3 +99,16 @@ Always prints YAML to stdout. Exit code `0` on success, `1` on error.
| `page-recommendations` | Page 0 | Smallest standard page size (A4→A3→A2→A1→custom) that fits content with 80 px margin |
> For install/build instructions, source structure, and how to add new actions, see [maintenance.md](./maintenance.md).
---
## Capability 5 — Native service libraries
The package includes additional strict TypeScript APIs under `scripts/src/services/`:
- `source-importers/` — conservative, bounded source/configuration topology extraction with diagnostics and no code execution;
- `themes/` and `shape-catalog/` — five validated themes and an offline generic shape catalog;
- `transforms/` — reverse Mermaid/Markdown, semantic diff, complete-map relabeling, and accessible bounded heatmaps;
- `profiles/` — C4, sequence, tube map, compression, runbook, timelapse, and build-up profiles.
These services are covered by the native test suite but are not registered CLI actions. Their exact peer-relative coverage and deliberate omissions are listed in [agents365-capability-coverage.md](./agents365-capability-coverage.md).
+25 -5
View File
@@ -89,13 +89,20 @@ 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
└── 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
@@ -109,7 +116,7 @@ src/
└── 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,7 +136,9 @@ 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
---
@@ -162,6 +171,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 +183,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"),
};
```
+118
View File
@@ -0,0 +1,118 @@
# Semantic lifecycle workflows
`drawio-tools` keeps editable Draw.io XML and semantic Diagram IR v2 synchronized without requiring Draw.io Desktop, a browser, network access, Python, Graphviz, or ELK.
All examples run from `scripts/` after `pnpm install --frozen-lockfile` and `task build`. Results are YAML on standard output. Commands that write files require an explicit `--output` and use an atomic temporary-file rename. Input files cannot be used as output aliases.
## Import and round trip
```bash
node dist/cli/commands.js --action import \
--file architecture.drawio \
--output architecture.yaml
node dist/cli/commands.js --action build \
--file architecture.yaml \
--output rebuilt.drawio
```
Import accepts bare `mxGraphModel` documents and multi-page `mxfile` wrappers. It preserves stable cell IDs, labels (including deliberately empty labels), parent relationships, styles, geometry, waypoints, semantic metadata, wrapper/page/model attributes, and unknown XML children in `$drawio` preservation envelopes. Dangling endpoints and ambiguous identities fail closed. Import output must use `.yaml`, `.yml`, or `.json`.
## Transactional editing
```bash
node dist/cli/commands.js --action edit \
--file architecture.yaml \
--spec ../examples/edit-batch.yaml \
--dry-run
node dist/cli/commands.js --action edit \
--file architecture.yaml \
--spec ../examples/edit-batch.yaml \
--output edited.drawio
```
A batch targets one page and supports typed `add`, `update`, `move`, `delete`, and `connect` operations. Supported preconditions are `exists`, `not-exists`, and `property-equals`, each expressed with a `type` and `id`. 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 -1
View File
@@ -22,7 +22,7 @@
"$defs": {
"safeId": {
"type": "string",
"pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$",
"pattern": "^[^\\s\\\"'<>]+$",
"not": { "enum": ["0", "1"] }
},
"point": {
+40
View File
@@ -38,6 +38,46 @@ 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
sync:
desc: Three-way stable-ID synchronization; requires --base, --file, and --spec.
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=sync {{ .CLI_ARGS }}"
silent: true
story:
desc: Publish a self-contained accessible offline architecture story.
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=story {{ .CLI_ARGS }}"
silent: true
validate:
desc: |
Validate a .drawio file (XML well-formedness + required structure).
+1 -1
View File
@@ -14,7 +14,7 @@
},
"dependencies": {
"@maxgraph/core": "^0.23.0",
"js-yaml": "^4.1.0",
"js-yaml": "^4.3.1",
"jsdom": "^25.0.0",
"pako": "^2.1.0"
},
+5 -5
View File
@@ -12,8 +12,8 @@ importers:
specifier: ^0.23.0
version: 0.23.0
js-yaml:
specifier: ^4.1.0
version: 4.3.0
specifier: ^4.3.1
version: 4.3.1
jsdom:
specifier: ^25.0.0
version: 25.0.1
@@ -373,8 +373,8 @@ packages:
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
jsdom@25.0.1:
@@ -779,7 +779,7 @@ snapshots:
is-potential-custom-element-name@1.0.1: {}
js-yaml@4.3.0:
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
+43
View File
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { JSDOM } from "jsdom";
import { parseAllPages } from "../../services/drawio-parser/parser.js";
import { run } from "./action.js";
@@ -101,3 +102,45 @@ test("build serializes every v2 page and honors explicit geometry and waypoints"
rmSync(dir, { recursive: true, force: true });
}
});
test("build emits parseable XML when generic model attributes collide with fixed attributes", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-build-model-attributes-test-"));
const input = join(dir, "model-attributes.yaml");
const output = join(dir, "model-attributes.drawio");
try {
writeFileSync(input, `version: 2
pages:
- id: page
title: Page
layout: { type: manual, gridSize: 20 }
extensions:
$drawio:
attributes:
model:grid: "0"
model:gridSize: "99"
model:page: "0"
model:pageWidth: "1"
model:pageHeight: "2"
model:background: "#ffffff"
nodes:
- { id: node, label: Node, geometry: { x: 0, y: 0, width: 100, height: 60 } }
edges: []
`, "utf8");
run(input, 0, output);
const xml = readFileSync(output, "utf8");
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
const document = new dom.window.DOMParser().parseFromString(xml, "text/xml");
assert.equal(document.querySelector("parsererror"), null);
const model = document.querySelector("mxGraphModel");
assert.ok(model);
assert.equal(model.getAttribute("grid"), "1");
assert.equal(model.getAttribute("gridSize"), "20");
assert.equal(model.getAttribute("page"), "1");
assert.equal(model.getAttribute("pageWidth"), "1169");
assert.equal(model.getAttribute("pageHeight"), "827");
assert.equal(model.getAttribute("background"), "#ffffff");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+3 -4
View File
@@ -1,9 +1,9 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { readFileSync } from "node:fs";
import * as yaml from "js-yaml";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { normalizeDiagramIR, validateDiagramIR } from "../../model/diagram-ir.js";
import { atomicWrite } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _pageIndex = 0, outputPath?: string): Record<string, unknown> {
if (!outputPath) throw new Error("build requires --output <path.drawio>");
@@ -11,8 +11,7 @@ export function run(filePath: string, _pageIndex = 0, outputPath?: string): Reco
const ir = validateDiagramIR(parsed);
const normalized = normalizeDiagramIR(ir);
const xml = diagramIRToDrawio(normalized);
const resolvedOutput = resolve(outputPath);
writeFileSync(resolvedOutput, xml, "utf8");
const resolvedOutput = atomicWrite(outputPath, xml, [filePath]);
return {
action: "build",
file: filePath,
+2
View File
@@ -0,0 +1,2 @@
import { doctorReport } from "../../services/semantic-lifecycle/publishing.js";
export function run(_filePath = "", _page = 0, _output?: string): Record<string, unknown> { return { action: "doctor", ...doctorReport() }; }
+17
View File
@@ -0,0 +1,17 @@
import type { EditBatch } from "../../services/semantic-lifecycle/edit-batch.js";
import { applyEditBatch } from "../../services/semantic-lifecycle/edit-batch.js";
import { assertOutputSafe, atomicWrite, loadIR, loadStructured, structuredText, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
if (!options.spec) throw new Error("edit requires --spec <batch.yaml>");
if (!options.dryRun && !outputPath) throw new Error("edit requires --output unless --dry-run is used");
if (outputPath) assertOutputSafe(outputPath, [filePath, options.spec]);
const source = loadIR(filePath); const batch = loadStructured(options.spec) as EditBatch;
const result = applyEditBatch(source, batch, { dryRun: options.dryRun });
let output: string | undefined;
if (!options.dryRun && outputPath) {
const content = /\.(drawio|xml)$/i.test(outputPath) ? diagramIRToDrawio(result.ir) : structuredText(result.ir, outputPath);
output = atomicWrite(outputPath, content, [filePath, options.spec]);
}
return { action: "edit", output, applied: result.applied, dryRun: result.dryRun, preview: options.dryRun ? result.preview : undefined };
}
+9
View File
@@ -0,0 +1,9 @@
import { importDrawioToIR } from "../../services/semantic-lifecycle/import-drawio.js";
import { atomicWrite, structuredText } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, outputPath?: string): Record<string, unknown> {
if (!outputPath) throw new Error("import requires --output <model.yaml|model.json>");
if (!/\.(?:ya?ml|json)$/i.test(outputPath)) throw new Error("import output must use a .yaml, .yml, or .json extension");
const result = importDrawioToIR(filePath);
const output = atomicWrite(outputPath, structuredText(result.ir, outputPath), [filePath]);
return { action: "import", file: filePath, output, lossReport: result.lossReport };
}
+6
View File
@@ -0,0 +1,6 @@
import { queryDiagram } from "../../services/semantic-lifecycle/analysis.js";
import { loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, _output?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
const properties = Object.fromEntries((options.property ?? []).map((entry) => { const at = entry.indexOf("="); if (at < 1) throw new Error(`--property requires key=value: ${entry}`); const raw = entry.slice(at + 1); let value: unknown = raw; try { value = JSON.parse(raw); } catch {} return [entry.slice(0, at), value]; }));
return { action: "query", ...queryDiagram(loadIR(filePath), { kind: options.kind, properties, from: options.from, to: options.to }) };
}
+4
View File
@@ -0,0 +1,4 @@
import { createStoryHtml } from "../../services/semantic-lifecycle/publishing.js";
import { atomicWrite, loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
import { simulateFailure } from "../../services/semantic-lifecycle/analysis.js";
export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record<string, unknown> { if (!outputPath) throw new Error("story requires --output <story.html>"); const ir = loadIR(filePath); const scenario = options.fail ? simulateFailure(ir, options.fail) : undefined; const output = atomicWrite(outputPath, createStoryHtml(ir, { scenario }), [filePath]); return { action: "story", output, accessible: true, offline: true, scenario: scenario ? { failed: scenario.failed, impacted: scenario.impacted } : undefined }; }
+10
View File
@@ -0,0 +1,10 @@
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { assertOutputSafe, atomicWrite, loadIR, structuredText, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
import { syncDiagramIR } from "../../services/semantic-lifecycle/sync.js";
export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
if (!options.base || !options.spec) throw new Error("sync requires --base <base> and --spec <incoming>"); if (!options.dryRun && !outputPath) throw new Error("sync requires --output unless --dry-run is used");
if (outputPath) assertOutputSafe(outputPath, [filePath, options.base, options.spec]);
const result = syncDiagramIR(loadIR(options.base), loadIR(filePath), loadIR(options.spec), { prune: options.prune });
let output: string | undefined; if (!options.dryRun && outputPath) output = atomicWrite(outputPath, /\.(drawio|xml)$/i.test(outputPath) ? diagramIRToDrawio(result.ir) : structuredText(result.ir, outputPath), [filePath, options.base, options.spec]);
return { action: "sync", output, dryRun: options.dryRun === true, added: result.added, removed: result.removed, conflicts: result.conflicts, preview: options.dryRun ? result.ir : undefined };
}
+8
View File
@@ -0,0 +1,8 @@
import { POLICY_IDS, runPolicies } from "../../services/semantic-lifecycle/analysis.js";
import { loadIR, loadStructured, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, _output?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
let rules: readonly string[] = POLICY_IDS; if (options.spec) { const raw = loadStructured(options.spec); rules = Array.isArray(raw) ? raw.map(String) : ((raw as { rules?: unknown[] }).rules ?? []).map((item) => typeof item === "string" ? item : String((item as { id?: unknown }).id)); }
const unknown = rules.filter((rule) => !(POLICY_IDS as readonly string[]).includes(rule));
if (unknown.length) throw new Error(`Unknown policy identifier: ${unknown.join(", ")}`);
const report = runPolicies(loadIR(filePath), rules); return { action: "test", ...report, failed: report.errors > 0 || (options.strict === true && report.warnings > 0) };
}
+16
View File
@@ -0,0 +1,16 @@
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import type { DiagramIRV2 } 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);
const ir: DiagramIRV2 = { version: 2, title: source.title, provenance: source.provenance, pages: views.map((view) => ({ id: view.id, title: view.title, nodes: view.nodes, edges: view.edges, layout: { type: "manual" }, properties: { linkedView: true, sourcePageIds: view.sourcePageIds, fallback: view.fallback, fallbackReason: view.fallbackReason, hint: view.hint } })) };
const output = atomicWrite(outputPath, diagramIRToDrawio(ir), [filePath]);
return { action: "views", output, views: views.map(({ id, fallback, fallbackReason, hint }) => ({ id, fallback, fallbackReason, hint })) };
}
+3
View File
@@ -0,0 +1,3 @@
import { simulateFailure } from "../../services/semantic-lifecycle/analysis.js";
import { loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, _output?: string, options: LifecycleActionOptions = {}): Record<string, unknown> { if (!options.fail) throw new Error("what-if requires --fail <node-id>"); return { action: "what-if", ...simulateFailure(loadIR(filePath), options.fail) }; }
+78 -31
View File
@@ -6,14 +6,24 @@ import { layoutPage } from "../services/layout/layout-engine.js";
const DEFAULT_NODE_STYLE = "whiteSpace=wrap;html=1;rounded=0;fillColor=#f5f5f5;strokeColor=#666666;";
const DEFAULT_CONTAINER_STYLE = "swimlane;html=1;rounded=0;startSize=30;fillColor=#f5f5f5;strokeColor=#666666;";
const DEFAULT_EDGE_STYLE = "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;";
const FIXED_MODEL_ATTRIBUTES = new Set(["grid", "gridSize", "page", "pageWidth", "pageHeight"]);
type Preservation = {
host?: string;
attributes?: Record<string, string>;
holderTag?: string;
holderAttributes?: Record<string, string>;
childXml?: string[];
geometryAttributes?: Record<string, string>;
geometryChildXml?: string[];
styleAbsent?: boolean;
modelAttributes?: Record<string, string>;
modelChildXml?: string[];
unknownCells?: string[];
};
function escapeXml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
}
function stableJson(value: unknown): string {
@@ -26,38 +36,72 @@ function stableJson(value: unknown): string {
return JSON.stringify(value);
}
function semanticExtensions(value: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
if (!value) return undefined;
const result = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "$drawio"));
return Object.keys(result).length ? result : undefined;
}
function preservation(value: Record<string, unknown> | undefined): Preservation {
const raw = value?.$drawio;
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Preservation : {};
}
function dataAttributes(values: Record<string, unknown>): string {
return Object.entries(values)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`)
return Object.entries(values).filter(([, value]) => value !== undefined).map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`).join("");
}
function rawAttributes(values: Record<string, string> | undefined, prefix?: string, reserved = new Set<string>()): string {
return Object.entries(values ?? {})
.filter(([key]) => !prefix || key.startsWith(prefix))
.map(([key, value]) => [prefix ? key.slice(prefix.length) : key, value] as const)
.filter(([key]) => !reserved.has(key) && /^[A-Za-z_][\w:.-]*$/.test(key))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => ` ${escapeXml(key)}="${escapeXml(value)}"`)
.join("");
}
function unprefixedAttributes(values: Record<string, string> | undefined): string {
return Object.entries(values ?? {}).filter(([key]) => !key.includes(":")).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` ${escapeXml(key)}="${escapeXml(value)}"`).join("");
}
function geometryForNode(node: DiagramNode, index: number, page: DiagramPage): { x: number; y: number; width: number; height: number } {
if (node.geometry) return node.geometry;
const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal";
return {
x: horizontal ? 40 + index * 200 : 40,
y: horizontal ? 80 : 80 + index * 120,
width: node.width ?? 120,
height: node.height ?? 60,
};
return { x: horizontal ? 40 + index * 200 : 40, y: horizontal ? 80 : 80 + index * 120, width: node.width ?? 120, height: node.height ?? 60 };
}
function wrapHolder(cell: string, id: string, label: string, ext: Record<string, unknown> | undefined, p: Preservation): string {
if (!p.holderTag) return cell;
const metadata = dataAttributes({ kind: undefined, properties: undefined, provenance: undefined, extensions: semanticExtensions(ext) });
return `<${p.holderTag} id="${escapeXml(id)}" label="${escapeXml(label)}"${rawAttributes(p.holderAttributes)}${metadata}>${cell}</${p.holderTag}>`;
}
function serializeNode(node: DiagramNode, index: number, page: DiagramPage): string {
const geometry = geometryForNode(node, index, page);
const style = node.style ?? (node.kind === "container" ? DEFAULT_CONTAINER_STYLE : DEFAULT_NODE_STYLE);
const parent = node.parentId ?? "1";
const metadata = dataAttributes({ kind: node.kind, properties: node.properties, provenance: node.provenance, extensions: node.extensions });
return ` <mxCell id="${escapeXml(node.id)}" value="${escapeXml(node.label)}" style="${escapeXml(style)}" vertex="1" parent="${escapeXml(parent)}"${metadata}><mxGeometry x="${geometry.x}" y="${geometry.y}" width="${geometry.width}" height="${geometry.height}" as="geometry"/></mxCell>`;
const p = preservation(node.extensions);
const renderedLabel = p.attributes?.["semantic:label"] ?? node.label;
const metadata = dataAttributes({ kind: node.kind, properties: node.properties, provenance: node.provenance, extensions: semanticExtensions(node.extensions) });
const geometryChildren = (p.geometryChildXml ?? []).join("");
const relative = p.geometryAttributes?.relative === undefined ? "" : ` relative="${escapeXml(p.geometryAttributes.relative)}"`;
const geometryXml = `<mxGeometry x="${geometry.x}" y="${geometry.y}" width="${geometry.width}" height="${geometry.height}"${relative} as="${escapeXml(p.geometryAttributes?.as ?? "geometry")}"${rawAttributes(p.attributes, "geometry:")}${geometryChildren ? `>${geometryChildren}</mxGeometry>` : "/>"}`;
const styleAttribute = p.styleAbsent === true ? "" : ` style="${escapeXml(style)}"`;
const cell = `<mxCell id="${escapeXml(node.id)}" value="${escapeXml(renderedLabel)}"${styleAttribute} vertex="1" parent="${escapeXml(parent)}"${metadata}${unprefixedAttributes(p.attributes)}>${geometryXml}${(p.childXml ?? []).join("")}</mxCell>`;
return ` ${wrapHolder(cell, node.id, renderedLabel, node.extensions, p)}`;
}
function serializeEdge(edge: DiagramEdge): string {
const points = edge.waypoints?.length
? `<Array as="points">${edge.waypoints.map((point) => `<mxPoint x="${point.x}" y="${point.y}"/>`).join("")}</Array>`
: "";
const metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: edge.extensions });
return ` <mxCell id="${escapeXml(edge.id)}" value="${escapeXml(edge.label ?? "")}" style="${escapeXml(edge.style ?? DEFAULT_EDGE_STYLE)}" edge="1" parent="1" source="${escapeXml(edge.source)}" target="${escapeXml(edge.target)}"${metadata}><mxGeometry relative="1" as="geometry">${points}</mxGeometry></mxCell>`;
const p = preservation(edge.extensions);
const points = edge.waypoints?.length ? `<Array as="points">${edge.waypoints.map((point) => `<mxPoint x="${point.x}" y="${point.y}"/>`).join("")}</Array>` : "";
const metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: semanticExtensions(edge.extensions) });
const geometryChildren = `${points}${(p.geometryChildXml ?? []).join("")}`;
const relative = p.geometryAttributes?.relative === undefined ? "1" : p.geometryAttributes.relative;
const geometry = `<mxGeometry relative="${escapeXml(relative)}" as="${escapeXml(p.geometryAttributes?.as ?? "geometry")}"${rawAttributes(p.attributes, "geometry:")}>${geometryChildren}</mxGeometry>`;
const cell = `<mxCell id="${escapeXml(edge.id)}" value="${escapeXml(edge.label ?? "")}" style="${escapeXml(edge.style ?? DEFAULT_EDGE_STYLE)}" edge="1" parent="1" source="${escapeXml(edge.source)}" target="${escapeXml(edge.target)}"${metadata}${unprefixedAttributes(p.attributes)}>${geometry}${(p.childXml ?? []).join("")}</mxCell>`;
return ` ${wrapHolder(cell, edge.id, edge.label ?? "", edge.extensions, p)}`;
}
function contentExtent(page: DiagramPage): { width: number; height: number } {
@@ -75,14 +119,11 @@ function contentExtent(page: DiagramPage): { width: number; height: number } {
};
const geometries = page.nodes.map((node) => resolve(node.id)).filter((geometry) => geometry !== undefined);
const points = page.edges.flatMap((edge) => edge.waypoints ?? []);
return {
width: Math.max(0, ...geometries.map((geometry) => geometry.x + geometry.width), ...points.map((point) => point.x)),
height: Math.max(0, ...geometries.map((geometry) => geometry.y + geometry.height), ...points.map((point) => point.y)),
};
return { width: Math.max(0, ...geometries.map((geometry) => geometry.x + geometry.width), ...points.map((point) => point.x)), height: Math.max(0, ...geometries.map((geometry) => geometry.y + geometry.height), ...points.map((point) => point.y)) };
}
function serializePage(input: DiagramPage): string {
const page = routePageEdges(layoutPage(input));
const page = input.layout?.type === "manual" ? input : routePageEdges(layoutPage(input));
const nodeCells = page.nodes.map((node, index) => serializeNode(node, index, page));
const edgeCells = page.edges.map(serializeEdge);
const extent = contentExtent(page);
@@ -90,12 +131,18 @@ function serializePage(input: DiagramPage): string {
const fit = (value: number) => Math.ceil((value + 40) / grid) * grid;
const pageWidth = Math.max(page.width ?? 1169, fit(extent.width));
const pageHeight = Math.max(page.height ?? 827, fit(extent.height));
const metadata = dataAttributes({ properties: page.properties, extensions: page.extensions });
return ` <diagram id="${escapeXml(page.id)}" name="${escapeXml(page.title)}"${metadata}>\n <mxGraphModel grid="1" gridSize="${page.layout?.gridSize ?? 10}" page="1" pageWidth="${pageWidth}" pageHeight="${pageHeight}">\n <root>\n <mxCell id="0"/>\n <mxCell id="1" parent="0"/>\n${[...nodeCells, ...edgeCells].join("\n")}\n </root>\n </mxGraphModel>\n </diagram>`;
const p = preservation(page.extensions);
const metadata = dataAttributes({ properties: page.properties, extensions: semanticExtensions(page.extensions) });
const diagramChildren = (p.childXml ?? []).map((xml) => ` ${xml}`);
const modelChildren = (p.modelChildXml ?? []).map((xml) => ` ${xml}`);
const modelAttributes = p.modelAttributes ?? {};
return ` <diagram id="${escapeXml(page.id)}" name="${escapeXml(page.title)}"${metadata}${unprefixedAttributes(p.attributes)}>\n${diagramChildren.length ? `${diagramChildren.join("\n")}\n` : ""} <mxGraphModel grid="${escapeXml(modelAttributes.grid ?? "1")}" gridSize="${escapeXml(modelAttributes.gridSize ?? String(grid))}" page="${escapeXml(modelAttributes.page ?? "1")}" pageWidth="${escapeXml(modelAttributes.pageWidth ?? String(pageWidth))}" pageHeight="${escapeXml(modelAttributes.pageHeight ?? String(pageHeight))}"${rawAttributes(p.attributes, "model:", FIXED_MODEL_ATTRIBUTES)}>\n${modelChildren.length ? `${modelChildren.join("\n")}\n` : ""} <root>\n <mxCell id="0"/>\n <mxCell id="1" parent="0"/>\n${[...nodeCells, ...edgeCells, ...(p.unknownCells ?? []).map((xml) => ` ${xml}`)].join("\n")}\n </root>\n </mxGraphModel>\n </diagram>`;
}
export function diagramIRToDrawio(ir: DiagramIR): string {
const normalized = normalizeDiagramIR(ir);
const metadata = dataAttributes({ version: normalized.version, title: normalized.title, theme: normalized.theme, provenance: normalized.provenance, assets: normalized.assets, properties: normalized.properties, extensions: normalized.extensions });
return `<?xml version="1.0" encoding="UTF-8"?>\n<mxfile host="drawio-tools"${metadata}>\n${normalized.pages.map(serializePage).join("\n")}\n</mxfile>\n`;
const p = preservation(normalized.extensions);
const metadata = dataAttributes({ version: normalized.version, title: normalized.title, theme: normalized.theme, provenance: normalized.provenance, assets: normalized.assets, properties: normalized.properties, extensions: semanticExtensions(normalized.extensions) });
const rootChildren = (p.childXml ?? []).map((xml) => ` ${xml}`);
return `<?xml version="1.0" encoding="UTF-8"?>\n<mxfile host="${escapeXml(p.host ?? "drawio-tools")}"${metadata}${unprefixedAttributes(p.attributes)}>\n${rootChildren.length ? `${rootChildren.join("\n")}\n` : ""}${normalized.pages.map(serializePage).join("\n")}\n</mxfile>\n`;
}
+9
View File
@@ -34,6 +34,15 @@ test("CLI rejects partially numeric page indexes", () => {
assert.match(result.stderr, /non-negative integer/);
});
test("CLI help documents lifecycle options and file-free doctor", () => {
const result = spawnSync(process.execPath, ["--import", "tsx", CLI, "--help"], { cwd: resolve("."), encoding: "utf8" });
assert.equal(result.status, 0, result.stdout + result.stderr);
for (const option of ["--spec", "--base", "--strict", "--prune", "--dry-run", "--fail", "--views", "--property", "--from", "--to"]) {
assert.match(result.stdout, new RegExp(option));
}
assert.match(result.stdout, /doctor.*does not require --file/i);
});
test("CLI validate exits non-zero when the validation summary is invalid", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-cli-validate-test-"));
const input = join(dir, "empty.drawio");
+45 -5
View File
@@ -22,13 +22,14 @@
import { parseArgs } from "node:util";
import * as yaml from "js-yaml";
import type { LifecycleActionOptions } from "../services/semantic-lifecycle/lifecycle-io.js";
// ---------------------------------------------------------------------------
// Action registry
// ---------------------------------------------------------------------------
type ActionModule = {
run: (filePath: string, pageIndex?: number, outputPath?: string) => Record<string, unknown>;
run: (filePath: string, pageIndex?: number, outputPath?: string, options?: LifecycleActionOptions) => 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; "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,17 @@ 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" },
"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 +95,25 @@ 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
--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 +123,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 +148,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,
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 +160,7 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
if (actionName === "validate" && summary && typeof summary === "object" && (summary as Record<string, unknown>).valid === false) {
process.exitCode = 1;
}
if (result.failed === true) process.exitCode = 1;
} catch (err) {
const errorOutput = {
error: true,
+108
View File
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const CLI = resolve("src/cli/commands.ts");
const run = (args: string[]) => spawnSync(process.execPath, ["--import", "tsx", CLI, ...args], { cwd: resolve("."), encoding: "utf8" });
test("CLI imports Draw.io to machine-readable IR only with explicit atomic output", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-import-"));
const source = join(dir, "source.drawio"); const output = join(dir, "model.json");
try {
writeFileSync(source, `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="API" vertex="1" parent="1"><mxGeometry x="10" y="20" width="100" height="50" as="geometry"/></mxCell></root></mxGraphModel>`, "utf8");
const missing = run(["--action", "import", "--file", source]);
assert.equal(missing.status, 1);
assert.match(missing.stdout, /requires --output/i);
const result = run(["--action", "import", "--file", source, "--output", output]);
assert.equal(result.status, 0, result.stdout + result.stderr);
const parsed = JSON.parse(readFileSync(output, "utf8"));
assert.equal(parsed.version, 2);
assert.equal(parsed.pages[0].nodes[0].id, "api");
assert.match(result.stdout, /lossy: false/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("failed CLI edit is atomic and traversal aliases cannot clobber source", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-edit-"));
const source = join(dir, "source.json"); const spec = join(dir, "bad.yaml"); const output = join(dir, "output.json");
const sourceText = JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }, null, 2) + "\n";
try {
writeFileSync(source, sourceText, "utf8"); writeFileSync(spec, "pageId: p\noperations:\n - { type: update, id: api, changes: { parentId: missing } }\n", "utf8"); writeFileSync(output, "DO NOT CLOBBER", "utf8");
const failed = run(["--action", "edit", "--file", source, "--spec", spec, "--output", output]);
assert.equal(failed.status, 1);
assert.equal(readFileSync(source, "utf8"), sourceText);
assert.equal(readFileSync(output, "utf8"), "DO NOT CLOBBER");
const alias = join(dir, "nested", "..", "source.json");
const clobber = run(["--action", "edit", "--file", source, "--spec", spec, "--output", alias]);
assert.equal(clobber.status, 1);
assert.match(clobber.stdout, /overwrite an input/i);
assert.equal(readFileSync(source, "utf8"), sourceText);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("CLI rejects symlink input aliases targeting the real output", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-symlink-alias-"));
const source = join(dir, "source.json"); const inputLink = join(dir, "input.json"); const spec = join(dir, "edit.yaml");
const sourceText = JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }, null, 2) + "\n";
try {
writeFileSync(source, sourceText, "utf8");
symlinkSync(source, inputLink);
writeFileSync(spec, "pageId: p\noperations:\n - { type: update, id: api, changes: { label: Updated } }\n", "utf8");
const result = run(["--action", "edit", "--file", inputLink, "--spec", spec, "--output", source]);
assert.equal(result.status, 1);
assert.match(result.stdout, /overwrite an input/i);
assert.equal(readFileSync(source, "utf8"), sourceText);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("CLI lifecycle flags route deterministic views, policies, what-if, sync, story, and doctor", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-actions-"));
const model = join(dir, "model.json"); const views = join(dir, "views.drawio"); const story = join(dir, "story.html"); const base = join(dir, "base.json"); const incoming = join(dir, "incoming.json"); const synced = join(dir, "synced.json");
const ir = { version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API", kind: "service", properties: { environment: "production" } }, { id: "db", label: "DB", kind: "database" }], edges: [{ id: "write", source: "api", target: "db", properties: { isolates_failure: true } }] }] };
try {
writeFileSync(model, JSON.stringify(ir), "utf8"); writeFileSync(base, JSON.stringify(ir), "utf8"); writeFileSync(incoming, JSON.stringify(ir), "utf8");
const viewResult = run(["--action", "views", "--file", model, "--views", "system,security", "--output", views]);
assert.equal(viewResult.status, 0, viewResult.stdout + viewResult.stderr); assert.equal(existsSync(views), true);
const policy = run(["--action", "test", "--file", model, "--strict"]); assert.equal(policy.status, 1); assert.match(policy.stdout, /warnings:/);
const whatif = run(["--action", "what-if", "--file", model, "--fail", "api"]); assert.equal(whatif.status, 0); assert.match(whatif.stdout, /impacted: \[\]/);
const sync = run(["--action", "sync", "--file", model, "--base", base, "--spec", incoming, "--prune", "--output", synced]); assert.equal(sync.status, 0, sync.stdout + sync.stderr);
const storyResult = run(["--action", "story", "--file", model, "--fail", "api", "--output", story]); assert.equal(storyResult.status, 0); assert.doesNotMatch(readFileSync(story, "utf8"), /https?:\/\//);
const doctor = run(["--action", "doctor"]); assert.equal(doctor.status, 0, doctor.stdout + doctor.stderr); assert.match(doctor.stdout, /networkRequired: false/);
const first = readFileSync(views, "utf8"); run(["--action", "views", "--file", model, "--views", "system,security", "--output", views]); assert.equal(readFileSync(views, "utf8"), first);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("CLI edits serialize Draw.io output and rejects unsupported import extensions", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-formats-"));
const model = join(dir, "model.json"); const spec = join(dir, "edit.yaml"); const drawio = join(dir, "edited.drawio");
const source = join(dir, "source.drawio"); const unsupported = join(dir, "model.txt");
try {
writeFileSync(model, JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }), "utf8");
writeFileSync(spec, "pageId: p\noperations:\n - { type: update, id: api, changes: { label: Updated } }\n", "utf8");
const edited = run(["--action", "edit", "--file", model, "--spec", spec, "--output", drawio]);
assert.equal(edited.status, 0, edited.stdout + edited.stderr);
assert.match(readFileSync(drawio, "utf8"), /^<\?xml[^]*<mxfile/);
writeFileSync(source, `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`, "utf8");
const imported = run(["--action", "import", "--file", source, "--output", unsupported]);
assert.equal(imported.status, 1);
assert.match(imported.stdout, /yaml.*json|json.*yaml/i);
assert.equal(existsSync(unsupported), false);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("CLI rejects unknown linked views and policy identifiers", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-invalid-"));
const model = join(dir, "model.json"); const views = join(dir, "views.drawio"); const rules = join(dir, "rules.yaml");
try {
writeFileSync(model, JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }), "utf8");
writeFileSync(rules, "rules: [not-a-real-policy]\n", "utf8");
const badView = run(["--action", "views", "--file", model, "--views", "not-a-view", "--output", views]);
assert.equal(badView.status, 1); assert.match(badView.stdout, /unknown.*view/i); assert.equal(existsSync(views), false);
const badPolicy = run(["--action", "test", "--file", model, "--spec", rules]);
assert.equal(badPolicy.status, 1); assert.match(badPolicy.stdout, /unknown.*policy/i);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
+1 -1
View File
@@ -79,7 +79,7 @@ export interface DiagramIRV2 {
export type DiagramIR = DiagramIRV1 | DiagramIRV2;
const SAFE_ID = /^[A-Za-z][A-Za-z0-9_.:-]*$/;
const SAFE_ID = /^[^\s"'<>]+$/;
const RESERVED_IDS = new Set(["0", "1"]);
const LAYOUT_TYPES = new Set<DiagramLayoutType>(["linear", "layered", "tree", "grid", "manual"]);
const ROOT_V1_FIELDS = new Set(["version", "title", "direction", "nodes", "edges"]);
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
import { createBuildup } from "./buildup.js";
const PAGE: DiagramPage = { id: "deploy", title: "Deploy", nodes: [{ id: "db", label: "DB", provenance: { source: "infra" } }, { id: "api", label: "API" }, { id: "web", label: "Web" }], edges: [{ id: "db-api", source: "db", target: "api" }, { id: "api-web", source: "api", target: "web" }] };
test("buildup uses deterministic topological order and valid cumulative IR frames", () => {
const before = structuredClone(PAGE); const result = createBuildup(PAGE);
assert.deepEqual(result, createBuildup(PAGE)); assert.deepEqual(PAGE, before); assert.deepEqual(result.order, ["db", "api", "web"]); assert.deepEqual(result.cycleFallback, []);
result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame), frame));
assert.deepEqual(result.frames.map((frame) => frame.pages[0].nodes.map((node) => node.id)), [["db"], ["db", "api"], ["db", "api", "web"]]);
assert.equal(result.frames[0].pages[0].nodes[0].provenance?.source, "infra");
});
test("buildup explicitly falls back to stable ID order for cycles", () => {
const cyclic: DiagramPage = { id: "cycle", title: "Cycle", nodes: [{ id: "b", label: "B" }, { id: "a", label: "A" }], edges: [{ id: "ab", source: "a", target: "b" }, { id: "ba", source: "b", target: "a" }] };
const result = createBuildup(cyclic); assert.deepEqual(result.order, ["a", "b"]); assert.deepEqual(result.cycleFallback, ["a", "b"]);
result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame), frame));
});
test("buildup cycle fallback keeps containers before children so every frame remains valid", () => {
const cyclic: DiagramPage = { id: "nested", title: "Nested", nodes: [{ id: "a-child", label: "Child", parentId: "z-parent" }, { id: "z-parent", label: "Parent", kind: "container" }], edges: [{ id: "reverse", source: "a-child", target: "z-parent" }] };
const result = createBuildup(cyclic);
assert.deepEqual(result.order, ["z-parent", "a-child"]);
result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame), frame));
});
test("buildup rejects empty and invalid input", () => {
assert.throws(() => createBuildup({ id: "empty", title: "Empty", nodes: [], edges: [] }), /non-empty/i);
assert.throws(() => createBuildup({ id: "bad", title: "Bad", nodes: [{ id: "a", label: "A" }], edges: [{ id: "e", source: "x", target: "a" }] }), /unknown source/i);
});
+36
View File
@@ -0,0 +1,36 @@
import type { DiagramIRV2, DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export interface BuildupResult { order: string[]; cycleFallback: string[]; frames: DiagramIRV2[] }
export function createBuildup(page: DiagramPage): BuildupResult {
validateDiagramIR({ version: 2, pages: [structuredClone(page)] });
const ids = page.nodes.map((node) => node.id);
const adjacency = new Map(ids.map((id) => [id, new Set<string>()]));
const indegree = new Map(ids.map((id) => [id, 0]));
const addDependency = (source: string, target: string) => {
const next = adjacency.get(source)!; if (next.has(target)) return; next.add(target); indegree.set(target, indegree.get(target)! + 1);
};
page.edges.forEach((edge) => addDependency(edge.source, edge.target));
page.nodes.forEach((node) => { if (node.parentId) addDependency(node.parentId, node.id); });
const ready = ids.filter((id) => indegree.get(id) === 0).sort(); const order: string[] = [];
while (ready.length) {
const id = ready.shift()!; order.push(id);
for (const target of [...adjacency.get(id)!].sort()) { indegree.set(target, indegree.get(target)! - 1); if (indegree.get(target) === 0) { ready.push(target); ready.sort(); } }
}
const nodeById = new Map(page.nodes.map((node) => [node.id, node]));
const parentDepth = (id: string): number => {
let depth = 0; let current = nodeById.get(id)?.parentId;
while (current) { depth += 1; current = nodeById.get(current)?.parentId; }
return depth;
};
const cycleFallback = ids.filter((id) => !order.includes(id)).sort((a, b) => parentDepth(a) - parentDepth(b) || a.localeCompare(b));
order.push(...cycleFallback);
const frames = order.map((_, index) => {
const includedOrder = order.slice(0, index + 1); const included = new Set(includedOrder);
const framePage: DiagramPage = { ...structuredClone(page), id: `${page.id}-step-${String(index + 1).padStart(3, "0")}`, title: `${page.title} — Step ${index + 1}`, nodes: includedOrder.map((id) => structuredClone(nodeById.get(id)!)), edges: page.edges.filter((edge) => included.has(edge.source) && included.has(edge.target)).map((edge) => structuredClone(edge)), properties: { ...(page.properties ?? {}), profile: "buildup", step: index + 1, addedNodeId: order[index], cycleFallback: cycleFallback.includes(order[index]) } };
const ir: DiagramIRV2 = { version: 2, title: page.title, pages: [framePage], properties: { profile: "buildup", order: [...order], cycleFallback: [...cycleFallback] } };
return validateDiagramIR(ir) as DiagramIRV2;
});
return { order, cycleFallback, frames };
}
+133
View File
@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import test from "node:test";
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"],
});
assert.ok(first.pages.flatMap((page) => page.nodes).every((node) => /rounded=0/.test(node.style ?? "")));
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`);
}
});
+112
View File
@@ -0,0 +1,112 @@
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;
return {
id: element.id,
label: element.label,
kind: model.elements.some((child) => child.parentId === element.id && included.has(child.id)) ? "container" : `c4-${element.type}`,
...(element.parentId && included.has(element.parentId) ? { parentId: element.parentId } : {}),
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] });
}
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 candidates = pageSpecs.filter((page) => page.elements.some((element) =>
element.id === relationship.source || element.id === relationship.target));
const page = candidates.at(-1) ?? pageSpecs[0];
const included = new Set(page.elements.map((element) => element.id));
for (const endpoint of [relationship.source, relationship.target]) {
if (!included.has(endpoint)) {
page.elements.push(byId.get(endpoint)!);
included.add(endpoint);
}
}
}
const pages = pageSpecs.map((page) => makePage(page.id, page.title, page.elements));
const ir: DiagramIRV2 = { version: 2, title: model.title, pages, provenance: cloneRecord(model.provenance), properties: { profile: "c4" } };
return validateDiagramIR(ir) as DiagramIRV2;
}
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
import { compressExecutiveView } from "./compression.js";
const PAGE: DiagramPage = { id: "platform", title: "Platform", nodes: [{ id: "a", label: "A", provenance: { source: "a.yaml" } }, { id: "b", label: "B" }, { id: "c", label: "C" }, { id: "d", label: "D" }], edges: [{ id: "ab", source: "a", target: "b" }, { id: "bc", source: "b", target: "c" }, { id: "cd", source: "c", target: "d" }] };
test("compression deterministically clusters topology into linked summary and full pages", () => {
const before = structuredClone(PAGE); const ir = compressExecutiveView(PAGE, { maxClusterSize: 2 });
assert.deepEqual(ir, compressExecutiveView(PAGE, { maxClusterSize: 2 })); assert.deepEqual(PAGE, before); assert.equal(validateDiagramIR(ir), ir);
assert.deepEqual(ir.pages.map((p) => p.id), ["executive-summary", "platform-full"]);
assert.deepEqual(ir.pages[0].nodes.map((n) => n.properties?.memberIds), [["a", "b"], ["c", "d"]]);
assert.equal(ir.pages[0].nodes[0].properties?.drillDownPage, "platform-full");
assert.equal(ir.pages[1].nodes[0].provenance?.source, "a.yaml");
assert.equal(ir.pages[0].edges.length, 1);
});
test("compression rejects invalid and empty pages", () => {
assert.throws(() => compressExecutiveView({ id: "empty", title: "Empty", nodes: [], edges: [] }), /non-empty/i);
assert.throws(() => compressExecutiveView({ id: "bad", title: "Bad", nodes: [{ id: "a", label: "A" }], edges: [{ id: "e", source: "a", target: "missing" }] }), /unknown target/i);
});
@@ -0,0 +1,36 @@
import type { DiagramEdge, DiagramIRV2, DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export interface CompressionOptions { maxClusterSize?: number }
export function compressExecutiveView(page: DiagramPage, options: CompressionOptions = {}): DiagramIRV2 {
validateDiagramIR({ version: 2, pages: [structuredClone(page)] });
const maxSize = options.maxClusterSize ?? 6;
if (!Number.isInteger(maxSize) || maxSize < 1) throw new Error("maxClusterSize must be a positive integer");
const nodeById = new Map(page.nodes.map((node) => [node.id, node]));
const adjacency = new Map(page.nodes.map((node) => [node.id, new Set<string>()]));
for (const edge of page.edges) { adjacency.get(edge.source)!.add(edge.target); adjacency.get(edge.target)!.add(edge.source); }
const remaining = new Set([...nodeById.keys()].sort());
const clusters: string[][] = [];
while (remaining.size) {
const seed = [...remaining][0]; const queue = [seed]; const members: string[] = [];
while (queue.length && members.length < maxSize) {
const id = queue.shift()!; if (!remaining.delete(id)) continue; members.push(id);
const neighbors = [...adjacency.get(id)!].filter((item) => remaining.has(item)).sort();
queue.push(...neighbors);
}
members.sort(); clusters.push(members);
}
const fullPageId = `${page.id}-full`;
const clusterByNode = new Map<string, number>(); clusters.forEach((members, index) => members.forEach((id) => clusterByNode.set(id, index)));
const summaryNodes = clusters.map((members, index) => ({ id: `cluster-${String(index + 1).padStart(3, "0")}`, label: members.map((id) => nodeById.get(id)!.label).join(" + "), kind: "executive-cluster", properties: { memberIds: members, memberCount: members.length, drillDownPage: fullPageId }, provenance: { sources: members.map((id) => nodeById.get(id)!.provenance ?? {}).filter((value) => Object.keys(value).length > 0) } }));
const aggregate = new Map<string, { source: number; target: number; edgeIds: string[] }>();
for (const edge of [...page.edges].sort((a, b) => a.id.localeCompare(b.id))) {
const source = clusterByNode.get(edge.source)!; const target = clusterByNode.get(edge.target)!; if (source === target) continue;
const key = `${source}:${target}`; const item = aggregate.get(key) ?? { source, target, edgeIds: [] }; item.edgeIds.push(edge.id); aggregate.set(key, item);
}
const summaryEdges: DiagramEdge[] = [...aggregate.values()].sort((a, b) => a.source - b.source || a.target - b.target).map((item, index) => ({ id: `cluster-link-${String(index + 1).padStart(3, "0")}`, source: summaryNodes[item.source].id, target: summaryNodes[item.target].id, kind: "executive-aggregate", properties: { edgeIds: item.edgeIds, edgeCount: item.edgeIds.length } }));
const full: DiagramPage = structuredClone(page); full.id = fullPageId; full.properties = { ...(full.properties ?? {}), profile: "compression-full", summaryPage: "executive-summary" };
const ir: DiagramIRV2 = { version: 2, title: page.title, pages: [{ id: "executive-summary", title: `${page.title} — Executive Summary`, nodes: summaryNodes, edges: summaryEdges, layout: { type: "layered", direction: "horizontal", gridSize: 10, nodeGap: 40, layerGap: 80 }, properties: { profile: "executive-compression", fullPage: fullPageId } }, full], properties: { profile: "executive-compression", algorithm: "deterministic-topology-bfs" } };
return validateDiagramIR(ir) as DiagramIRV2;
}
@@ -0,0 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as profiles from "./index.js";
test("specialized profile barrel exposes all seven native profiles", () => {
assert.deepEqual(Object.keys(profiles).filter((key) => key.startsWith("create") || key.startsWith("project") || key.startsWith("compress")).sort(), ["compressExecutiveView", "createArchitectureTimelapse", "createBuildup", "createRunbookHtml", "createSequenceDiagram", "createTubeMap", "projectC4"]);
});
+14
View File
@@ -0,0 +1,14 @@
export { projectC4 } from "./c4.js";
export type { C4Element, C4ElementType, C4Model, C4Relationship } from "./c4.js";
export { createSequenceDiagram } from "./sequence.js";
export type { SequenceInput, SequenceMessage, SequenceParticipant } from "./sequence.js";
export { createTubeMap } from "./tube-map.js";
export type { TubeLine, TubeMapInput, TubeStation } from "./tube-map.js";
export { compressExecutiveView } from "./compression.js";
export type { CompressionOptions } from "./compression.js";
export { createRunbookHtml } from "./runbook.js";
export type { RunbookChoice, RunbookGraph, RunbookNode } from "./runbook.js";
export { createArchitectureTimelapse } from "./timelapse.js";
export type { ArchitectureSnapshot, ArchitectureTimelapse, ChangeSet, TimelapseFrame } from "./timelapse.js";
export { createBuildup } from "./buildup.js";
export type { BuildupResult } from "./buildup.js";
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createRunbookHtml } from "./runbook.js";
const GRAPH = { title: "Ops <Runbook>", startId: "start", nodes: [{ id: "start", label: "Is it healthy? </script><img src=x onerror=alert(1)>", choices: [{ label: "No & investigate", target: "check" }] }, { id: "check", label: "Check logs", choices: [{ label: "Retry", target: "start" }] }] };
test("runbook HTML is deterministic, self-contained and escapes malicious labels", () => {
const before = structuredClone(GRAPH); const html = createRunbookHtml(GRAPH);
assert.equal(html, createRunbookHtml(GRAPH)); assert.deepEqual(GRAPH, before);
assert.ok(html.includes("default-src 'none'")); assert.ok(html.includes("Breadcrumb")); assert.ok(html.includes("Back")); assert.ok(html.includes("Restart")); assert.ok(html.includes("keydown"));
assert.ok(html.includes("border-radius:0")); assert.equal(/border-radius:(?!0(?:[;}]))/.test(html), false);
assert.equal(html.includes("</script><img"), false); assert.equal(html.includes("onerror=alert"), false); assert.equal(/(?:src|href)=["']https?:/i.test(html), false);
});
test("runbook rejects empty graphs and unknown targets", () => {
assert.throws(() => createRunbookHtml({ title: "Empty", startId: "x", nodes: [] }), /node/i);
assert.throws(() => createRunbookHtml({ title: "Bad", startId: "a", nodes: [{ id: "a", label: "A", choices: [{ label: "Go", target: "b" }] }] }), /target/i);
});
+19
View File
@@ -0,0 +1,19 @@
export interface RunbookChoice { label: string; target: string }
export interface RunbookNode { id: string; label: string; detail?: string; choices?: readonly RunbookChoice[] }
export interface RunbookGraph { title: string; startId: string; nodes: readonly RunbookNode[] }
function stripMarkup(value: string): string { return value.replace(/<[^>]*>/g, "").replace(/on\w+\s*=\s*/gi, ""); }
function escapeHtml(value: string): string { return stripMarkup(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;"); }
function safeJson(value: unknown): string { return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); }
export function createRunbookHtml(graph: RunbookGraph): string {
if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) throw new Error("Runbook requires at least one node");
const nodes: readonly RunbookNode[] = graph.nodes;
const ids = new Set(nodes.map((node) => node.id));
if (ids.size !== nodes.length) throw new Error("Runbook node IDs must be unique");
if (!ids.has(graph.startId)) throw new Error("Runbook startId references an unknown node");
for (const node of nodes) for (const choice of node.choices ?? []) if (!ids.has(choice.target)) throw new Error(`Runbook choice has unknown target ${choice.target}`);
const data = { title: stripMarkup(graph.title), startId: graph.startId, nodes: nodes.map((node) => ({ id: node.id, label: stripMarkup(node.label), detail: node.detail === undefined ? undefined : stripMarkup(node.detail), choices: (node.choices ?? []).map((choice) => ({ label: stripMarkup(choice.label), target: choice.target })) })) };
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:"><title>${escapeHtml(graph.title)}</title><style>body{font:16px system-ui;margin:0;background:#f4f6f8;color:#17202a}main{max-width:760px;margin:3rem auto;padding:2rem;background:white;border-radius:0}button{display:block;width:100%;margin:.75rem 0;padding:1rem;text-align:left}nav button{display:inline-block;width:auto;margin-right:.5rem}#crumbs{margin:1rem 0;color:#566573}</style></head><body><main><h1>${escapeHtml(graph.title)}</h1><div id="crumbs" aria-label="Breadcrumb"></div><section aria-live="polite"><h2 id="label"></h2><p id="detail"></p><div id="choices"></div></section><nav><button id="back" type="button">Back</button><button id="restart" type="button">Restart</button></nav></main><script id="runbook-data" type="application/json">${safeJson(data)}</script><script>(()=>{'use strict';const d=JSON.parse(document.getElementById('runbook-data').textContent);const byId=new Map(d.nodes.map(n=>[n.id,n]));const history=[];let current=d.startId;const label=document.getElementById('label'),detail=document.getElementById('detail'),choices=document.getElementById('choices'),crumbs=document.getElementById('crumbs');function render(){const n=byId.get(current);label.textContent=n.label;detail.textContent=n.detail||'';crumbs.textContent='Breadcrumb: '+[...history,current].map(id=>byId.get(id).label).join(' › ');choices.replaceChildren();n.choices.forEach((c,i)=>{const b=document.createElement('button');b.type='button';b.textContent=(i+1)+'. '+c.label;b.addEventListener('click',()=>go(c.target));choices.appendChild(b)});document.getElementById('back').disabled=!history.length}function go(id){history.push(current);current=id;render()}function back(){if(history.length){current=history.pop();render()}}function restart(){history.length=0;current=d.startId;render()}document.getElementById('back').addEventListener('click',back);document.getElementById('restart').addEventListener('click',restart);document.addEventListener('keydown',e=>{if(e.key==='Escape'||e.key==='ArrowLeft')back();else if(e.key.toLowerCase()==='r')restart();else if(/^\\d$/.test(e.key)){const n=byId.get(current),c=n.choices[Number(e.key)-1];if(c)go(c.target)}});render()})();</script></body></html>`;
}
@@ -0,0 +1,133 @@
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);
});
+94
View File
@@ -0,0 +1,94 @@
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 clearX = (id: string, centerX: number, side: number) => centerX + side * (10 + maxDepthById.get(id)! * 10);
const sourceX = clearX(message.from, fromX, direction);
const targetX = clearX(message.to, toX, -direction);
return [
{ id: `${message.id}-source-anchor`, label: "\u200B", kind: "sequence-message-anchor", geometry: { x: sourceX - 1, y: y - 1, width: 2, height: 2 }, style: "opacity=0;fillOpacity=0;strokeOpacity=0;connectable=1", properties: { participantId: message.from, messageId: message.id, role: "source" } },
{ id: `${message.id}-target-anchor`, label: "\u200B", kind: "sequence-message-anchor", geometry: { x: targetX - 1, y: y - 1, width: 2, height: 2 }, style: "opacity=0;fillOpacity=0;strokeOpacity=0;connectable=1", properties: { participantId: message.to, messageId: message.id, role: "target" } },
];
});
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: targetX, 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,121 @@
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 });
}
});
+50
View File
@@ -0,0 +1,50 @@
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); }));
const centers = new Map<string, DiagramPoint>();
const nodes = stations.map((station, index) => {
const center = { x: 100 + index * 160, y: 100 + (firstLine.get(station.id) ?? lines.length) * 160 }; centers.set(station.id, center);
const 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), 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,71 @@
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 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,133 @@
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 flatten(ir: DiagramIRV2): { nodes: DiagramNode[]; edges: DiagramEdge[] } {
validateDiagramIR(ir);
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 semantic node ID ${id} appears on pages ${pageIds.join(", ")}`);
}
return { nodes: ir.pages.flatMap((page) => page.nodes), edges: ir.pages.flatMap((page) => page.edges) };
}
function induced(nodes: DiagramNode[], edges: DiagramEdge[], selected: Set<string>): { nodes: DiagramNode[]; edges: DiagramEdge[] } {
return { nodes: nodes.filter((node) => selected.has(node.id)).map((node) => structuredClone(node)), edges: edges.filter((edge) => selected.has(edge.source) && selected.has(edge.target)).map((edge) => structuredClone(edge)) };
}
export function projectLinkedViews(ir: DiagramIRV2, requested: ViewName[] = ["executive", "system", "deployment", "dataflow", "security"]): LinkedView[] {
const { nodes, edges } = flatten(ir);
const all = new Set(nodes.map((node) => node.id));
const degree = new Map(nodes.map((node) => [node.id, 0]));
for (const edge of edges) { degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1); degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1); }
return requested.map((name) => {
let selected = new Set<string>();
let reason: string | undefined;
let hint: string | undefined;
if (name === "system") selected = all;
else if (name === "executive") {
const hasImportance = nodes.some((node) => typeof node.properties?.importance === "number");
selected = new Set([...nodes].sort((a, b) => Number(b.properties?.importance ?? 0) - Number(a.properties?.importance ?? 0) || (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0) || a.id.localeCompare(b.id)).slice(0, 12).map((node) => node.id));
if (!hasImportance) { reason = "no properties.importance metadata; ranked by connection degree"; hint = "set properties.importance on executive-significant components"; }
} else if (name === "deployment") {
selected = new Set(nodes.filter((node) => ["environment", "region", "runtime", "host", "deployment"].some((key) => node.properties?.[key] !== undefined)).map((node) => node.id));
if (!selected.size) { selected = all; reason = "no deployment metadata"; hint = "set properties.environment, region, runtime, host, or deployment"; }
} else if (name === "dataflow") {
const relevant = edges.filter((edge) => ["data", "read", "write", "async"].includes(edge.kind ?? "") || /data|event|read|write|publish|consume/i.test(edge.label ?? ""));
selected = new Set(relevant.flatMap((edge) => [edge.source, edge.target]));
if (!selected.size) { selected = all; reason = "no data-flow edges"; hint = "set edge kind to data, read, write, or async"; }
} else {
selected = new Set(nodes.filter((node) => ["external", "gateway", "database", "actor"].includes(node.kind ?? "") || node.properties?.trust_boundary !== undefined).map((node) => node.id));
for (const edge of edges) {
const source = nodes.find((node) => node.id === edge.source)?.properties?.trust_boundary;
const target = nodes.find((node) => node.id === edge.target)?.properties?.trust_boundary;
if (source !== target && (source !== undefined || target !== undefined)) { selected.add(edge.source); selected.add(edge.target); }
}
if (!selected.size) { selected = all; reason = "no trust-boundary or security-kind metadata"; hint = "set properties.trust_boundary or kind external/database/gateway/actor"; }
}
const projection = induced(nodes, edges, selected);
return { id: name, title: name[0].toUpperCase() + name.slice(1), ...projection, sourcePageIds: ir.pages.map((page) => page.id), fallback: reason !== undefined, ...(reason ? { fallbackReason: reason, hint } : {}) };
});
}
export function queryDiagram(ir: DiagramIRV2, options: QueryOptions): QueryResult {
const { nodes, edges } = flatten(ir);
if ((options.from === undefined) !== (options.to === undefined)) throw new Error("Path query requires both from and to");
if (options.from && options.to) {
const ids = new Set(nodes.map((node) => node.id));
if (!ids.has(options.from) || !ids.has(options.to)) throw new Error("Path query references an unknown node");
const queue = [options.from];
const previous = new Map<string, { node: string; edge: DiagramEdge } | null>([[options.from, null]]);
while (queue.length) {
const current = queue.shift()!;
if (current === options.to) break;
for (const edge of edges.filter((item) => item.source === current).sort((a, b) => a.target.localeCompare(b.target) || a.id.localeCompare(b.id))) {
if (!previous.has(edge.target)) { previous.set(edge.target, { node: current, edge }); queue.push(edge.target); }
}
}
if (!previous.has(options.to)) return { nodes: [], edges: [], path: [] };
const path: string[] = [];
const pathEdges: DiagramEdge[] = [];
let current = options.to;
while (true) {
path.push(current);
const prior = previous.get(current)!;
if (!prior) break;
pathEdges.push(prior.edge);
current = prior.node;
}
path.reverse(); pathEdges.reverse();
const selected = new Set(path);
return { nodes: nodes.filter((node) => selected.has(node.id)), edges: pathEdges, path };
}
const selectedNodes = nodes.filter((node) => (!options.kind || node.kind === options.kind) && Object.entries(options.properties ?? {}).every(([key, value]) => JSON.stringify(node.properties?.[key]) === JSON.stringify(value)));
const selected = new Set(selectedNodes.map((node) => node.id));
return { nodes: selectedNodes, edges: edges.filter((edge) => selected.has(edge.source) && selected.has(edge.target)) };
}
function cycles(nodes: DiagramNode[], edges: DiagramEdge[]): string[][] {
const adjacency = new Map<string, string[]>();
for (const edge of edges) adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target].sort());
const visiting = new Set<string>(); const done = new Set<string>(); const stack: string[] = []; const found: string[][] = [];
const visit = (id: string): void => {
if (visiting.has(id)) { const start = stack.indexOf(id); found.push([...stack.slice(start), id]); return; }
if (done.has(id)) return;
visiting.add(id); stack.push(id);
for (const target of adjacency.get(id) ?? []) visit(target);
stack.pop(); visiting.delete(id); done.add(id);
};
for (const node of [...nodes].sort((a, b) => a.id.localeCompare(b.id))) visit(node.id);
return found;
}
export const POLICY_IDS = ["no-direct-internet-to-database", "no-cycles", "no-orphans", "every-service-has-owner", "production-has-observability", "external-dependencies-have-timeouts", "trust-boundaries-use-protocol"] as const;
export function runPolicies(ir: DiagramIRV2, enabled: readonly string[] = POLICY_IDS): PolicyReport {
const { nodes, edges } = flatten(ir); const byId = new Map(nodes.map((node) => [node.id, node])); const findings: PolicyFinding[] = [];
const add = (rule: string, severity: "error" | "warning", subject: string, message: string, hint: string): void => { findings.push({ rule, severity, subject, message, hint }); };
if (enabled.includes("no-direct-internet-to-database")) for (const edge of edges) { const source = byId.get(edge.source); const target = byId.get(edge.target); if ((source?.kind === "external" || source?.kind === "actor" || /internet/i.test(source?.label ?? "")) && target?.kind === "database") add("no-direct-internet-to-database", "error", edge.id, "internet-facing component connects directly to a database", "insert an authenticated service boundary"); }
if (enabled.includes("no-cycles")) for (const cycle of cycles(nodes, edges)) add("no-cycles", "warning", cycle.join(" -> "), "cyclic dependency detected", "break the cycle or add an asynchronous boundary");
if (enabled.includes("no-orphans") && nodes.length > 1) for (const node of nodes) if (!node.properties?.intentional_orphan && !edges.some((edge) => edge.source === node.id || edge.target === node.id)) add("no-orphans", "warning", node.id, "component is disconnected", "connect it or set properties.intentional_orphan=true");
if (enabled.includes("every-service-has-owner")) for (const node of nodes) if (["service", "gateway", "database", "queue"].includes(node.kind ?? "") && !node.properties?.owner) add("every-service-has-owner", "warning", node.id, "service has no owner", "set properties.owner");
if (enabled.includes("production-has-observability")) for (const node of nodes) if (["prod", "production"].includes(String(node.properties?.environment ?? "").toLowerCase()) && !node.properties?.observability) add("production-has-observability", "warning", node.id, "production component lacks observability metadata", "set properties.observability");
if (enabled.includes("external-dependencies-have-timeouts")) for (const edge of edges) if (byId.get(edge.target)?.kind === "external" && !edge.properties?.timeout) add("external-dependencies-have-timeouts", "warning", edge.id, "external dependency has no timeout", "set edge properties.timeout");
if (enabled.includes("trust-boundaries-use-protocol")) for (const edge of edges) { const a = byId.get(edge.source)?.properties?.trust_boundary; const b = byId.get(edge.target)?.properties?.trust_boundary; if (a !== b && !edge.properties?.protocol && !edge.label) add("trust-boundaries-use-protocol", "warning", edge.id, "unlabelled connection crosses a trust boundary", "set edge properties.protocol"); }
findings.sort((a, b) => a.rule.localeCompare(b.rule) || a.subject.localeCompare(b.subject));
return { errors: findings.filter((item) => item.severity === "error").length, warnings: findings.filter((item) => item.severity === "warning").length, findings };
}
export function simulateFailure(ir: DiagramIRV2, failed: string): { failed: string; impacted: string[]; paths: Record<string, string[]> } {
const { nodes, edges } = flatten(ir); if (!nodes.some((node) => node.id === failed)) throw new Error(`Unknown node: ${failed}`);
const queue = [failed]; const paths: Record<string, string[]> = { [failed]: [failed] }; const impacted = new Set<string>();
while (queue.length) { const current = queue.shift()!; for (const edge of edges.filter((item) => item.source === current).sort((a, b) => a.target.localeCompare(b.target) || a.id.localeCompare(b.id))) { if (edge.properties?.isolates_failure === true || paths[edge.target]) continue; paths[edge.target] = [...paths[current], edge.target]; impacted.add(edge.target); queue.push(edge.target); } }
return { failed, impacted: [...impacted].sort(), paths };
}
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { applyEditBatch, type EditBatch } from "./edit-batch.js";
const SOURCE: DiagramIRV2 = { version: 2, pages: [{ id: "main", title: "Main", layout: { type: "manual" }, nodes: [
{ id: "zone", label: "Zone", kind: "container", geometry: { x: 0, y: 0, width: 400, height: 300 } },
{ id: "api", label: "API", kind: "service", parentId: "zone", geometry: { x: 20, y: 40, width: 120, height: 60 } },
{ id: "db", label: "DB", kind: "database", geometry: { x: 500, y: 40, width: 120, height: 60 } },
], edges: [{ id: "query", source: "api", target: "db", label: "SQL" }] }] };
test("transactional edits apply typed add/update/move/connect operations with stable IDs", () => {
const batch: EditBatch = { pageId: "main", preconditions: [{ type: "exists", id: "api" }, { type: "not-exists", id: "worker" }], operations: [
{ type: "add", node: { id: "worker", label: "Worker", kind: "service", geometry: { x: 200, y: 40, width: 120, height: 60 } } },
{ type: "update", id: "api", changes: { label: "Public API", properties: { owner: "platform" } } },
{ type: "move", id: "worker", parentId: "zone", geometry: { x: 180, y: 140, width: 120, height: 60 } },
{ type: "connect", edge: { id: "dispatch", source: "api", target: "worker", kind: "async" } },
] };
const result = applyEditBatch(SOURCE, batch);
assert.equal(result.ir.pages[0].nodes.find((node) => node.id === "api")?.label, "Public API");
assert.equal(result.ir.pages[0].nodes.find((node) => node.id === "worker")?.parentId, "zone");
assert.equal(result.ir.pages[0].edges.find((edge) => edge.id === "dispatch")?.target, "worker");
assert.deepEqual(SOURCE.pages[0].nodes.map((node) => node.id), ["zone", "api", "db"]);
});
test("edit batches reject unknown precondition types", () => {
const batch = { pageId: "main", preconditions: [{ type: "surprise", id: "missing" }], operations: [] } as unknown as EditBatch;
assert.throws(() => applyEditBatch(SOURCE, batch), /unknown precondition type.*surprise/i);
});
test("edit batches reject unknown operation types instead of deleting", () => {
const batch = { pageId: "main", operations: [{ type: "surprise", id: "api" }] } as unknown as EditBatch;
assert.throws(() => applyEditBatch(SOURCE, batch), /unknown operation type.*surprise/i);
assert.ok(SOURCE.pages[0].nodes.some((node) => node.id === "api"));
});
test("edit batches reject id changes in runtime update payloads", () => {
const batch = { pageId: "main", operations: [{ type: "update", id: "api", changes: { id: "renamed" } }] } as unknown as EditBatch;
assert.throws(() => applyEditBatch(SOURCE, batch), /update changes.*id/i);
});
test("edit batches are atomic, support dry-run, and require explicit cascade", () => {
const before = JSON.stringify(SOURCE);
assert.throws(() => applyEditBatch(SOURCE, { pageId: "main", operations: [{ type: "delete", id: "zone" }] }), /cascade/i);
assert.equal(JSON.stringify(SOURCE), before);
assert.throws(() => applyEditBatch(SOURCE, { pageId: "main", operations: [{ type: "update", id: "api", changes: { parentId: "missing" } }] }), /unknown parent/i);
assert.equal(JSON.stringify(SOURCE), before);
const dry = applyEditBatch(SOURCE, { pageId: "main", operations: [{ type: "delete", id: "zone", cascade: true }] }, { dryRun: true });
assert.deepEqual(dry.ir, SOURCE);
assert.deepEqual(dry.preview.pages[0].nodes.map((node) => node.id), ["db"]);
});
@@ -0,0 +1,95 @@
import type { DiagramEdge, DiagramGeometry, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export type EditPrecondition =
| { type: "exists"; id: string }
| { type: "not-exists"; id: string }
| { type: "property-equals"; id: string; property: string; value: unknown };
export type EditOperation =
| { type: "add"; node: DiagramNode }
| { type: "update"; id: string; changes: Partial<Omit<DiagramNode, "id">> }
| { type: "move"; id: string; parentId?: string; geometry: DiagramGeometry }
| { type: "delete"; id: string; cascade?: boolean }
| { type: "connect"; edge: DiagramEdge };
export interface EditBatch {
pageId: string;
preconditions?: EditPrecondition[];
operations: EditOperation[];
}
export interface EditBatchResult {
ir: DiagramIRV2;
preview: DiagramIRV2;
applied: number;
dryRun: boolean;
}
function getItem(page: DiagramIRV2["pages"][number], id: string): DiagramNode | DiagramEdge | undefined {
return page.nodes.find((node) => node.id === id) ?? page.edges.find((edge) => edge.id === id);
}
export function applyEditBatch(source: DiagramIRV2, batch: EditBatch, options: { dryRun?: boolean } = {}): EditBatchResult {
validateDiagramIR(source);
const candidate = structuredClone(source);
const page = candidate.pages.find((item) => item.id === batch.pageId);
if (!page) throw new Error(`Unknown page: ${batch.pageId}`);
for (const condition of batch.preconditions ?? []) {
const item = getItem(page, condition.id);
if (condition.type === "exists" && !item) throw new Error(`Precondition failed: ${condition.id} does not exist`);
if (condition.type === "not-exists" && item) throw new Error(`Precondition failed: ${condition.id} already exists`);
if (condition.type === "property-equals") {
const actual = item?.properties?.[condition.property];
if (!item || JSON.stringify(actual) !== JSON.stringify(condition.value)) throw new Error(`Precondition failed: ${condition.id}.${condition.property} differs`);
} else if (condition.type !== "exists" && condition.type !== "not-exists") {
throw new Error(`Unknown precondition type: ${(condition as { type?: unknown }).type}`);
}
}
for (const operation of batch.operations) {
if (operation.type === "add") {
if (getItem(page, operation.node.id)) throw new Error(`ID already exists: ${operation.node.id}`);
page.nodes.push(structuredClone(operation.node));
} else if (operation.type === "connect") {
if (getItem(page, operation.edge.id)) throw new Error(`ID already exists: ${operation.edge.id}`);
page.edges.push(structuredClone(operation.edge));
} else if (operation.type === "update") {
if (Object.hasOwn(operation.changes, "id")) throw new Error("Update changes must not include id");
const node = page.nodes.find((item) => item.id === operation.id);
if (!node) throw new Error(`Unknown node: ${operation.id}`);
Object.assign(node, structuredClone(operation.changes));
} else if (operation.type === "move") {
const node = page.nodes.find((item) => item.id === operation.id);
if (!node) throw new Error(`Unknown node: ${operation.id}`);
node.geometry = structuredClone(operation.geometry);
if (operation.parentId === undefined) delete node.parentId;
else node.parentId = operation.parentId;
} else if (operation.type === "delete") {
const nodeIndex = page.nodes.findIndex((item) => item.id === operation.id);
const edgeIndex = page.edges.findIndex((item) => item.id === operation.id);
if (nodeIndex < 0 && edgeIndex < 0) throw new Error(`Unknown item: ${operation.id}`);
if (edgeIndex >= 0) {
page.edges.splice(edgeIndex, 1);
continue;
}
const descendants = new Set<string>();
const collect = (parentId: string): void => {
for (const node of page.nodes) if (node.parentId === parentId && !descendants.has(node.id)) { descendants.add(node.id); collect(node.id); }
};
collect(operation.id);
const affected = new Set([operation.id, ...descendants]);
const connected = page.edges.filter((edge) => affected.has(edge.source) || affected.has(edge.target));
if (!operation.cascade && (descendants.size || connected.length)) throw new Error(`Delete ${operation.id} requires cascade for descendants or connected edges`);
page.nodes = page.nodes.filter((node) => !affected.has(node.id));
page.edges = page.edges.filter((edge) => !affected.has(edge.source) && !affected.has(edge.target));
} else {
throw new Error(`Unknown operation type: ${(operation as { type?: unknown }).type}`);
}
}
validateDiagramIR(candidate);
const dryRun = options.dryRun === true;
return { ir: dryRun ? source : candidate, preview: candidate, applied: batch.operations.length, dryRun };
}
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { JSDOM } from "jsdom";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { importDrawioToIR } from "./import-drawio.js";
test("loss-aware import preserves multi-page semantics and round-trips deterministically", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-import-"));
const source = join(dir, "source.drawio");
try {
writeFileSync(source, `<?xml version="1.0"?><mxfile host="desktop" custom-root="keep"><diagram id="system" name="System" custom-page="p"><mxGraphModel pageWidth="900" pageHeight="700" custom-model="m"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="zone" value="Zone" vertex="1" parent="1" style="swimlane;startSize=30;" custom-cell="c"><mxGeometry x="20" y="20" width="500" height="260" as="geometry" custom-geo="g"/></mxCell><mxCell id="api" value="API" vertex="1" parent="zone" style="fillColor=#fff;" data-kind="service" data-properties="{&quot;owner&quot;:&quot;team-a&quot;}" data-provenance="{&quot;source&quot;:&quot;catalog&quot;}" data-extensions="{&quot;x&quot;:1}"><mxGeometry x="40" y="60" width="120" height="60" as="geometry"/><Custom payload="keep"/></mxCell><mxCell id="call" value="HTTPS" edge="1" parent="1" source="api" target="zone" style="edgeStyle=orthogonalEdgeStyle;" data-kind="request"><mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="220" y="100"/></Array></mxGeometry></mxCell><mxCell id="note" value="raw" parent="1" custom="yes"/></root></mxGraphModel></diagram><diagram id="deploy" name="Deployment"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="host" value="Host" vertex="1" parent="1"><mxGeometry x="10" y="10" width="100" height="50" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`, "utf8");
const imported = importDrawioToIR(source);
assert.equal(imported.ir.pages.length, 2);
const page = imported.ir.pages[0];
assert.equal(page.id, "system");
assert.equal(page.nodes.find((node) => node.id === "api")?.parentId, "zone");
assert.deepEqual(page.nodes.find((node) => node.id === "api")?.geometry, { x: 40, y: 60, width: 120, height: 60 });
assert.deepEqual(page.nodes.find((node) => node.id === "api")?.properties, { owner: "team-a" });
assert.deepEqual(page.edges[0].waypoints, [{ x: 220, y: 100 }]);
assert.equal(imported.lossReport.lossy, false);
assert.ok(JSON.stringify(imported.ir.extensions).includes("custom-root"));
assert.ok(JSON.stringify(page.extensions).includes("note"));
const rebuilt = diagramIRToDrawio(imported.ir);
assert.match(rebuilt, /<mxfile host="desktop"/);
const roundTrip = importDrawioToIR(rebuilt, { sourceIsXml: true });
assert.deepEqual(roundTrip.ir.pages.map((item) => item.id), ["system", "deploy"]);
assert.deepEqual(roundTrip.ir.pages[0].nodes, page.nodes);
assert.deepEqual(roundTrip.ir.pages[0].edges, page.edges);
assert.equal(diagramIRToDrawio(roundTrip.ir), rebuilt);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("import and serialization preserve model flags, node geometry children, and absent node style without false losslessness", () => {
const xml = `<mxfile><diagram id="p" name="P"><mxGraphModel grid="0" page="0"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="10" y="20" width="100" height="50" relative="1" as="geometry"><mxPoint x="3" y="4" as="offset"/></mxGeometry></mxCell></root></mxGraphModel></diagram></mxfile>`;
const imported = importDrawioToIR(xml, { sourceIsXml: true });
assert.equal(imported.lossReport.lossy, false);
const rebuilt = diagramIRToDrawio(imported.ir);
const document = new JSDOM(rebuilt, { contentType: "text/xml" }).window.document;
const model = document.querySelector("mxGraphModel")!;
const node = document.querySelector("mxCell#n")!;
const geometry = node.querySelector(":scope > mxGeometry")!;
const offset = geometry.querySelector(":scope > mxPoint[as='offset']")!;
assert.equal(model.getAttribute("grid"), "0");
assert.equal(model.getAttribute("page"), "0");
assert.equal(node.hasAttribute("style"), false);
assert.equal(geometry.getAttribute("relative"), "1");
assert.deepEqual([offset.getAttribute("x"), offset.getAttribute("y")], ["3", "4"]);
assert.equal(importDrawioToIR(rebuilt, { sourceIsXml: true }).lossReport.lossy, false);
});
test("import rejects duplicate semantic node IDs across pages", () => {
const xml = `<mxfile><diagram id="one" name="One"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="First" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="two" name="Two"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="Second" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`;
assert.throws(() => importDrawioToIR(xml, { sourceIsXml: true }), /duplicate semantic node id.*api.*pages.*one.*two/i);
});
test("loss-aware import fails closed on dangling endpoints", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="A" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell><mxCell id="bad" edge="1" source="a" target="missing"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel>`;
assert.throws(() => importDrawioToIR(xml, { sourceIsXml: true }), /dangling endpoint.*bad.*missing/i);
});
test("loss-aware import preserves valid Draw.io IDs that begin with digits", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="42-node" value="Numeric" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`;
assert.equal(importDrawioToIR(xml, { sourceIsXml: true }).ir.pages[0].nodes[0].id, "42-node");
});
test("loss-aware import preserves unknown mxfile, diagram, and model children", () => {
const xml = `<mxfile><meta-marker keep="root"/><diagram id="p" name="P"><diagram-marker keep="page"/><mxGraphModel><model-marker keep="model"/><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`;
const imported = importDrawioToIR(xml, { sourceIsXml: true });
assert.equal(imported.lossReport.lossy, false);
const rebuilt = diagramIRToDrawio(imported.ir);
assert.match(rebuilt, /<meta-marker keep="root"\s*\/>/);
assert.match(rebuilt, /<diagram-marker keep="page"\s*\/>/);
assert.match(rebuilt, /<model-marker keep="model"\s*\/>/);
const second = importDrawioToIR(rebuilt, { sourceIsXml: true });
assert.equal(second.lossReport.lossy, false);
assert.equal(diagramIRToDrawio(second.ir), rebuilt);
});
test("loss-aware import preserves deliberately empty labels", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="icon" value="" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`;
const imported = importDrawioToIR(xml, { sourceIsXml: true }).ir;
assert.equal(imported.pages[0].nodes[0].label, "icon");
assert.match(JSON.stringify(imported.pages[0].nodes[0].extensions), /semantic:label/);
assert.match(diagramIRToDrawio(imported), /id="icon" value=""/);
});
test("loss-aware import envelopes malformed semantic attributes instead of dropping them", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="API" vertex="1" parent="1" data-properties="not-json"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`;
const result = importDrawioToIR(xml, { sourceIsXml: true });
assert.match(JSON.stringify(result.ir.pages[0].nodes[0].extensions), /data-properties.*not-json/);
});
@@ -0,0 +1,237 @@
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import type { DiagramEdge, DiagramIRV2, DiagramNode, DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
import { decodeDiagramContent, normalizeDrawioDocument } from "../drawio-parser/parser.js";
export interface PreservationEnvelope {
host?: string;
attributes?: Record<string, string>;
holderTag?: string;
holderAttributes?: Record<string, string>;
childXml?: string[];
geometryAttributes?: Record<string, string>;
geometryChildXml?: string[];
styleAbsent?: boolean;
modelAttributes?: Record<string, string>;
modelChildXml?: string[];
unknownCells?: string[];
}
export interface ImportLossReport {
lossy: boolean;
preserved: string[];
warnings: string[];
}
export interface ImportDrawioOptions {
sourceIsXml?: boolean;
}
const CELL_KNOWN = new Set(["id", "value", "label", "style", "vertex", "edge", "parent", "source", "target", "data-kind", "data-properties", "data-provenance", "data-extensions"]);
const GEOMETRY_KNOWN = new Set(["x", "y", "width", "height", "relative", "as"]);
const PAGE_KNOWN = new Set(["id", "name", "data-properties", "data-extensions"]);
const MODEL_KNOWN = new Set(["pageWidth", "pageHeight", "grid", "gridSize", "page"]);
const ROOT_KNOWN = new Set(["host", "data-version", "data-title", "data-theme", "data-provenance", "data-assets", "data-properties", "data-extensions"]);
function parseDocument(xml: string, expected: "mxfile" | "mxGraphModel"): Document {
if (/<!DOCTYPE/i.test(xml)) throw new Error("Invalid XML: DOCTYPE is not allowed");
const Parser = new JSDOM("<root/>", { contentType: "text/xml" }).window.DOMParser;
const parsed = new Parser().parseFromString(xml, "text/xml");
if (parsed.querySelector("parsererror") || parsed.documentElement.tagName !== expected) {
throw new Error(`Invalid XML: expected complete <${expected}> document`);
}
return parsed;
}
function serializeElement(element: Element): string {
const XMLSerializer = new JSDOM("<root/>", { contentType: "text/xml" }).window.XMLSerializer;
return new XMLSerializer().serializeToString(element);
}
function attrs(element: Element, known: Set<string>): Record<string, string> | undefined {
const result: Record<string, string> = {};
for (const attribute of Array.from(element.attributes).sort((a, b) => a.name.localeCompare(b.name))) {
if (!known.has(attribute.name)) result[attribute.name] = attribute.value;
}
return Object.keys(result).length ? result : undefined;
}
function selectedAttrs(element: Element, names: Set<string>): Record<string, string> | undefined {
const result: Record<string, string> = {};
for (const name of names) if (element.hasAttribute(name)) result[name] = element.getAttribute(name)!;
return Object.keys(result).length ? result : undefined;
}
function jsonObject(element: Element, name: string): Record<string, unknown> | undefined {
const raw = element.getAttribute(name);
if (!raw) return undefined;
try {
const value: unknown = JSON.parse(raw);
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
} catch {
// Invalid semantic JSON remains preserved as an unknown attribute below.
}
return undefined;
}
function numberAttr(element: Element | null, name: string, fallback = 0): number {
const raw = element?.getAttribute(name);
if (raw === null || raw === undefined || raw === "") return fallback;
const value = Number(raw);
if (!Number.isFinite(value)) throw new Error(`Invalid geometry ${name}: ${raw}`);
return value;
}
function mergePreservation(extensions: Record<string, unknown> | undefined, envelope: PreservationEnvelope): Record<string, unknown> | undefined {
const clean = Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined && (!Array.isArray(value) || value.length > 0)));
if (!Object.keys(clean).length) return extensions;
return { ...(extensions ?? {}), $drawio: clean };
}
function cellRows(model: Element): Array<{ holder: Element; cell: Element }> {
const root = Array.from(model.children).find((child) => child.tagName === "root");
if (!root) throw new Error("Draw.io page is missing <root>");
const rows: Array<{ holder: Element; cell: Element }> = [];
for (const child of Array.from(root.children)) {
if (child.tagName === "mxCell") rows.push({ holder: child, cell: child });
else if (child.tagName === "object" || child.tagName === "UserObject") {
const cell = Array.from(child.children).find((nested) => nested.tagName === "mxCell");
if (cell) rows.push({ holder: child, cell });
}
}
return rows;
}
function parsePage(modelXml: string, pageElement: Element | undefined, index: number, report: ImportLossReport): DiagramPage {
const document = parseDocument(modelXml, "mxGraphModel");
const model = document.documentElement;
const rows = cellRows(model);
const nodeIds = new Set(rows.filter(({ cell }) => cell.getAttribute("vertex") === "1").map(({ holder, cell }) => holder.getAttribute("id") ?? cell.getAttribute("id") ?? ""));
const root = Array.from(model.children).find((child) => child.tagName === "root")!;
const knownElements = new Set(rows.map(({ holder }) => holder));
const unknownCells = Array.from(root.children).filter((child) => !knownElements.has(child) && !["0", "1"].includes(child.getAttribute("id") ?? "")).map((child) => serializeElement(child));
const nodes: DiagramNode[] = [];
const edges: DiagramEdge[] = [];
for (const { holder, cell } of rows) {
const id = holder.getAttribute("id") ?? cell.getAttribute("id") ?? "";
if (!id || id === "0" || id === "1") continue;
const semantic = holder === cell ? cell : holder;
const geometry = Array.from(cell.children).find((child) => child.tagName === "mxGeometry") ?? null;
const envelope: PreservationEnvelope = {
attributes: attrs(cell, CELL_KNOWN),
holderTag: holder === cell ? undefined : holder.tagName,
holderAttributes: holder === cell ? undefined : attrs(holder, new Set(["id", "label", "value", "data-kind", "data-properties", "data-provenance", "data-extensions"])),
childXml: Array.from(cell.children).filter((child) => child.tagName !== "mxGeometry").map((child) => serializeElement(child)),
geometryAttributes: geometry ? selectedAttrs(geometry, new Set(["relative", "as"])) : undefined,
geometryChildXml: geometry ? Array.from(geometry.children).filter((child) => !(cell.getAttribute("edge") === "1" && child.matches("Array[as='points']"))).map((child) => serializeElement(child)) : undefined,
styleAbsent: cell.getAttribute("vertex") === "1" && !cell.hasAttribute("style") ? true : undefined,
};
for (const name of ["data-properties", "data-provenance", "data-extensions"]) {
const owner = semantic.hasAttribute(name) ? semantic : cell;
if (owner.hasAttribute(name) && jsonObject(owner, name) === undefined) {
envelope.attributes = { ...(envelope.attributes ?? {}), [name]: owner.getAttribute(name)! };
}
}
const geometryAttrs = geometry ? attrs(geometry, GEOMETRY_KNOWN) : undefined;
if (geometryAttrs) envelope.attributes = { ...(envelope.attributes ?? {}), ...Object.fromEntries(Object.entries(geometryAttrs).map(([key, value]) => [`geometry:${key}`, value])) };
const rawLabel = semantic.getAttribute("label") ?? semantic.getAttribute("value") ?? cell.getAttribute("value") ?? id;
if (rawLabel.trim() === "") envelope.attributes = { ...(envelope.attributes ?? {}), "semantic:label": rawLabel };
const extensions = mergePreservation(jsonObject(semantic, "data-extensions") ?? jsonObject(cell, "data-extensions"), envelope);
const label = rawLabel.trim() === "" ? id : rawLabel;
if (cell.getAttribute("vertex") === "1") {
nodes.push({
id,
label,
kind: semantic.getAttribute("data-kind") ?? cell.getAttribute("data-kind") ?? (nodeIds.has(id) && rows.some((row) => row.cell.getAttribute("parent") === id) ? "container" : "service"),
...(cell.getAttribute("parent") && cell.getAttribute("parent") !== "1" ? { parentId: cell.getAttribute("parent")! } : {}),
geometry: { x: numberAttr(geometry, "x"), y: numberAttr(geometry, "y"), width: numberAttr(geometry, "width"), height: numberAttr(geometry, "height") },
...(cell.hasAttribute("style") ? { style: cell.getAttribute("style")! } : {}),
...(jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") ? { properties: jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") } : {}),
...(jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") ? { provenance: jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") } : {}),
...(extensions ? { extensions } : {}),
});
} else if (cell.getAttribute("edge") === "1") {
const source = cell.getAttribute("source") ?? "";
const target = cell.getAttribute("target") ?? "";
const missing = [source, target].filter((endpoint) => !nodeIds.has(endpoint));
if (missing.length) throw new Error(`Unsupported dangling endpoint on edge ${id}: ${missing.join(", ")}`);
const points = geometry ? Array.from(geometry.querySelectorAll("Array[as='points'] > mxPoint")).map((point) => ({ x: numberAttr(point, "x"), y: numberAttr(point, "y") })) : [];
edges.push({
id, source, target, label: rawLabel,
kind: semantic.getAttribute("data-kind") ?? cell.getAttribute("data-kind") ?? "relation",
...(cell.hasAttribute("style") ? { style: cell.getAttribute("style")! } : {}),
...(points.length ? { waypoints: points } : {}),
...(jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") ? { properties: jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") } : {}),
...(jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") ? { provenance: jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") } : {}),
...(extensions ? { extensions } : {}),
});
} else {
unknownCells.push(serializeElement(holder));
}
}
const pageExtensions = mergePreservation(jsonObject(pageElement ?? model, "data-extensions"), {
attributes: { ...(pageElement ? attrs(pageElement, PAGE_KNOWN) : {}), ...Object.fromEntries(Object.entries(attrs(model, MODEL_KNOWN) ?? {}).map(([key, value]) => [`model:${key}`, value])) },
modelAttributes: selectedAttrs(model, MODEL_KNOWN),
childXml: pageElement ? Array.from(pageElement.children).filter((child) => child.tagName !== "mxGraphModel").map(serializeElement) : undefined,
modelChildXml: Array.from(model.children).filter((child) => child !== root).map(serializeElement),
unknownCells,
});
if (unknownCells.length) report.preserved.push(`page ${index + 1}: ${unknownCells.length} unknown cell(s)`);
return {
id: pageElement?.getAttribute("id") || `page-${index + 1}`,
title: pageElement?.getAttribute("name") || `Page ${index + 1}`,
nodes,
edges,
layout: { type: "manual", gridSize: numberAttr(model, "gridSize", 10) },
...(numberAttr(model, "pageWidth") > 0 ? { width: numberAttr(model, "pageWidth") } : {}),
...(numberAttr(model, "pageHeight") > 0 ? { height: numberAttr(model, "pageHeight") } : {}),
...(jsonObject(pageElement ?? model, "data-properties") ? { properties: jsonObject(pageElement ?? model, "data-properties") } : {}),
...(pageExtensions ? { extensions: pageExtensions } : {}),
};
}
export function importDrawioToIR(source: string, options: ImportDrawioOptions = {}): { ir: DiagramIRV2; lossReport: ImportLossReport } {
const raw = normalizeDrawioDocument(options.sourceIsXml ? source : readFileSync(source, "utf8"));
const report: ImportLossReport = { lossy: false, preserved: [], warnings: [] };
let pages: DiagramPage[];
let root: Element | undefined;
if (/^<mxfile/i.test(raw)) {
const document = parseDocument(raw, "mxfile");
root = document.documentElement;
const diagrams = Array.from(root.children).filter((child) => child.tagName === "diagram");
if (!diagrams.length) throw new Error("No <diagram> elements found in mxfile");
pages = diagrams.map((diagram, index) => {
const model = Array.from(diagram.children).find((child) => child.tagName === "mxGraphModel");
return parsePage(model ? serializeElement(model) : decodeDiagramContent(diagram.textContent ?? ""), diagram, index, report);
});
} else {
pages = [parsePage(raw, undefined, 0, report)];
}
const nodePages = new Map<string, string>();
for (const page of pages) for (const node of page.nodes) {
const priorPage = nodePages.get(node.id);
if (priorPage !== undefined) throw new Error(`Duplicate semantic node ID ${node.id} across pages ${priorPage} and ${page.id}`);
nodePages.set(node.id, page.id);
}
const extensions = root ? mergePreservation(jsonObject(root, "data-extensions"), {
host: root.getAttribute("host") ?? undefined,
attributes: attrs(root, ROOT_KNOWN),
childXml: Array.from(root.children).filter((child) => child.tagName !== "diagram").map(serializeElement),
}) : undefined;
const ir: DiagramIRV2 = {
version: 2,
...(root?.getAttribute("data-title") ? { title: root.getAttribute("data-title")! } : {}),
pages,
...(root ? (jsonObject(root, "data-provenance") ? { provenance: jsonObject(root, "data-provenance") } : {}) : {}),
...(root ? (jsonObject(root, "data-assets") ? { assets: jsonObject(root, "data-assets") } : {}) : {}),
...(root?.getAttribute("data-theme") ? { theme: root.getAttribute("data-theme")! } : {}),
...(root ? (jsonObject(root, "data-properties") ? { properties: jsonObject(root, "data-properties") } : {}) : {}),
...(extensions ? { extensions } : {}),
};
validateDiagramIR(ir);
return { ir, lossReport: report };
}
@@ -0,0 +1,35 @@
import { lstatSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { basename, dirname, extname, resolve } from "node:path";
import * as yaml from "js-yaml";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { normalizeDiagramIR, validateDiagramIR } from "../../model/diagram-ir.js";
import { importDrawioToIR } from "./import-drawio.js";
export interface LifecycleActionOptions { spec?: string; base?: string; strict?: boolean; prune?: boolean; dryRun?: boolean; fail?: string; views?: string; kind?: string; property?: string[]; from?: string; to?: string }
export function loadStructured(path: string): unknown { return yaml.load(readFileSync(path, "utf8"), { schema: yaml.JSON_SCHEMA }); }
export function loadIR(path: string): DiagramIRV2 { return /\.(drawio|xml)$/i.test(path) ? importDrawioToIR(path).ir : normalizeDiagramIR(validateDiagramIR(loadStructured(path))); }
export function structuredText(value: unknown, path: string): string { return extname(path).toLowerCase() === ".json" ? `${JSON.stringify(value, null, 2)}\n` : yaml.dump(value, { noRefs: true, sortKeys: false, lineWidth: 120 }); }
function canonicalExisting(path: string): string {
const absolute = resolve(path);
try { return realpathSync(absolute); } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return absolute;
throw error;
}
}
export function assertOutputSafe(path: string, inputs: string[] = []): string {
const output = resolve(path);
try { if (lstatSync(output).isSymbolicLink()) throw new Error("Refusing to overwrite a symbolic link"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
const canonicalOutput = canonicalExisting(output);
if (inputs.some((input) => canonicalExisting(input) === canonicalOutput)) throw new Error("Refusing to overwrite an input path (including traversal aliases)");
return output;
}
export function atomicWrite(path: string, content: string, inputs: string[] = []): string {
const output = assertOutputSafe(path, inputs);
const temporary = resolve(dirname(output), `.${basename(output)}.${process.pid}.${Date.now()}.tmp`);
try { writeFileSync(temporary, content, { encoding: "utf8", flag: "wx" }); renameSync(temporary, output); } finally { rmSync(temporary, { force: true }); }
return output;
}
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { createStoryHtml, doctorReport } from "./publishing.js";
const ir: DiagramIRV2 = { version: 2, title: "Platform", pages: [{ id: "p", title: "P", nodes: [
{ id: "api", label: "API <primary>", kind: "service", properties: { owner: "team" }, provenance: { source: "catalog" }, geometry: { x: 10, y: 20, width: 120, height: 60 } },
{ id: "db", label: "DB", kind: "database", geometry: { x: 220, y: 20, width: 120, height: 60 } },
], edges: [{ id: "write", source: "api", target: "db", label: "SQL" }] }] };
test("story HTML is deterministic, offline, accessible, keyboard navigable, and supports a what-if overlay", () => {
const html = createStoryHtml(ir, { scenario: { failed: "api", impacted: ["db"] } });
assert.equal(createStoryHtml(ir, { scenario: { failed: "api", impacted: ["db"] } }), html);
assert.match(html, /^<!doctype html>/i);
assert.match(html, /http-equiv="Content-Security-Policy"/i);
assert.match(html, /default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'/i);
assert.doesNotMatch(html, /https?:\/\//i);
assert.doesNotMatch(html, /<script[^>]+src=|<link[^>]+href=/i);
assert.match(html, /aria-labelledby=/);
assert.match(html, /Text alternative/);
assert.match(html, /ArrowRight/);
assert.match(html, /data-status="failed"/);
assert.match(html, /API &lt;primary&gt;/);
assert.match(html, /catalog/);
});
test("doctor reports optional backend availability without launching commands", () => {
let launches = 0;
const report = doctorReport({ path: "/definitely/missing", onLaunch: () => launches++ });
assert.equal(launches, 0);
assert.equal(report.networkRequired, false);
assert.equal(report.backends.drawio.status, "missing");
assert.equal(report.backends.graphviz.mandatory, false);
assert.equal(report.capabilities.semanticLifecycle, true);
});
test("story fails closed on ambiguous multi-page node IDs", () => {
const ambiguous: DiagramIRV2 = {
version: 2,
pages: [
{ id: "one", title: "One", nodes: [{ id: "shared", label: "First" }], edges: [] },
{ id: "two", title: "Two", nodes: [{ id: "shared", label: "Second" }], edges: [] },
],
};
assert.throws(() => createStoryHtml(ambiguous), /ambiguous.*shared.*one.*two/i);
});
@@ -0,0 +1,49 @@
import { accessSync, constants, existsSync } from "node:fs";
import { delimiter, join } from "node:path";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
function escapeHtml(value: string): string { return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;"); }
function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; if (value && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`; } return JSON.stringify(value); }
export interface StoryOptions { title?: string; scenario?: { failed: string; impacted: string[] } }
export function createStoryHtml(ir: DiagramIRV2, options: StoryOptions = {}): string {
validateDiagramIR(ir);
const title = options.title ?? ir.title ?? "Architecture Story";
const pagesByNodeId = new Map<string, string[]>();
for (const page of ir.pages) for (const node of page.nodes) {
pagesByNodeId.set(node.id, [...(pagesByNodeId.get(node.id) ?? []), page.id]);
}
for (const [id, pageIds] of pagesByNodeId) {
if (pageIds.length > 1) throw new Error(`Ambiguous story node ID ${id} appears on pages ${pageIds.join(", ")}`);
}
const nodes = ir.pages.flatMap((page) => page.nodes);
const edges = ir.pages.flatMap((page) => page.edges);
const positions = new Map(nodes.map((node, index) => [node.id, { x: node.geometry?.x ?? 60 + (index % 4) * 220, y: node.geometry?.y ?? 70 + Math.floor(index / 4) * 130, width: node.geometry?.width ?? 160, height: node.geometry?.height ?? 70 }]));
const failed = options.scenario?.failed; const impacted = new Set(options.scenario?.impacted ?? []);
const edgeSvg = edges.map((edge) => { const a = positions.get(edge.source); const b = positions.get(edge.target); if (!a || !b) return ""; return `<line x1="${a.x + a.width}" y1="${a.y + a.height / 2}" x2="${b.x}" y2="${b.y + b.height / 2}" marker-end="url(#arrow)"/><text x="${(a.x + a.width + b.x) / 2}" y="${(a.y + b.y) / 2}">${escapeHtml(edge.label ?? "")}</text>`; }).join("");
const steps = nodes.map((node) => ({ id: node.id, label: node.label, detail: `${node.kind ?? "component"}${node.properties?.owner ? ` · owner: ${String(node.properties.owner)}` : ""}${node.provenance ? ` · provenance: ${stableJson(node.provenance)}` : ""}` }));
const nodeSvg = nodes.map((node) => { const p = positions.get(node.id)!; const status = node.id === failed ? "failed" : impacted.has(node.id) ? "impacted" : "normal"; return `<g class="node ${status}" data-id="${escapeHtml(node.id)}" data-status="${status}" tabindex="0" role="button" aria-label="${escapeHtml(`${node.label}, ${node.kind ?? "component"}`)}"><rect x="${p.x}" y="${p.y}" width="${p.width}" height="${p.height}"/><text x="${p.x + p.width / 2}" y="${p.y + p.height / 2}" text-anchor="middle">${escapeHtml(node.label)}</text></g>`; }).join("");
const alternatives = steps.map((step) => `<li><strong>${escapeHtml(step.label)}</strong> — ${escapeHtml(step.detail)}</li>`).join("") + edges.map((edge) => `<li>${escapeHtml(edge.source)} → ${escapeHtml(edge.target)}${edge.label ? ` — ${escapeHtml(edge.label)}` : ""}</li>`).join("");
const data = stableJson(steps).replaceAll("</", "<\\/");
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,92 @@
import assert from "node:assert/strict";
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"));
});
@@ -0,0 +1,113 @@
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[] }
const equal = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(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) { 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) { 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 (!basePage || !manualPage) { added.push(page.id); return structuredClone(page); }
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,385 @@
import assert from "node:assert/strict";
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);
});
@@ -0,0 +1,745 @@
import { constants } from "node:fs";
import { open, readdir, realpath } 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 (/\w+!\s*\(/.test(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 } };
}
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 }> {
let handle: FileHandle;
try {
handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW);
} 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 realpath(`/proc/self/fd/${handle.fd}`);
if (!isConfined(root, canonical)) throw new Error("Opened source escapes outside declared root");
return { handle, canonical };
} catch (error) {
await handle.close();
throw error;
}
}
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: FileHandle): Promise<void> => {
for (const name of (await readdir(`/proc/self/fd/${directory.fd}`)).sort(codePointCompare)) {
const opened = await openConfined(resolve(`/proc/self/fd/${directory.fd}`, name), rootPath);
try {
const entryInfo = await opened.handle.stat();
if (entryInfo.isDirectory()) await walk(opened.handle);
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.handle); }
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,134 @@
import assert from "node:assert/strict";
import test from "node:test";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { BUILT_IN_THEMES, applyTheme, contrastRatio, validateTheme } from "./theme-service.js";
const IR: DiagramIRV2 = {
version: 2,
pages: [{
id: "p", title: "Page",
nodes: [
{ id: "service", label: "Service", kind: "service", style: "shape=hexagon;fillColor=#123456;rounded=1;" },
{ id: "transparent", label: "Transparent", kind: "container", style: "swimlane;fillColor=none;horizontal=0;" },
],
edges: [{ id: "edge", source: "service", target: "transparent", style: "edgeStyle=orthogonalEdgeStyle;dashed=1;" }],
}],
};
test("built-in themes validate and meet role contrast requirements", () => {
assert.deepEqual(Object.keys(BUILT_IN_THEMES), ["default", "corporate", "dark", "colorblind-safe", "handdrawn"]);
for (const theme of Object.values(BUILT_IN_THEMES)) {
assert.doesNotThrow(() => validateTheme(theme));
assert.ok(contrastRatio(theme.node.fontColor, theme.node.fillColor) >= 4.5, theme.name);
}
});
test("built-in theme registry and nested role styles reject mutation", () => {
const dark = BUILT_IN_THEMES.dark;
assert.ok(Object.isFrozen(dark));
assert.ok(Object.isFrozen(dark.node));
assert.ok(Object.isFrozen(dark.container));
assert.ok(Object.isFrozen(dark.edge));
assert.throws(() => { (dark.node as { fillColor: string }).fillColor = "#FFFFFF"; }, TypeError);
assert.equal(BUILT_IN_THEMES.dark.node.fillColor, "#243447");
});
test("theme application is deterministic, immutable, role based, and preserves structural style", () => {
const snapshot = structuredClone(IR);
const first = applyTheme(IR, "corporate");
assert.deepEqual(first, applyTheme(IR, "corporate"));
assert.deepEqual(IR, snapshot);
assert.match(first.pages[0].nodes[0].style!, /shape=hexagon/);
assert.match(first.pages[0].nodes[0].style!, /rounded=1/);
assert.match(first.pages[0].nodes[1].style!, /fillColor=none/);
assert.match(first.pages[0].nodes[1].style!, /swimlane/);
assert.match(first.pages[0].edges[0].style!, /edgeStyle=orthogonalEdgeStyle/);
assert.doesNotMatch(first.pages[0].nodes[0].style!, /rounded=0/);
});
test("theme result is deeply isolated from source objects", () => {
const source = structuredClone(IR);
source.assets = { icons: [{ name: "api" }] };
source.pages[0].nodes[0].properties = { config: { retries: 3 } };
source.pages[0].edges[0].extensions = { route: { owner: "network" } };
const snapshot = structuredClone(source);
const themed = applyTheme(source, "corporate");
((themed.assets!.icons as Array<{ name: string }>)[0]).name = "changed";
((themed.pages[0].nodes[0].properties!.config as { retries: number })).retries = 9;
((themed.pages[0].edges[0].extensions!.route as { owner: string })).owner = "changed";
assert.deepEqual(source, snapshot);
});
test("theme application emits each visual style key exactly once", () => {
const source = structuredClone(IR);
source.pages[0].nodes[0].style = "shape=hexagon;fillColor=#000000;fillColor=#FFFFFF;strokeColor=#000000;strokeColor=#FFFFFF;fontColor=#000000;fontColor=#FFFFFF;sketch=0;sketch=0;";
const style = applyTheme(source, "handdrawn").pages[0].nodes[0].style!;
for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) {
assert.equal(style.split(";").filter((token) => token.startsWith(`${key}=`)).length, 1, style);
}
assert.match(style, /fillColor=#FFFDF5;/);
assert.match(style, /strokeColor=#444444;/);
assert.match(style, /fontColor=#222222;/);
assert.match(style, /sketch=1;/);
});
test("theme application preserves page extensions and serializes its canvas background", () => {
const source = structuredClone(IR);
source.pages[0].extensions = { owner: "platform", $drawio: { attributes: { custom: "kept", "model:pageScale": "1.25" } } };
const themed = applyTheme(source, "dark");
const xml = diagramIRToDrawio(themed);
assert.deepEqual(themed.pages[0].extensions, {
owner: "platform",
$drawio: { attributes: { custom: "kept", "model:pageScale": "1.25", "model:background": "#111827" } },
});
assert.match(xml, /<diagram[^>]* data-extensions="\{&quot;owner&quot;:&quot;platform&quot;\}"[^>]* custom="kept"/);
assert.match(xml, /<mxGraphModel[^>]* background="#111827"/);
assert.match(xml, /<mxGraphModel[^>]* pageScale="1.25"/);
assert.doesNotMatch(xml, /data-extensions="[^"]*\$drawio/);
});
test("transparent nodes use a readable dark font on a light canvas", () => {
const custom = structuredClone(BUILT_IN_THEMES.dark);
custom.name = "custom-light-canvas";
custom.backgroundColor = "#FFFFFF";
custom.edge.fontColor = "#000000";
const style = applyTheme(IR, custom).pages[0].nodes[1].style!;
assert.match(style, /fillColor=none;/);
assert.match(style, /fontColor=#000000;/);
assert.ok(contrastRatio("#000000", custom.backgroundColor) >= 4.5);
});
test("transparent nodes use a readable light font on a dark canvas", () => {
const custom = structuredClone(BUILT_IN_THEMES.default);
custom.name = "custom-dark-canvas";
custom.backgroundColor = "#000000";
custom.edge.fontColor = "#FFFFFF";
const source = structuredClone(IR);
source.pages[0].nodes[0].style = "shape=hexagon;fillColor=none;rounded=1;";
const style = applyTheme(source, custom).pages[0].nodes[0].style!;
assert.match(style, /fillColor=none;/);
assert.match(style, /fontColor=#FFFFFF;/);
assert.ok(contrastRatio("#FFFFFF", custom.backgroundColor) >= 4.5);
});
test("theme validation rejects invalid colors and insufficient contrast", () => {
const invalidColor = structuredClone(BUILT_IN_THEMES.default);
invalidColor.node.fillColor = "red";
assert.throws(() => validateTheme(invalidColor), /color/i);
const lowContrast = structuredClone(BUILT_IN_THEMES.default);
lowContrast.node.fontColor = "#777777";
lowContrast.node.fillColor = "#777777";
assert.throws(() => validateTheme(lowContrast), /contrast/i);
});
@@ -0,0 +1,148 @@
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): 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);
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", "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", "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) });
return {
...isolated,
theme: selectedTheme.name,
pages: isolated.pages.map((page) => {
const extensions = page.extensions ?? {};
const drawio = extensions.$drawio && typeof extensions.$drawio === "object" && !Array.isArray(extensions.$drawio)
? extensions.$drawio as Record<string, unknown>
: {};
const attributes = drawio.attributes && typeof drawio.attributes === "object" && !Array.isArray(drawio.attributes)
? drawio.attributes as Record<string, unknown>
: {};
return {
...page,
extensions: {
...extensions,
$drawio: { ...drawio, attributes: { ...attributes, "model:background": selectedTheme.backgroundColor } },
},
nodes: page.nodes.map(applyNode),
edges: page.edges.map(applyEdge),
};
}),
};
}
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { applyMetricsHeatmap } from "./heatmap.js";
import { contrastRatio } from "../themes/theme-service.js";
const IR: DiagramIRV2 = {
version: 2,
pages: [{ id: "p", title: "Page", nodes: [
{ id: "low", label: "Low", style: "shape=hexagon;strokeColor=#000000;", geometry: { x: 0, y: 0, width: 100, height: 60 } },
{ id: "high", label: "High", style: "shape=ellipse;", geometry: { x: 200, y: 0, width: 100, height: 60 } },
], edges: [{ id: "flow", source: "low", target: "high", waypoints: [{ x: 150, y: 30 }] }] }],
};
test("metrics heatmap is deterministic with legend metadata and preserves topology", () => {
const request = { metric: "latency_ms", min: 0, max: 100, values: { "p/low": 0, "p/high": 100 } };
const snapshot = structuredClone(IR);
const first = applyMetricsHeatmap(IR, request);
assert.deepEqual(first, applyMetricsHeatmap(IR, request));
assert.deepEqual(IR, snapshot);
assert.deepEqual(first.legend, {
metric: "latency_ms", min: 0, max: 100,
stops: [
{ value: 0, color: "#440154", fontColor: "#FFFFFF" },
{ value: 25, color: "#3B528B", fontColor: "#FFFFFF" },
{ value: 50, color: "#21918C", fontColor: "#1A1A1A" },
{ value: 75, color: "#5EC962", fontColor: "#1A1A1A" },
{ value: 100, color: "#FDE725", fontColor: "#1A1A1A" },
],
});
assert.match(first.diagram.pages[0].nodes[0].style!, /shape=hexagon/);
assert.match(first.diagram.pages[0].nodes[0].style!, /fillColor=#440154/);
assert.match(first.diagram.pages[0].nodes[1].style!, /fillColor=#FDE725/);
assert.deepEqual(first.diagram.pages[0].edges, IR.pages[0].edges);
assert.deepEqual(first.diagram.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry })), IR.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry })));
});
test("metrics heatmap sets readable text for every palette stop", () => {
const result = applyMetricsHeatmap(IR, {
metric: "latency_ms", min: 0, max: 100,
values: { "p/low": 0, "p/high": 100 },
});
for (const stop of result.legend.stops) {
assert.ok(contrastRatio(stop.fontColor, stop.color) >= 4.5, `${stop.fontColor} on ${stop.color}`);
}
assert.match(result.diagram.pages[0].nodes[0].style!, /fontColor=#FFFFFF/);
assert.match(result.diagram.pages[0].nodes[1].style!, /fontColor=#1A1A1A/);
});
test("metrics heatmap switches colors at the legend stop boundaries", () => {
const colorsAt = (low: number, high: number): string[] => applyMetricsHeatmap(IR, {
metric: "latency_ms", min: 0, max: 100, values: { "p/low": low, "p/high": high },
}).diagram.pages[0].nodes.map((node) => /fillColor=(#[0-9A-F]{6})/i.exec(node.style!)?.[1] ?? "");
assert.deepEqual(colorsAt(24.999, 25), ["#440154", "#3B528B"]);
assert.deepEqual(colorsAt(49.999, 50), ["#3B528B", "#21918C"]);
assert.deepEqual(colorsAt(74.999, 75), ["#21918C", "#5EC962"]);
assert.deepEqual(colorsAt(99.999, 100), ["#5EC962", "#FDE725"]);
});
test("metrics heatmap rejects palettes without a readable text color", () => {
assert.throws(() => applyMetricsHeatmap(IR, {
metric: "x", min: 0, max: 1, values: {}, colors: ["#777777", "#777777"],
}), /no readable font color/i);
});
test("metrics heatmap rejects invalid bounds, colors, keys, and out-of-range values", () => {
assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 1, max: 1, values: {} }), /min.*max/i);
assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { "p/low": 2 } }), /bounded/i);
assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { missing: 0 } }), /unknown/i);
assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: {}, colors: ["red"] }), /color/i);
});
@@ -0,0 +1,85 @@
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
export interface HeatmapRequest {
metric: string;
min: number;
max: number;
values: Readonly<Record<string, number>>;
colors?: readonly string[];
}
export interface HeatmapLegendStop { value: number; color: string; fontColor: string }
export interface HeatmapLegend { metric: string; min: number; max: number; stops: HeatmapLegendStop[] }
export interface HeatmapResult { diagram: DiagramIRV2; legend: HeatmapLegend }
export const DEFAULT_HEATMAP_COLORS = Object.freeze(["#440154", "#3B528B", "#21918C", "#5EC962", "#FDE725"]);
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
function setHeatmapColors(style: string | undefined, color: string, fontColor: string): string {
const entries = (style ?? "").split(";").filter(Boolean);
const replacements = new Map([["fillColor", color], ["fontColor", fontColor]]);
const targetKeys = new Set(replacements.keys());
const output = entries.map((entry) => {
const key = entry.split("=", 1)[0];
if (!targetKeys.has(key)) return entry;
const replacement = replacements.get(key);
if (replacement === undefined) return undefined;
replacements.delete(key);
return `${key}=${replacement}`;
}).filter((entry): entry is string => entry !== undefined);
for (const [key, value] of replacements) output.push(`${key}=${value}`);
return `${output.join(";")};`;
}
function contrastRatio(left: string, right: string): number {
const luminance = (color: string): number => [1, 3, 5]
.map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255)
.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)
.reduce((total, value, index) => total + value * [0.2126, 0.7152, 0.0722][index], 0);
const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a);
return (high + 0.05) / (low + 0.05);
}
const READABLE_FONT_COLORS = ["#FFFFFF", "#1A1A1A"] as const;
function readableFontColor(background: string): string {
const color = READABLE_FONT_COLORS.find((candidate) => contrastRatio(candidate, background) >= 4.5);
if (!color) throw new Error(`Heatmap palette color ${background} has no readable font color`);
return color;
}
function exactNumber(value: number): number {
return Number(value.toFixed(12));
}
export function applyMetricsHeatmap(ir: DiagramIRV2, request: HeatmapRequest): HeatmapResult {
if (typeof request.metric !== "string" || request.metric.trim() === "") throw new Error("Heatmap metric must be a non-empty string");
if (!Number.isFinite(request.min) || !Number.isFinite(request.max) || request.min >= request.max) throw new Error("Heatmap min must be less than max");
if (!request.values || typeof request.values !== "object" || Array.isArray(request.values)) throw new Error("Heatmap values must be an object");
const colors = [...(request.colors ?? DEFAULT_HEATMAP_COLORS)];
if (colors.length < 2 || colors.some((color) => typeof color !== "string" || !HEX_COLOR.test(color))) throw new Error("Heatmap colors must contain at least two #RRGGBB colors");
const nodeKeys = new Set(ir.pages.flatMap((page) => page.nodes.map((node) => `${page.id}/${node.id}`)));
for (const [key, value] of Object.entries(request.values)) {
if (!nodeKeys.has(key)) throw new Error(`Heatmap value has unknown node: ${key}`);
if (typeof value !== "number" || !Number.isFinite(value) || value < request.min || value > request.max) {
throw new Error(`Heatmap value for ${key} must be numeric and bounded by min and max`);
}
}
const stops = colors.map((color, index) => ({
value: exactNumber(request.min + (request.max - request.min) * index / (colors.length - 1)),
color,
fontColor: readableFontColor(color),
}));
const legend: HeatmapLegend = { metric: request.metric, min: request.min, max: request.max, stops };
const diagram = structuredClone(ir);
for (const page of diagram.pages) for (const node of page.nodes) {
const value = request.values[`${page.id}/${node.id}`];
if (value === undefined) continue;
const nextStopIndex = stops.findIndex((stop) => value < stop.value);
const colorIndex = nextStopIndex === -1 ? stops.length - 1 : Math.max(0, nextStopIndex - 1);
node.style = setHeatmapColors(node.style, colors[colorIndex], stops[colorIndex].fontColor);
}
diagram.extensions = { ...(diagram.extensions ?? {}), heatmap: legend };
return { diagram, legend };
}
export const transformMetricsHeatmap = applyMetricsHeatmap;
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { relabelDiagram } from "./relabel.js";
const IR: DiagramIRV2 = {
version: 2,
title: "Original",
pages: [{ id: "p", title: "Page", layout: { type: "manual" }, nodes: [
{ id: "a", label: "A", style: "shape=hexagon;", geometry: { x: 10, y: 20, width: 100, height: 60 } },
{ id: "b", label: "B", style: "shape=ellipse;", geometry: { x: 200, y: 20, width: 100, height: 60 } },
], edges: [{ id: "flow", source: "a", target: "b", label: "old", style: "dashed=1;", waypoints: [{ x: 150, y: 50 }] }] }],
};
test("complete relabel changes only labels while preserving IDs, topology, layout, and style", () => {
const snapshot = structuredClone(IR);
const result = relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new" } });
assert.deepEqual(result.pages[0].nodes.map(({ id, label }) => ({ id, label })), [{ id: "a", label: "Alpha" }, { id: "b", label: "Beta" }]);
assert.equal(result.pages[0].edges[0].label, "new");
const restored = structuredClone(result);
restored.pages[0].nodes[0].label = "A";
restored.pages[0].nodes[1].label = "B";
restored.pages[0].edges[0].label = "old";
assert.deepEqual(restored, IR);
assert.deepEqual(IR, snapshot);
});
test("relabel requires a complete exact explicit map", () => {
assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha" } }), /missing.*p\/b.*p\/flow/i);
assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new", extra: "no" } }), /unknown.*extra/i);
assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "", "p/b": "Beta", "p/flow": "new" } }), /non-empty/i);
});
@@ -0,0 +1,35 @@
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
export interface CompleteRelabelRequest {
mode: "complete";
labels: Readonly<Record<string, string>>;
}
export function relabelDiagram(ir: DiagramIRV2, request: CompleteRelabelRequest): DiagramIRV2 {
if (!request || request.mode !== "complete" || !request.labels || typeof request.labels !== "object" || Array.isArray(request.labels)) {
throw new Error("Relabel requires an explicit complete map mode");
}
const required: string[] = [];
for (const page of ir.pages) {
for (const node of page.nodes) required.push(`${page.id}/${node.id}`);
for (const edge of page.edges) if (edge.label !== undefined) required.push(`${page.id}/${edge.id}`);
}
required.sort((left, right) => left.localeCompare(right, "en-US"));
const provided = Object.keys(request.labels).sort((left, right) => left.localeCompare(right, "en-US"));
const missing = required.filter((key) => !Object.hasOwn(request.labels, key));
if (missing.length > 0) throw new Error(`Relabel map is missing: ${missing.join(", ")}`);
const unknown = provided.filter((key) => !required.includes(key));
if (unknown.length > 0) throw new Error(`Relabel map has unknown keys: ${unknown.join(", ")}`);
for (const key of required) {
const label = request.labels[key];
if (typeof label !== "string" || label.trim() === "") throw new Error(`Relabel value for ${key} must be a non-empty string`);
}
const output = structuredClone(ir);
for (const page of output.pages) {
for (const node of page.nodes) node.label = request.labels[`${page.id}/${node.id}`];
for (const edge of page.edges) if (edge.label !== undefined) edge.label = request.labels[`${page.id}/${edge.id}`];
}
return output;
}
export const relabelDiagramIR = relabelDiagram;
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { diagramIRToMermaid, diagramIRToStructuredMarkdown } from "./reverse.js";
const IR: DiagramIRV2 = {
version: 2,
title: "Platform",
pages: [{
id: "system", title: "System",
nodes: [
{ id: "a", label: "Client" },
{ id: "b", label: "API \"danger\"]\n%%{init: {}}%% <script>alert(1)</script> [click](javascript:alert(1))" },
],
edges: [{ id: "call", source: "a", target: "b", label: "calls | inject" }],
}],
};
test("reverse Mermaid flowchart is deterministic and escapes untrusted labels", () => {
const output = diagramIRToMermaid(IR);
assert.equal(output, diagramIRToMermaid(IR));
assert.match(output, /^flowchart LR/m);
assert.match(output, /n0\["Client"\]/);
assert.match(output, /n0 -->\|"calls &#124; inject"\| n1/);
assert.doesNotMatch(output, /<script>|%%\{init|danger"\]/);
assert.match(output, /&#37;&#37;/);
});
test("reverse Markdown explains pages, nodes, and flows without executable HTML or broken tables", () => {
const output = diagramIRToStructuredMarkdown(IR);
assert.equal(output, diagramIRToStructuredMarkdown(IR));
assert.match(output, /## System/);
assert.match(output, /### Nodes/);
assert.match(output, /### Flows/);
assert.match(output, /calls \\| inject/);
assert.doesNotMatch(output, /<script>/);
assert.doesNotMatch(output, /\]\(javascript:/);
assert.match(output, /&lt;script&gt;/);
});
@@ -0,0 +1,62 @@
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
function mermaidEscape(value: string): string {
const entities: Record<string, string> = {
"&": "&#38;", "<": "&#60;", ">": "&#62;", "\"": "&#34;", "'": "&#39;",
"[": "&#91;", "]": "&#93;", "{": "&#123;", "}": "&#125;", "(": "&#40;", ")": "&#41;",
"|": "&#124;", "`": "&#96;", "%": "&#37;", "\n": "&#10;", "\r": "",
};
return [...value].map((character) => entities[character] ?? character).join("");
}
function markdownEscape(value: string): string {
return value
.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
.replaceAll("\\", "\\\\").replace(/([`*_{}\[\]()#+.!-])/g, "\\$1").replaceAll("|", "\\|").replace(/[\r\n]+/g, " ");
}
export function diagramIRToMermaid(ir: DiagramIRV2): string {
const direction = ir.pages[0]?.layout?.direction === "vertical" ? "TD" : "LR";
const lines = [`flowchart ${direction}`];
let nextNode = 0;
const nodeIds = new Map<string, string>();
for (const [pageIndex, page] of ir.pages.entries()) {
if (ir.pages.length > 1) lines.push(` subgraph page${pageIndex}["${mermaidEscape(page.title)}"]`);
const indent = ir.pages.length > 1 ? " " : " ";
for (const node of page.nodes) {
const safeId = `n${nextNode}`;
nextNode += 1;
nodeIds.set(`${pageIndex}:${node.id}`, safeId);
lines.push(`${indent}${safeId}["${mermaidEscape(node.label)}"]`);
}
if (ir.pages.length > 1) lines.push(" end");
for (const edge of page.edges) {
const source = nodeIds.get(`${pageIndex}:${edge.source}`);
const target = nodeIds.get(`${pageIndex}:${edge.target}`);
if (!source || !target) throw new Error(`Edge ${edge.id} references an unknown endpoint`);
const label = edge.label === undefined ? "" : `|"${mermaidEscape(edge.label)}"|`;
lines.push(` ${source} -->${label} ${target}`);
}
}
return `${lines.join("\n")}\n`;
}
export function diagramIRToStructuredMarkdown(ir: DiagramIRV2): string {
const lines = [`# ${markdownEscape(ir.title ?? "Diagram")}`, ""];
for (const page of ir.pages) {
lines.push(`## ${markdownEscape(page.title)}`, "", "### Nodes", "", "| ID | Label | Kind | Parent |", "| --- | --- | --- | --- |");
for (const node of page.nodes) {
lines.push(`| ${markdownEscape(node.id)} | ${markdownEscape(node.label)} | ${markdownEscape(node.kind ?? "node")} | ${markdownEscape(node.parentId ?? "—")} |`);
}
lines.push("", "### Flows", "", "| ID | From | To | Label |", "| --- | --- | --- | --- |");
if (page.edges.length === 0) lines.push("| — | — | — | No flows |" );
for (const edge of page.edges) {
lines.push(`| ${markdownEscape(edge.id)} | ${markdownEscape(edge.source)} | ${markdownEscape(edge.target)} | ${markdownEscape(edge.label ?? "—")} |`);
}
lines.push("");
}
return lines.join("\n");
}
export const toMermaidFlowchart = diagramIRToMermaid;
export const toMarkdownExplanation = diagramIRToStructuredMarkdown;
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
import { semanticDiff } from "./semantic-diff.js";
const before: DiagramIRV2 = {
version: 2,
pages: [{ id: "p", title: "Page", nodes: [
{ id: "removed", label: "Old" },
{ id: "moved", label: "Move", geometry: { x: 0, y: 0, width: 100, height: 60 } },
{ id: "changed", label: "Before", style: "shape=rectangle;" },
{ id: "target", label: "Target" },
], edges: [
{ id: "route", source: "moved", target: "target", waypoints: [{ x: 20, y: 10 }] },
{ id: "gone", source: "removed", target: "target" },
] }],
};
const after: DiagramIRV2 = {
version: 2,
pages: [{ id: "p", title: "Page", nodes: [
{ id: "moved", label: "Move", geometry: { x: 40, y: 0, width: 100, height: 60 } },
{ id: "changed", label: "After", style: "shape=ellipse;" },
{ id: "target", label: "Target" },
{ id: "added", label: "New" },
], edges: [
{ id: "route", source: "moved", target: "target", waypoints: [{ x: 30, y: 10 }] },
{ id: "new-edge", source: "added", target: "target" },
] }],
};
test("semantic diff classifies added, removed, changed, moved, and rerouted elements", () => {
const diff = semanticDiff(before, after);
assert.deepEqual(diff.added.map((item) => item.id), ["p/added", "p/new-edge"]);
assert.deepEqual(diff.removed.map((item) => item.id), ["p/gone", "p/removed"]);
assert.deepEqual(diff.changed.map((item) => item.id), ["p/changed"]);
assert.deepEqual(diff.moved.map((item) => item.id), ["p/moved"]);
assert.deepEqual(diff.rerouted.map((item) => item.id), ["p/route"]);
});
test("semantic diff values are deeply isolated from both inputs", () => {
const oldDiagram = structuredClone(before);
const newDiagram = structuredClone(after);
newDiagram.pages[0].nodes.find(({ id }) => id === "added")!.extensions = { details: { owner: "platform" } };
const oldSnapshot = structuredClone(oldDiagram);
const newSnapshot = structuredClone(newDiagram);
const diff = semanticDiff(oldDiagram, newDiagram);
(diff.moved[0].before as DiagramNode).geometry!.x = 999;
((diff.added.find(({ id }) => id === "p/added")!.after!.extensions!.details as { owner: string })).owner = "changed";
diff.removed.find(({ id }) => id === "p/removed")!.before!.label = "changed";
assert.deepEqual(oldDiagram, oldSnapshot);
assert.deepEqual(newDiagram, newSnapshot);
});
test("semantic diff is deterministic and does not mutate either input", () => {
const beforeSnapshot = structuredClone(before);
const afterSnapshot = structuredClone(after);
assert.deepEqual(semanticDiff(before, after), semanticDiff(before, after));
assert.deepEqual(before, beforeSnapshot);
assert.deepEqual(after, afterSnapshot);
});
@@ -0,0 +1,100 @@
import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
export type SemanticEntity = "node" | "edge";
export interface SemanticDiffItem {
id: string;
entity: SemanticEntity;
before?: DiagramNode | DiagramEdge;
after?: DiagramNode | DiagramEdge;
}
export interface SemanticDiffResult {
added: SemanticDiffItem[];
removed: SemanticDiffItem[];
changed: SemanticDiffItem[];
moved: SemanticDiffItem[];
rerouted: SemanticDiffItem[];
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") {
const object = value as Record<string, unknown>;
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function equal(left: unknown, right: unknown): boolean {
return canonical(left) === canonical(right);
}
function without<T extends object>(value: T, omitted: readonly string[]): Record<string, unknown> {
return Object.fromEntries(Object.entries(value).filter(([key]) => !omitted.includes(key)));
}
function position(node: DiagramNode): unknown {
return { parentId: node.parentId, x: node.geometry?.x, y: node.geometry?.y };
}
function nodeMeaning(node: DiagramNode): unknown {
const semantic = without(node, ["geometry", "parentId"]);
if (node.geometry) semantic.geometrySize = { width: node.geometry.width, height: node.geometry.height };
return semantic;
}
function edgeRoute(edge: DiagramEdge): unknown {
return { source: edge.source, target: edge.target, waypoints: edge.waypoints ?? [] };
}
function edgeMeaning(edge: DiagramEdge): unknown {
return without(edge, ["source", "target", "waypoints"]);
}
function index(ir: DiagramIRV2): Map<string, { entity: SemanticEntity; value: DiagramNode | DiagramEdge }> {
const result = new Map<string, { entity: SemanticEntity; value: DiagramNode | DiagramEdge }>();
for (const page of ir.pages) {
for (const node of page.nodes) result.set(`${page.id}/${node.id}`, { entity: "node", value: node });
for (const edge of page.edges) result.set(`${page.id}/${edge.id}`, { entity: "edge", value: edge });
}
return result;
}
function ordered(items: SemanticDiffItem[]): SemanticDiffItem[] {
return items.sort((left, right) => left.id.localeCompare(right.id, "en-US") || left.entity.localeCompare(right.entity, "en-US"));
}
function clone<T>(value: T): T {
return structuredClone(value);
}
export function semanticDiff(before: DiagramIRV2, after: DiagramIRV2): SemanticDiffResult {
const oldItems = index(before);
const newItems = index(after);
const result: SemanticDiffResult = { added: [], removed: [], changed: [], moved: [], rerouted: [] };
for (const [id, current] of newItems) {
const previous = oldItems.get(id);
if (!previous || previous.entity !== current.entity) {
result.added.push({ id, entity: current.entity, after: clone(current.value) });
continue;
}
if (current.entity === "node") {
const oldNode = previous.value as DiagramNode;
const newNode = current.value as DiagramNode;
if (!equal(position(oldNode), position(newNode))) result.moved.push({ id, entity: "node", before: clone(oldNode), after: clone(newNode) });
if (!equal(nodeMeaning(oldNode), nodeMeaning(newNode))) result.changed.push({ id, entity: "node", before: clone(oldNode), after: clone(newNode) });
} else {
const oldEdge = previous.value as DiagramEdge;
const newEdge = current.value as DiagramEdge;
if (!equal(edgeRoute(oldEdge), edgeRoute(newEdge))) result.rerouted.push({ id, entity: "edge", before: clone(oldEdge), after: clone(newEdge) });
if (!equal(edgeMeaning(oldEdge), edgeMeaning(newEdge))) result.changed.push({ id, entity: "edge", before: clone(oldEdge), after: clone(newEdge) });
}
}
for (const [id, previous] of oldItems) {
const current = newItems.get(id);
if (!current || current.entity !== previous.entity) result.removed.push({ id, entity: previous.entity, before: clone(previous.value) });
}
for (const values of Object.values(result)) ordered(values);
return result;
}
export const diffDiagramIR = semanticDiff;