feat: add integrated diagram lifecycle and capability toolbox #2
@@ -1,6 +1,6 @@
|
|||||||
# drawio-main
|
# 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.
|
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:
|
# or directly:
|
||||||
node dist/cli/commands.js --file diagram.drawio --action <action> [--page <index>]
|
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
|
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.
|
Output is always YAML to stdout.
|
||||||
@@ -62,6 +66,24 @@ 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Inventory
|
### Inventory
|
||||||
|
|
||||||
#### `summary`
|
#### `summary`
|
||||||
@@ -166,3 +188,4 @@ Standard sizes: A4 (1169×827), A3 (1654×1169), A2 (2339×1654), A1 (3307×2339
|
|||||||
|
|
||||||
- Bare `<mxGraphModel>` XML files
|
- Bare `<mxGraphModel>` XML files
|
||||||
- `<mxfile><diagram>…</diagram></mxfile>` wrappers (draw.io desktop format, base64+deflate encoded, multi-page supported)
|
- `<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
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ compatibility: Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, an
|
|||||||
|
|
||||||
# Draw.io Diagram Skill
|
# Draw.io Diagram Skill
|
||||||
|
|
||||||
This skill covers three capabilities:
|
This skill covers four 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
|
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
|
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
|
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
|
||||||
|
|
||||||
**Reference files** (read these when using this skill):
|
**Reference files** (read these when using this skill):
|
||||||
- [references/capabilities.md](./references/capabilities.md) — full list of capabilities and all CLI analysis actions an agent can execute
|
- [references/capabilities.md](./references/capabilities.md) — full list of capabilities and all CLI analysis actions an agent can execute
|
||||||
@@ -27,6 +28,7 @@ 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/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/routing-best-practices.md](./references/routing-best-practices.md) — corridor planning, routing patterns, overlap verification, swimlane routing, validation workflow
|
||||||
- [references/maintenance.md](./references/maintenance.md) — maintaining and rebuilding the skill itself
|
- [references/maintenance.md](./references/maintenance.md) — maintaining and rebuilding the skill itself
|
||||||
|
- [references/semantic-lifecycle.md](./references/semantic-lifecycle.md) — import, edit, views, query, policy, what-if, sync, story, and doctor workflows
|
||||||
|
|
||||||
## Available scripts
|
## Available scripts
|
||||||
|
|
||||||
@@ -48,6 +50,16 @@ task validate -- --file="/path/to/diagram.drawio"
|
|||||||
# Build a native .drawio file from a YAML Diagram IR
|
# Build a native .drawio file from a YAML Diagram IR
|
||||||
task generate -- --file="/path/to/spec.yaml" --output="/path/to/diagram.drawio"
|
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/
|
# Build TypeScript to dist/
|
||||||
task build
|
task build
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
A **TypeScript / Node.js** CLI tool for programmatic analysis of `.drawio` files.
|
||||||
Entry point: `node dist/cli/commands.js` (run from the skill's `scripts/` directory), or `task run -- --file=… --action=…`.
|
Entry point: `node dist/cli/commands.js` (run from the skill's `scripts/` directory), or `task run -- --file=… --action=…`.
|
||||||
@@ -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` |
|
| `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
|
#### Inventory
|
||||||
|
|
||||||
| Action | Scope | Description |
|
| Action | Scope | Description |
|
||||||
|
|||||||
@@ -89,13 +89,20 @@ task build # tsc → dist/
|
|||||||
src/
|
src/
|
||||||
├── cli/
|
├── cli/
|
||||||
│ └── commands.ts # parseArgs dispatcher → dynamic action imports
|
│ └── 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/
|
├── services/
|
||||||
│ ├── drawio-parser/
|
│ ├── drawio-parser/
|
||||||
│ │ ├── parser.ts # parseAllPages() / parseDiagram() — Shape, Edge, ParsedPage
|
│ │ ├── parser.ts # parseAllPages() / parseDiagram() — Shape, Edge, ParsedPage
|
||||||
│ │ └── page-summary.ts # buildPageSummary() — shared per-page serialisation helper
|
│ │ └── page-summary.ts # buildPageSummary() — shared per-page serialisation helper
|
||||||
│ └── hierarchy-builder/
|
│ ├── hierarchy-builder/
|
||||||
│ └── hierarchy-builder.ts # buildHierarchy() — shared BFS depth map + containment tree
|
│ │ └── hierarchy-builder.ts # buildHierarchy() — shared BFS depth map + containment tree
|
||||||
|
│ └── semantic-lifecycle/ # import, edit, views/query/policy/what-if, sync, story, atomic I/O
|
||||||
└── actions/
|
└── actions/
|
||||||
|
├── build|import|edit|views|query|test|what-if|sync|story|doctor/
|
||||||
|
│ # authoring and semantic lifecycle actions
|
||||||
├── summary/ # all pages inventory
|
├── summary/ # all pages inventory
|
||||||
├── page-summary/ # single page inventory (uses --page)
|
├── page-summary/ # single page inventory (uses --page)
|
||||||
├── page-hierarchy/ # containment tree from parentId
|
├── page-hierarchy/ # containment tree from parentId
|
||||||
@@ -109,7 +116,7 @@ src/
|
|||||||
└── validate/ # MANDATORY final gate — XML well-formedness + maxGraph compile + sanity check
|
└── 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
|
### Key modules
|
||||||
|
|
||||||
@@ -129,7 +136,9 @@ Each action exports `run(filePath: string, pageIndex?: number): Record<string, u
|
|||||||
|
|
||||||
**`src/cli/commands.ts`**
|
**`src/cli/commands.ts`**
|
||||||
- `parseArgs` dispatcher → dynamic imports of action modules
|
- `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
|
- All output serialised to YAML on stdout; exit `0` success, `1` error
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -162,6 +171,7 @@ Actions follow `{object}-{action}` naming:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const ACTIONS: Record<string, () => Promise<ActionModule>> = {
|
const ACTIONS: Record<string, () => Promise<ActionModule>> = {
|
||||||
|
"build": () => import("../actions/build/action.js"),
|
||||||
"summary": () => import("../actions/summary/action.js"),
|
"summary": () => import("../actions/summary/action.js"),
|
||||||
"page-summary": () => import("../actions/page-summary/action.js"),
|
"page-summary": () => import("../actions/page-summary/action.js"),
|
||||||
"page-hierarchy": () => import("../actions/page-hierarchy/action.js"),
|
"page-hierarchy": () => import("../actions/page-hierarchy/action.js"),
|
||||||
@@ -174,5 +184,15 @@ const ACTIONS: Record<string, () => Promise<ActionModule>> = {
|
|||||||
"page-hierarchy-full": () => import("../actions/page-hierarchy-full/action.js"),
|
"page-hierarchy-full": () => import("../actions/page-hierarchy-full/action.js"),
|
||||||
"page-negative-space-summary":() => import("../actions/page-negative-space-summary/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"),
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"$defs": {
|
"$defs": {
|
||||||
"safeId": {
|
"safeId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$",
|
"pattern": "^[^\\s\\\"'<>]+$",
|
||||||
"not": { "enum": ["0", "1"] }
|
"not": { "enum": ["0", "1"] }
|
||||||
},
|
},
|
||||||
"point": {
|
"point": {
|
||||||
|
|||||||
@@ -38,6 +38,46 @@ tasks:
|
|||||||
CLI_ARGS: "--action=build {{ .CLI_ARGS }}"
|
CLI_ARGS: "--action=build {{ .CLI_ARGS }}"
|
||||||
silent: true
|
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:
|
validate:
|
||||||
desc: |
|
desc: |
|
||||||
Validate a .drawio file (XML well-formedness + required structure).
|
Validate a .drawio file (XML well-formedness + required structure).
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@maxgraph/core": "^0.23.0",
|
"@maxgraph/core": "^0.23.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.3.1",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"pako": "^2.1.0"
|
"pako": "^2.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+5
-5
@@ -12,8 +12,8 @@ importers:
|
|||||||
specifier: ^0.23.0
|
specifier: ^0.23.0
|
||||||
version: 0.23.0
|
version: 0.23.0
|
||||||
js-yaml:
|
js-yaml:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.3.1
|
||||||
version: 4.3.0
|
version: 4.3.1
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^25.0.0
|
specifier: ^25.0.0
|
||||||
version: 25.0.1
|
version: 25.0.1
|
||||||
@@ -373,8 +373,8 @@ packages:
|
|||||||
is-potential-custom-element-name@1.0.1:
|
is-potential-custom-element-name@1.0.1:
|
||||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
js-yaml@4.3.0:
|
js-yaml@4.3.1:
|
||||||
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
|
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jsdom@25.0.1:
|
jsdom@25.0.1:
|
||||||
@@ -779,7 +779,7 @@ snapshots:
|
|||||||
|
|
||||||
is-potential-custom-element-name@1.0.1: {}
|
is-potential-custom-element-name@1.0.1: {}
|
||||||
|
|
||||||
js-yaml@4.3.0:
|
js-yaml@4.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { readFileSync, writeFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
|
||||||
import * as yaml from "js-yaml";
|
import * as yaml from "js-yaml";
|
||||||
|
|
||||||
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
|
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
|
||||||
import { normalizeDiagramIR, validateDiagramIR } from "../../model/diagram-ir.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> {
|
export function run(filePath: string, _pageIndex = 0, outputPath?: string): Record<string, unknown> {
|
||||||
if (!outputPath) throw new Error("build requires --output <path.drawio>");
|
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 ir = validateDiagramIR(parsed);
|
||||||
const normalized = normalizeDiagramIR(ir);
|
const normalized = normalizeDiagramIR(ir);
|
||||||
const xml = diagramIRToDrawio(normalized);
|
const xml = diagramIRToDrawio(normalized);
|
||||||
const resolvedOutput = resolve(outputPath);
|
const resolvedOutput = atomicWrite(outputPath, xml, [filePath]);
|
||||||
writeFileSync(resolvedOutput, xml, "utf8");
|
|
||||||
return {
|
return {
|
||||||
action: "build",
|
action: "build",
|
||||||
file: filePath,
|
file: filePath,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { doctorReport } from "../../services/semantic-lifecycle/publishing.js";
|
||||||
|
export function run(_filePath = "", _page = 0, _output?: string): Record<string, unknown> { return { action: "doctor", ...doctorReport() }; }
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { EditBatch } from "../../services/semantic-lifecycle/edit-batch.js";
|
||||||
|
import { applyEditBatch } from "../../services/semantic-lifecycle/edit-batch.js";
|
||||||
|
import { assertOutputSafe, atomicWrite, loadIR, loadStructured, structuredText, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
|
||||||
|
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
|
||||||
|
export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
|
||||||
|
if (!options.spec) throw new Error("edit requires --spec <batch.yaml>");
|
||||||
|
if (!options.dryRun && !outputPath) throw new Error("edit requires --output unless --dry-run is used");
|
||||||
|
if (outputPath) assertOutputSafe(outputPath, [filePath, options.spec]);
|
||||||
|
const source = loadIR(filePath); const batch = loadStructured(options.spec) as EditBatch;
|
||||||
|
const result = applyEditBatch(source, batch, { dryRun: options.dryRun });
|
||||||
|
let output: string | undefined;
|
||||||
|
if (!options.dryRun && outputPath) {
|
||||||
|
const content = /\.(drawio|xml)$/i.test(outputPath) ? diagramIRToDrawio(result.ir) : structuredText(result.ir, outputPath);
|
||||||
|
output = atomicWrite(outputPath, content, [filePath, options.spec]);
|
||||||
|
}
|
||||||
|
return { action: "edit", output, applied: result.applied, dryRun: result.dryRun, preview: options.dryRun ? result.preview : undefined };
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { importDrawioToIR } from "../../services/semantic-lifecycle/import-drawio.js";
|
||||||
|
import { atomicWrite, structuredText } from "../../services/semantic-lifecycle/lifecycle-io.js";
|
||||||
|
export function run(filePath: string, _page = 0, outputPath?: string): Record<string, unknown> {
|
||||||
|
if (!outputPath) throw new Error("import requires --output <model.yaml|model.json>");
|
||||||
|
if (!/\.(?:ya?ml|json)$/i.test(outputPath)) throw new Error("import output must use a .yaml, .yml, or .json extension");
|
||||||
|
const result = importDrawioToIR(filePath);
|
||||||
|
const output = atomicWrite(outputPath, structuredText(result.ir, outputPath), [filePath]);
|
||||||
|
return { action: "import", file: filePath, output, lossReport: result.lossReport };
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { queryDiagram } from "../../services/semantic-lifecycle/analysis.js";
|
||||||
|
import { loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
|
||||||
|
export function run(filePath: string, _page = 0, _output?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
|
||||||
|
const properties = Object.fromEntries((options.property ?? []).map((entry) => { const at = entry.indexOf("="); if (at < 1) throw new Error(`--property requires key=value: ${entry}`); const raw = entry.slice(at + 1); let value: unknown = raw; try { value = JSON.parse(raw); } catch {} return [entry.slice(0, at), value]; }));
|
||||||
|
return { action: "query", ...queryDiagram(loadIR(filePath), { kind: options.kind, properties, from: options.from, to: options.to }) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createStoryHtml } from "../../services/semantic-lifecycle/publishing.js";
|
||||||
|
import { atomicWrite, loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
|
||||||
|
import { simulateFailure } from "../../services/semantic-lifecycle/analysis.js";
|
||||||
|
export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record<string, unknown> { if (!outputPath) throw new Error("story requires --output <story.html>"); const ir = loadIR(filePath); const scenario = options.fail ? simulateFailure(ir, options.fail) : undefined; const output = atomicWrite(outputPath, createStoryHtml(ir, { scenario }), [filePath]); return { action: "story", output, accessible: true, offline: true, scenario: scenario ? { failed: scenario.failed, impacted: scenario.impacted } : undefined }; }
|
||||||
@@ -0,0 +1,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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { POLICY_IDS, runPolicies } from "../../services/semantic-lifecycle/analysis.js";
|
||||||
|
import { loadIR, loadStructured, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
|
||||||
|
export function run(filePath: string, _page = 0, _output?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
|
||||||
|
let rules: readonly string[] = POLICY_IDS; if (options.spec) { const raw = loadStructured(options.spec); rules = Array.isArray(raw) ? raw.map(String) : ((raw as { rules?: unknown[] }).rules ?? []).map((item) => typeof item === "string" ? item : String((item as { id?: unknown }).id)); }
|
||||||
|
const unknown = rules.filter((rule) => !(POLICY_IDS as readonly string[]).includes(rule));
|
||||||
|
if (unknown.length) throw new Error(`Unknown policy identifier: ${unknown.join(", ")}`);
|
||||||
|
const report = runPolicies(loadIR(filePath), rules); return { action: "test", ...report, failed: report.errors > 0 || (options.strict === true && report.warnings > 0) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 })) };
|
||||||
|
}
|
||||||
@@ -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) }; }
|
||||||
@@ -7,13 +7,22 @@ const DEFAULT_NODE_STYLE = "whiteSpace=wrap;html=1;rounded=0;fillColor=#f5f5f5;s
|
|||||||
const DEFAULT_CONTAINER_STYLE = "swimlane;html=1;rounded=0;startSize=30;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 DEFAULT_EDGE_STYLE = "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;";
|
||||||
|
|
||||||
|
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 {
|
function escapeXml(value: string): string {
|
||||||
return value
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stableJson(value: unknown): string {
|
function stableJson(value: unknown): string {
|
||||||
@@ -26,38 +35,65 @@ function stableJson(value: unknown): string {
|
|||||||
return JSON.stringify(value);
|
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 {
|
function dataAttributes(values: Record<string, unknown>): string {
|
||||||
return Object.entries(values)
|
return Object.entries(values).filter(([, value]) => value !== undefined).map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`).join("");
|
||||||
.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): string {
|
||||||
|
return Object.entries(values ?? {}).filter(([key]) => !prefix || key.startsWith(prefix)).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` ${escapeXml(prefix ? key.slice(prefix.length) : 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 } {
|
function geometryForNode(node: DiagramNode, index: number, page: DiagramPage): { x: number; y: number; width: number; height: number } {
|
||||||
if (node.geometry) return node.geometry;
|
if (node.geometry) return node.geometry;
|
||||||
const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal";
|
const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal";
|
||||||
return {
|
return { x: horizontal ? 40 + index * 200 : 40, y: horizontal ? 80 : 80 + index * 120, width: node.width ?? 120, height: node.height ?? 60 };
|
||||||
x: horizontal ? 40 + index * 200 : 40,
|
}
|
||||||
y: horizontal ? 80 : 80 + index * 120,
|
|
||||||
width: node.width ?? 120,
|
function wrapHolder(cell: string, id: string, label: string, ext: Record<string, unknown> | undefined, p: Preservation): string {
|
||||||
height: node.height ?? 60,
|
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 {
|
function serializeNode(node: DiagramNode, index: number, page: DiagramPage): string {
|
||||||
const geometry = geometryForNode(node, index, page);
|
const geometry = geometryForNode(node, index, page);
|
||||||
const style = node.style ?? (node.kind === "container" ? DEFAULT_CONTAINER_STYLE : DEFAULT_NODE_STYLE);
|
const style = node.style ?? (node.kind === "container" ? DEFAULT_CONTAINER_STYLE : DEFAULT_NODE_STYLE);
|
||||||
const parent = node.parentId ?? "1";
|
const parent = node.parentId ?? "1";
|
||||||
const metadata = dataAttributes({ kind: node.kind, properties: node.properties, provenance: node.provenance, extensions: node.extensions });
|
const p = preservation(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 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 {
|
function serializeEdge(edge: DiagramEdge): string {
|
||||||
const points = edge.waypoints?.length
|
const p = preservation(edge.extensions);
|
||||||
? `<Array as="points">${edge.waypoints.map((point) => `<mxPoint x="${point.x}" y="${point.y}"/>`).join("")}</Array>`
|
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 metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: edge.extensions });
|
const geometryChildren = `${points}${(p.geometryChildXml ?? []).join("")}`;
|
||||||
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 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 } {
|
function contentExtent(page: DiagramPage): { width: number; height: number } {
|
||||||
@@ -75,14 +111,11 @@ function contentExtent(page: DiagramPage): { width: number; height: number } {
|
|||||||
};
|
};
|
||||||
const geometries = page.nodes.map((node) => resolve(node.id)).filter((geometry) => geometry !== undefined);
|
const geometries = page.nodes.map((node) => resolve(node.id)).filter((geometry) => geometry !== undefined);
|
||||||
const points = page.edges.flatMap((edge) => edge.waypoints ?? []);
|
const points = page.edges.flatMap((edge) => edge.waypoints ?? []);
|
||||||
return {
|
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)) };
|
||||||
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 {
|
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 nodeCells = page.nodes.map((node, index) => serializeNode(node, index, page));
|
||||||
const edgeCells = page.edges.map(serializeEdge);
|
const edgeCells = page.edges.map(serializeEdge);
|
||||||
const extent = contentExtent(page);
|
const extent = contentExtent(page);
|
||||||
@@ -90,12 +123,18 @@ function serializePage(input: DiagramPage): string {
|
|||||||
const fit = (value: number) => Math.ceil((value + 40) / grid) * grid;
|
const fit = (value: number) => Math.ceil((value + 40) / grid) * grid;
|
||||||
const pageWidth = Math.max(page.width ?? 1169, fit(extent.width));
|
const pageWidth = Math.max(page.width ?? 1169, fit(extent.width));
|
||||||
const pageHeight = Math.max(page.height ?? 827, fit(extent.height));
|
const pageHeight = Math.max(page.height ?? 827, fit(extent.height));
|
||||||
const metadata = dataAttributes({ properties: page.properties, extensions: page.extensions });
|
const p = preservation(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 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:")}>\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 {
|
export function diagramIRToDrawio(ir: DiagramIR): string {
|
||||||
const normalized = normalizeDiagramIR(ir);
|
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 });
|
const p = preservation(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 metadata = dataAttributes({ version: normalized.version, title: normalized.title, theme: normalized.theme, provenance: normalized.provenance, assets: normalized.assets, properties: normalized.properties, extensions: semanticExtensions(normalized.extensions) });
|
||||||
|
const rootChildren = (p.childXml ?? []).map((xml) => ` ${xml}`);
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<mxfile host="${escapeXml(p.host ?? "drawio-tools")}"${metadata}${unprefixedAttributes(p.attributes)}>\n${rootChildren.length ? `${rootChildren.join("\n")}\n` : ""}${normalized.pages.map(serializePage).join("\n")}\n</mxfile>\n`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ test("CLI rejects partially numeric page indexes", () => {
|
|||||||
assert.match(result.stderr, /non-negative integer/);
|
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", () => {
|
test("CLI validate exits non-zero when the validation summary is invalid", () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "drawio-cli-validate-test-"));
|
const dir = mkdtempSync(join(tmpdir(), "drawio-cli-validate-test-"));
|
||||||
const input = join(dir, "empty.drawio");
|
const input = join(dir, "empty.drawio");
|
||||||
|
|||||||
@@ -22,13 +22,14 @@
|
|||||||
|
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
import * as yaml from "js-yaml";
|
import * as yaml from "js-yaml";
|
||||||
|
import type { LifecycleActionOptions } from "../services/semantic-lifecycle/lifecycle-io.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Action registry
|
// Action registry
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type ActionModule = {
|
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>> = {
|
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"),
|
"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"),
|
"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> {
|
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 {
|
try {
|
||||||
({ values } = parseArgs({
|
({ values } = parseArgs({
|
||||||
args: process.argv.slice(2),
|
args: process.argv.slice(2),
|
||||||
@@ -62,6 +72,17 @@ async function main(): Promise<void> {
|
|||||||
action: { type: "string", short: "a" },
|
action: { type: "string", short: "a" },
|
||||||
page: { type: "string", short: "p" },
|
page: { type: "string", short: "p" },
|
||||||
output: { type: "string", short: "o" },
|
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" },
|
help: { type: "boolean", short: "h" },
|
||||||
},
|
},
|
||||||
strict: true,
|
strict: true,
|
||||||
@@ -74,11 +95,25 @@ async function main(): Promise<void> {
|
|||||||
if (values.help) {
|
if (values.help) {
|
||||||
console.log(`drawio-tools --file <path> --action <action> [--page <index>] [--output <path>]
|
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(", ")}`);
|
Actions: ${Object.keys(ACTIONS).join(", ")}`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!values.file) {
|
if (!values.file && values.action !== "doctor") {
|
||||||
printError("Missing required argument: --file");
|
printError("Missing required argument: --file");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -88,7 +123,7 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actionName = values.action;
|
const actionName = values.action;
|
||||||
const filePath = values.file;
|
const filePath = values.file ?? "";
|
||||||
|
|
||||||
// Parse optional --page argument (0-based index, default 0)
|
// Parse optional --page argument (0-based index, default 0)
|
||||||
let pageIndex = 0;
|
let pageIndex = 0;
|
||||||
@@ -113,7 +148,11 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const mod = await ACTIONS[actionName]();
|
const mod = await ACTIONS[actionName]();
|
||||||
const result = mod.run(filePath, pageIndex, values.output);
|
const result = mod.run(filePath, pageIndex, values.output, {
|
||||||
|
spec: values.spec, base: values.base, strict: values.strict, prune: values.prune,
|
||||||
|
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(
|
process.stdout.write(
|
||||||
yaml.dump(result, { noRefs: true, sortKeys: false, lineWidth: 120 })
|
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) {
|
if (actionName === "validate" && summary && typeof summary === "object" && (summary as Record<string, unknown>).valid === false) {
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
|
if (result.failed === true) process.exitCode = 1;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorOutput = {
|
const errorOutput = {
|
||||||
error: true,
|
error: true,
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const CLI = resolve("src/cli/commands.ts");
|
||||||
|
const run = (args: string[]) => spawnSync(process.execPath, ["--import", "tsx", CLI, ...args], { cwd: resolve("."), encoding: "utf8" });
|
||||||
|
|
||||||
|
test("CLI imports Draw.io to machine-readable IR only with explicit atomic output", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-import-"));
|
||||||
|
const source = join(dir, "source.drawio"); const output = join(dir, "model.json");
|
||||||
|
try {
|
||||||
|
writeFileSync(source, `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="API" vertex="1" parent="1"><mxGeometry x="10" y="20" width="100" height="50" as="geometry"/></mxCell></root></mxGraphModel>`, "utf8");
|
||||||
|
const missing = run(["--action", "import", "--file", source]);
|
||||||
|
assert.equal(missing.status, 1);
|
||||||
|
assert.match(missing.stdout, /requires --output/i);
|
||||||
|
const result = run(["--action", "import", "--file", source, "--output", output]);
|
||||||
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||||
|
const parsed = JSON.parse(readFileSync(output, "utf8"));
|
||||||
|
assert.equal(parsed.version, 2);
|
||||||
|
assert.equal(parsed.pages[0].nodes[0].id, "api");
|
||||||
|
assert.match(result.stdout, /lossy: false/);
|
||||||
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed CLI edit is atomic and traversal aliases cannot clobber source", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-edit-"));
|
||||||
|
const source = join(dir, "source.json"); const spec = join(dir, "bad.yaml"); const output = join(dir, "output.json");
|
||||||
|
const sourceText = JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }, null, 2) + "\n";
|
||||||
|
try {
|
||||||
|
writeFileSync(source, sourceText, "utf8"); writeFileSync(spec, "pageId: p\noperations:\n - { type: update, id: api, changes: { parentId: missing } }\n", "utf8"); writeFileSync(output, "DO NOT CLOBBER", "utf8");
|
||||||
|
const failed = run(["--action", "edit", "--file", source, "--spec", spec, "--output", output]);
|
||||||
|
assert.equal(failed.status, 1);
|
||||||
|
assert.equal(readFileSync(source, "utf8"), sourceText);
|
||||||
|
assert.equal(readFileSync(output, "utf8"), "DO NOT CLOBBER");
|
||||||
|
const alias = join(dir, "nested", "..", "source.json");
|
||||||
|
const clobber = run(["--action", "edit", "--file", source, "--spec", spec, "--output", alias]);
|
||||||
|
assert.equal(clobber.status, 1);
|
||||||
|
assert.match(clobber.stdout, /overwrite an input/i);
|
||||||
|
assert.equal(readFileSync(source, "utf8"), sourceText);
|
||||||
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI rejects symlink input aliases targeting the real output", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-symlink-alias-"));
|
||||||
|
const source = join(dir, "source.json"); const inputLink = join(dir, "input.json"); const spec = join(dir, "edit.yaml");
|
||||||
|
const sourceText = JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }, null, 2) + "\n";
|
||||||
|
try {
|
||||||
|
writeFileSync(source, sourceText, "utf8");
|
||||||
|
symlinkSync(source, inputLink);
|
||||||
|
writeFileSync(spec, "pageId: p\noperations:\n - { type: update, id: api, changes: { label: Updated } }\n", "utf8");
|
||||||
|
const result = run(["--action", "edit", "--file", inputLink, "--spec", spec, "--output", source]);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.stdout, /overwrite an input/i);
|
||||||
|
assert.equal(readFileSync(source, "utf8"), sourceText);
|
||||||
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI lifecycle flags route deterministic views, policies, what-if, sync, story, and doctor", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-actions-"));
|
||||||
|
const model = join(dir, "model.json"); const views = join(dir, "views.drawio"); const story = join(dir, "story.html"); const base = join(dir, "base.json"); const incoming = join(dir, "incoming.json"); const synced = join(dir, "synced.json");
|
||||||
|
const ir = { version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API", kind: "service", properties: { environment: "production" } }, { id: "db", label: "DB", kind: "database" }], edges: [{ id: "write", source: "api", target: "db", properties: { isolates_failure: true } }] }] };
|
||||||
|
try {
|
||||||
|
writeFileSync(model, JSON.stringify(ir), "utf8"); writeFileSync(base, JSON.stringify(ir), "utf8"); writeFileSync(incoming, JSON.stringify(ir), "utf8");
|
||||||
|
const viewResult = run(["--action", "views", "--file", model, "--views", "system,security", "--output", views]);
|
||||||
|
assert.equal(viewResult.status, 0, viewResult.stdout + viewResult.stderr); assert.equal(existsSync(views), true);
|
||||||
|
const policy = run(["--action", "test", "--file", model, "--strict"]); assert.equal(policy.status, 1); assert.match(policy.stdout, /warnings:/);
|
||||||
|
const whatif = run(["--action", "what-if", "--file", model, "--fail", "api"]); assert.equal(whatif.status, 0); assert.match(whatif.stdout, /impacted: \[\]/);
|
||||||
|
const sync = run(["--action", "sync", "--file", model, "--base", base, "--spec", incoming, "--prune", "--output", synced]); assert.equal(sync.status, 0, sync.stdout + sync.stderr);
|
||||||
|
const storyResult = run(["--action", "story", "--file", model, "--fail", "api", "--output", story]); assert.equal(storyResult.status, 0); assert.doesNotMatch(readFileSync(story, "utf8"), /https?:\/\//);
|
||||||
|
const doctor = run(["--action", "doctor"]); assert.equal(doctor.status, 0, doctor.stdout + doctor.stderr); assert.match(doctor.stdout, /networkRequired: false/);
|
||||||
|
const first = readFileSync(views, "utf8"); run(["--action", "views", "--file", model, "--views", "system,security", "--output", views]); assert.equal(readFileSync(views, "utf8"), first);
|
||||||
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI edits serialize Draw.io output and rejects unsupported import extensions", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-formats-"));
|
||||||
|
const model = join(dir, "model.json"); const spec = join(dir, "edit.yaml"); const drawio = join(dir, "edited.drawio");
|
||||||
|
const source = join(dir, "source.drawio"); const unsupported = join(dir, "model.txt");
|
||||||
|
try {
|
||||||
|
writeFileSync(model, JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }), "utf8");
|
||||||
|
writeFileSync(spec, "pageId: p\noperations:\n - { type: update, id: api, changes: { label: Updated } }\n", "utf8");
|
||||||
|
const edited = run(["--action", "edit", "--file", model, "--spec", spec, "--output", drawio]);
|
||||||
|
assert.equal(edited.status, 0, edited.stdout + edited.stderr);
|
||||||
|
assert.match(readFileSync(drawio, "utf8"), /^<\?xml[^]*<mxfile/);
|
||||||
|
|
||||||
|
writeFileSync(source, `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`, "utf8");
|
||||||
|
const imported = run(["--action", "import", "--file", source, "--output", unsupported]);
|
||||||
|
assert.equal(imported.status, 1);
|
||||||
|
assert.match(imported.stdout, /yaml.*json|json.*yaml/i);
|
||||||
|
assert.equal(existsSync(unsupported), false);
|
||||||
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI rejects unknown linked views and policy identifiers", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-invalid-"));
|
||||||
|
const model = join(dir, "model.json"); const views = join(dir, "views.drawio"); const rules = join(dir, "rules.yaml");
|
||||||
|
try {
|
||||||
|
writeFileSync(model, JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }), "utf8");
|
||||||
|
writeFileSync(rules, "rules: [not-a-real-policy]\n", "utf8");
|
||||||
|
const badView = run(["--action", "views", "--file", model, "--views", "not-a-view", "--output", views]);
|
||||||
|
assert.equal(badView.status, 1); assert.match(badView.stdout, /unknown.*view/i); assert.equal(existsSync(views), false);
|
||||||
|
const badPolicy = run(["--action", "test", "--file", model, "--spec", rules]);
|
||||||
|
assert.equal(badPolicy.status, 1); assert.match(badPolicy.stdout, /unknown.*policy/i);
|
||||||
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
@@ -79,7 +79,7 @@ export interface DiagramIRV2 {
|
|||||||
|
|
||||||
export type DiagramIR = DiagramIRV1 | DiagramIRV2;
|
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 RESERVED_IDS = new Set(["0", "1"]);
|
||||||
const LAYOUT_TYPES = new Set<DiagramLayoutType>(["linear", "layered", "tree", "grid", "manual"]);
|
const LAYOUT_TYPES = new Set<DiagramLayoutType>(["linear", "layered", "tree", "grid", "manual"]);
|
||||||
const ROOT_V1_FIELDS = new Set(["version", "title", "direction", "nodes", "edges"]);
|
const ROOT_V1_FIELDS = new Set(["version", "title", "direction", "nodes", "edges"]);
|
||||||
|
|||||||
@@ -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="{"owner":"team-a"}" data-provenance="{"source":"catalog"}" data-extensions="{"x":1}"><mxGeometry x="40" y="60" width="120" height="60" as="geometry"/><Custom payload="keep"/></mxCell><mxCell id="call" value="HTTPS" edge="1" parent="1" source="api" target="zone" style="edgeStyle=orthogonalEdgeStyle;" data-kind="request"><mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="220" y="100"/></Array></mxGeometry></mxCell><mxCell id="note" value="raw" parent="1" custom="yes"/></root></mxGraphModel></diagram><diagram id="deploy" name="Deployment"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="host" value="Host" vertex="1" parent="1"><mxGeometry x="10" y="10" width="100" height="50" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`, "utf8");
|
||||||
|
|
||||||
|
const imported = importDrawioToIR(source);
|
||||||
|
assert.equal(imported.ir.pages.length, 2);
|
||||||
|
const page = imported.ir.pages[0];
|
||||||
|
assert.equal(page.id, "system");
|
||||||
|
assert.equal(page.nodes.find((node) => node.id === "api")?.parentId, "zone");
|
||||||
|
assert.deepEqual(page.nodes.find((node) => node.id === "api")?.geometry, { x: 40, y: 60, width: 120, height: 60 });
|
||||||
|
assert.deepEqual(page.nodes.find((node) => node.id === "api")?.properties, { owner: "team-a" });
|
||||||
|
assert.deepEqual(page.edges[0].waypoints, [{ x: 220, y: 100 }]);
|
||||||
|
assert.equal(imported.lossReport.lossy, false);
|
||||||
|
assert.ok(JSON.stringify(imported.ir.extensions).includes("custom-root"));
|
||||||
|
assert.ok(JSON.stringify(page.extensions).includes("note"));
|
||||||
|
|
||||||
|
const rebuilt = diagramIRToDrawio(imported.ir);
|
||||||
|
assert.match(rebuilt, /<mxfile host="desktop"/);
|
||||||
|
const roundTrip = importDrawioToIR(rebuilt, { sourceIsXml: true });
|
||||||
|
assert.deepEqual(roundTrip.ir.pages.map((item) => item.id), ["system", "deploy"]);
|
||||||
|
assert.deepEqual(roundTrip.ir.pages[0].nodes, page.nodes);
|
||||||
|
assert.deepEqual(roundTrip.ir.pages[0].edges, page.edges);
|
||||||
|
assert.equal(diagramIRToDrawio(roundTrip.ir), rebuilt);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("import and serialization preserve model flags, node geometry children, and absent node style without false losslessness", () => {
|
||||||
|
const xml = `<mxfile><diagram id="p" name="P"><mxGraphModel grid="0" page="0"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="10" y="20" width="100" height="50" relative="1" as="geometry"><mxPoint x="3" y="4" as="offset"/></mxGeometry></mxCell></root></mxGraphModel></diagram></mxfile>`;
|
||||||
|
const imported = importDrawioToIR(xml, { sourceIsXml: true });
|
||||||
|
assert.equal(imported.lossReport.lossy, false);
|
||||||
|
|
||||||
|
const rebuilt = diagramIRToDrawio(imported.ir);
|
||||||
|
const document = new JSDOM(rebuilt, { contentType: "text/xml" }).window.document;
|
||||||
|
const model = document.querySelector("mxGraphModel")!;
|
||||||
|
const node = document.querySelector("mxCell#n")!;
|
||||||
|
const geometry = node.querySelector(":scope > mxGeometry")!;
|
||||||
|
const offset = geometry.querySelector(":scope > mxPoint[as='offset']")!;
|
||||||
|
assert.equal(model.getAttribute("grid"), "0");
|
||||||
|
assert.equal(model.getAttribute("page"), "0");
|
||||||
|
assert.equal(node.hasAttribute("style"), false);
|
||||||
|
assert.equal(geometry.getAttribute("relative"), "1");
|
||||||
|
assert.deepEqual([offset.getAttribute("x"), offset.getAttribute("y")], ["3", "4"]);
|
||||||
|
assert.equal(importDrawioToIR(rebuilt, { sourceIsXml: true }).lossReport.lossy, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("import rejects duplicate semantic node IDs across pages", () => {
|
||||||
|
const xml = `<mxfile><diagram id="one" name="One"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="First" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="two" name="Two"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="Second" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`;
|
||||||
|
assert.throws(() => importDrawioToIR(xml, { sourceIsXml: true }), /duplicate semantic node id.*api.*pages.*one.*two/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loss-aware import fails closed on dangling endpoints", () => {
|
||||||
|
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="A" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell><mxCell id="bad" edge="1" source="a" target="missing"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel>`;
|
||||||
|
assert.throws(() => importDrawioToIR(xml, { sourceIsXml: true }), /dangling endpoint.*bad.*missing/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loss-aware import preserves valid Draw.io IDs that begin with digits", () => {
|
||||||
|
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="42-node" value="Numeric" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`;
|
||||||
|
assert.equal(importDrawioToIR(xml, { sourceIsXml: true }).ir.pages[0].nodes[0].id, "42-node");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loss-aware import preserves unknown mxfile, diagram, and model children", () => {
|
||||||
|
const xml = `<mxfile><meta-marker keep="root"/><diagram id="p" name="P"><diagram-marker keep="page"/><mxGraphModel><model-marker keep="model"/><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`;
|
||||||
|
const imported = importDrawioToIR(xml, { sourceIsXml: true });
|
||||||
|
assert.equal(imported.lossReport.lossy, false);
|
||||||
|
const rebuilt = diagramIRToDrawio(imported.ir);
|
||||||
|
assert.match(rebuilt, /<meta-marker keep="root"\s*\/>/);
|
||||||
|
assert.match(rebuilt, /<diagram-marker keep="page"\s*\/>/);
|
||||||
|
assert.match(rebuilt, /<model-marker keep="model"\s*\/>/);
|
||||||
|
const second = importDrawioToIR(rebuilt, { sourceIsXml: true });
|
||||||
|
assert.equal(second.lossReport.lossy, false);
|
||||||
|
assert.equal(diagramIRToDrawio(second.ir), rebuilt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loss-aware import preserves deliberately empty labels", () => {
|
||||||
|
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="icon" value="" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`;
|
||||||
|
const imported = importDrawioToIR(xml, { sourceIsXml: true }).ir;
|
||||||
|
assert.equal(imported.pages[0].nodes[0].label, "icon");
|
||||||
|
assert.match(JSON.stringify(imported.pages[0].nodes[0].extensions), /semantic:label/);
|
||||||
|
assert.match(diagramIRToDrawio(imported), /id="icon" value=""/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loss-aware import envelopes malformed semantic attributes instead of dropping them", () => {
|
||||||
|
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="API" vertex="1" parent="1" data-properties="not-json"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`;
|
||||||
|
const result = importDrawioToIR(xml, { sourceIsXml: true });
|
||||||
|
assert.match(JSON.stringify(result.ir.pages[0].nodes[0].extensions), /data-properties.*not-json/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
|
import type { DiagramEdge, DiagramIRV2, DiagramNode, DiagramPage } from "../../model/diagram-ir.js";
|
||||||
|
import { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||||
|
import { decodeDiagramContent, normalizeDrawioDocument } from "../drawio-parser/parser.js";
|
||||||
|
|
||||||
|
export interface PreservationEnvelope {
|
||||||
|
host?: string;
|
||||||
|
attributes?: Record<string, string>;
|
||||||
|
holderTag?: string;
|
||||||
|
holderAttributes?: Record<string, string>;
|
||||||
|
childXml?: string[];
|
||||||
|
geometryAttributes?: Record<string, string>;
|
||||||
|
geometryChildXml?: string[];
|
||||||
|
styleAbsent?: boolean;
|
||||||
|
modelAttributes?: Record<string, string>;
|
||||||
|
modelChildXml?: string[];
|
||||||
|
unknownCells?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportLossReport {
|
||||||
|
lossy: boolean;
|
||||||
|
preserved: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportDrawioOptions {
|
||||||
|
sourceIsXml?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CELL_KNOWN = new Set(["id", "value", "label", "style", "vertex", "edge", "parent", "source", "target", "data-kind", "data-properties", "data-provenance", "data-extensions"]);
|
||||||
|
const GEOMETRY_KNOWN = new Set(["x", "y", "width", "height", "relative", "as"]);
|
||||||
|
const PAGE_KNOWN = new Set(["id", "name", "data-properties", "data-extensions"]);
|
||||||
|
const MODEL_KNOWN = new Set(["pageWidth", "pageHeight", "grid", "gridSize", "page"]);
|
||||||
|
const ROOT_KNOWN = new Set(["host", "data-version", "data-title", "data-theme", "data-provenance", "data-assets", "data-properties", "data-extensions"]);
|
||||||
|
|
||||||
|
function parseDocument(xml: string, expected: "mxfile" | "mxGraphModel"): Document {
|
||||||
|
if (/<!DOCTYPE/i.test(xml)) throw new Error("Invalid XML: DOCTYPE is not allowed");
|
||||||
|
const Parser = new JSDOM("<root/>", { contentType: "text/xml" }).window.DOMParser;
|
||||||
|
const parsed = new Parser().parseFromString(xml, "text/xml");
|
||||||
|
if (parsed.querySelector("parsererror") || parsed.documentElement.tagName !== expected) {
|
||||||
|
throw new Error(`Invalid XML: expected complete <${expected}> document`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeElement(element: Element): string {
|
||||||
|
const XMLSerializer = new JSDOM("<root/>", { contentType: "text/xml" }).window.XMLSerializer;
|
||||||
|
return new XMLSerializer().serializeToString(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attrs(element: Element, known: Set<string>): Record<string, string> | undefined {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const attribute of Array.from(element.attributes).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
if (!known.has(attribute.name)) result[attribute.name] = attribute.value;
|
||||||
|
}
|
||||||
|
return Object.keys(result).length ? result : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedAttrs(element: Element, names: Set<string>): Record<string, string> | undefined {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const name of names) if (element.hasAttribute(name)) result[name] = element.getAttribute(name)!;
|
||||||
|
return Object.keys(result).length ? result : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonObject(element: Element, name: string): Record<string, unknown> | undefined {
|
||||||
|
const raw = element.getAttribute(name);
|
||||||
|
if (!raw) return undefined;
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(raw);
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
// Invalid semantic JSON remains preserved as an unknown attribute below.
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberAttr(element: Element | null, name: string, fallback = 0): number {
|
||||||
|
const raw = element?.getAttribute(name);
|
||||||
|
if (raw === null || raw === undefined || raw === "") return fallback;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isFinite(value)) throw new Error(`Invalid geometry ${name}: ${raw}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePreservation(extensions: Record<string, unknown> | undefined, envelope: PreservationEnvelope): Record<string, unknown> | undefined {
|
||||||
|
const clean = Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined && (!Array.isArray(value) || value.length > 0)));
|
||||||
|
if (!Object.keys(clean).length) return extensions;
|
||||||
|
return { ...(extensions ?? {}), $drawio: clean };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellRows(model: Element): Array<{ holder: Element; cell: Element }> {
|
||||||
|
const root = Array.from(model.children).find((child) => child.tagName === "root");
|
||||||
|
if (!root) throw new Error("Draw.io page is missing <root>");
|
||||||
|
const rows: Array<{ holder: Element; cell: Element }> = [];
|
||||||
|
for (const child of Array.from(root.children)) {
|
||||||
|
if (child.tagName === "mxCell") rows.push({ holder: child, cell: child });
|
||||||
|
else if (child.tagName === "object" || child.tagName === "UserObject") {
|
||||||
|
const cell = Array.from(child.children).find((nested) => nested.tagName === "mxCell");
|
||||||
|
if (cell) rows.push({ holder: child, cell });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePage(modelXml: string, pageElement: Element | undefined, index: number, report: ImportLossReport): DiagramPage {
|
||||||
|
const document = parseDocument(modelXml, "mxGraphModel");
|
||||||
|
const model = document.documentElement;
|
||||||
|
const rows = cellRows(model);
|
||||||
|
const nodeIds = new Set(rows.filter(({ cell }) => cell.getAttribute("vertex") === "1").map(({ holder, cell }) => holder.getAttribute("id") ?? cell.getAttribute("id") ?? ""));
|
||||||
|
const root = Array.from(model.children).find((child) => child.tagName === "root")!;
|
||||||
|
const knownElements = new Set(rows.map(({ holder }) => holder));
|
||||||
|
const unknownCells = Array.from(root.children).filter((child) => !knownElements.has(child) && !["0", "1"].includes(child.getAttribute("id") ?? "")).map((child) => serializeElement(child));
|
||||||
|
const nodes: DiagramNode[] = [];
|
||||||
|
const edges: DiagramEdge[] = [];
|
||||||
|
|
||||||
|
for (const { holder, cell } of rows) {
|
||||||
|
const id = holder.getAttribute("id") ?? cell.getAttribute("id") ?? "";
|
||||||
|
if (!id || id === "0" || id === "1") continue;
|
||||||
|
const semantic = holder === cell ? cell : holder;
|
||||||
|
const geometry = Array.from(cell.children).find((child) => child.tagName === "mxGeometry") ?? null;
|
||||||
|
const envelope: PreservationEnvelope = {
|
||||||
|
attributes: attrs(cell, CELL_KNOWN),
|
||||||
|
holderTag: holder === cell ? undefined : holder.tagName,
|
||||||
|
holderAttributes: holder === cell ? undefined : attrs(holder, new Set(["id", "label", "value", "data-kind", "data-properties", "data-provenance", "data-extensions"])),
|
||||||
|
childXml: Array.from(cell.children).filter((child) => child.tagName !== "mxGeometry").map((child) => serializeElement(child)),
|
||||||
|
geometryAttributes: geometry ? selectedAttrs(geometry, new Set(["relative", "as"])) : undefined,
|
||||||
|
geometryChildXml: geometry ? Array.from(geometry.children).filter((child) => !(cell.getAttribute("edge") === "1" && child.matches("Array[as='points']"))).map((child) => serializeElement(child)) : undefined,
|
||||||
|
styleAbsent: cell.getAttribute("vertex") === "1" && !cell.hasAttribute("style") ? true : undefined,
|
||||||
|
};
|
||||||
|
for (const name of ["data-properties", "data-provenance", "data-extensions"]) {
|
||||||
|
const owner = semantic.hasAttribute(name) ? semantic : cell;
|
||||||
|
if (owner.hasAttribute(name) && jsonObject(owner, name) === undefined) {
|
||||||
|
envelope.attributes = { ...(envelope.attributes ?? {}), [name]: owner.getAttribute(name)! };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const geometryAttrs = geometry ? attrs(geometry, GEOMETRY_KNOWN) : undefined;
|
||||||
|
if (geometryAttrs) envelope.attributes = { ...(envelope.attributes ?? {}), ...Object.fromEntries(Object.entries(geometryAttrs).map(([key, value]) => [`geometry:${key}`, value])) };
|
||||||
|
const rawLabel = semantic.getAttribute("label") ?? semantic.getAttribute("value") ?? cell.getAttribute("value") ?? id;
|
||||||
|
if (rawLabel.trim() === "") envelope.attributes = { ...(envelope.attributes ?? {}), "semantic:label": rawLabel };
|
||||||
|
const extensions = mergePreservation(jsonObject(semantic, "data-extensions") ?? jsonObject(cell, "data-extensions"), envelope);
|
||||||
|
const label = rawLabel.trim() === "" ? id : rawLabel;
|
||||||
|
if (cell.getAttribute("vertex") === "1") {
|
||||||
|
nodes.push({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
kind: semantic.getAttribute("data-kind") ?? cell.getAttribute("data-kind") ?? (nodeIds.has(id) && rows.some((row) => row.cell.getAttribute("parent") === id) ? "container" : "service"),
|
||||||
|
...(cell.getAttribute("parent") && cell.getAttribute("parent") !== "1" ? { parentId: cell.getAttribute("parent")! } : {}),
|
||||||
|
geometry: { x: numberAttr(geometry, "x"), y: numberAttr(geometry, "y"), width: numberAttr(geometry, "width"), height: numberAttr(geometry, "height") },
|
||||||
|
...(cell.hasAttribute("style") ? { style: cell.getAttribute("style")! } : {}),
|
||||||
|
...(jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") ? { properties: jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") } : {}),
|
||||||
|
...(jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") ? { provenance: jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") } : {}),
|
||||||
|
...(extensions ? { extensions } : {}),
|
||||||
|
});
|
||||||
|
} else if (cell.getAttribute("edge") === "1") {
|
||||||
|
const source = cell.getAttribute("source") ?? "";
|
||||||
|
const target = cell.getAttribute("target") ?? "";
|
||||||
|
const missing = [source, target].filter((endpoint) => !nodeIds.has(endpoint));
|
||||||
|
if (missing.length) throw new Error(`Unsupported dangling endpoint on edge ${id}: ${missing.join(", ")}`);
|
||||||
|
const points = geometry ? Array.from(geometry.querySelectorAll("Array[as='points'] > mxPoint")).map((point) => ({ x: numberAttr(point, "x"), y: numberAttr(point, "y") })) : [];
|
||||||
|
edges.push({
|
||||||
|
id, source, target, label: rawLabel,
|
||||||
|
kind: semantic.getAttribute("data-kind") ?? cell.getAttribute("data-kind") ?? "relation",
|
||||||
|
...(cell.hasAttribute("style") ? { style: cell.getAttribute("style")! } : {}),
|
||||||
|
...(points.length ? { waypoints: points } : {}),
|
||||||
|
...(jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") ? { properties: jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") } : {}),
|
||||||
|
...(jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") ? { provenance: jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") } : {}),
|
||||||
|
...(extensions ? { extensions } : {}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
unknownCells.push(serializeElement(holder));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageExtensions = mergePreservation(jsonObject(pageElement ?? model, "data-extensions"), {
|
||||||
|
attributes: { ...(pageElement ? attrs(pageElement, PAGE_KNOWN) : {}), ...Object.fromEntries(Object.entries(attrs(model, MODEL_KNOWN) ?? {}).map(([key, value]) => [`model:${key}`, value])) },
|
||||||
|
modelAttributes: selectedAttrs(model, MODEL_KNOWN),
|
||||||
|
childXml: pageElement ? Array.from(pageElement.children).filter((child) => child.tagName !== "mxGraphModel").map(serializeElement) : undefined,
|
||||||
|
modelChildXml: Array.from(model.children).filter((child) => child !== root).map(serializeElement),
|
||||||
|
unknownCells,
|
||||||
|
});
|
||||||
|
if (unknownCells.length) report.preserved.push(`page ${index + 1}: ${unknownCells.length} unknown cell(s)`);
|
||||||
|
return {
|
||||||
|
id: pageElement?.getAttribute("id") || `page-${index + 1}`,
|
||||||
|
title: pageElement?.getAttribute("name") || `Page ${index + 1}`,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
layout: { type: "manual", gridSize: numberAttr(model, "gridSize", 10) },
|
||||||
|
...(numberAttr(model, "pageWidth") > 0 ? { width: numberAttr(model, "pageWidth") } : {}),
|
||||||
|
...(numberAttr(model, "pageHeight") > 0 ? { height: numberAttr(model, "pageHeight") } : {}),
|
||||||
|
...(jsonObject(pageElement ?? model, "data-properties") ? { properties: jsonObject(pageElement ?? model, "data-properties") } : {}),
|
||||||
|
...(pageExtensions ? { extensions: pageExtensions } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importDrawioToIR(source: string, options: ImportDrawioOptions = {}): { ir: DiagramIRV2; lossReport: ImportLossReport } {
|
||||||
|
const raw = normalizeDrawioDocument(options.sourceIsXml ? source : readFileSync(source, "utf8"));
|
||||||
|
const report: ImportLossReport = { lossy: false, preserved: [], warnings: [] };
|
||||||
|
let pages: DiagramPage[];
|
||||||
|
let root: Element | undefined;
|
||||||
|
if (/^<mxfile/i.test(raw)) {
|
||||||
|
const document = parseDocument(raw, "mxfile");
|
||||||
|
root = document.documentElement;
|
||||||
|
const diagrams = Array.from(root.children).filter((child) => child.tagName === "diagram");
|
||||||
|
if (!diagrams.length) throw new Error("No <diagram> elements found in mxfile");
|
||||||
|
pages = diagrams.map((diagram, index) => {
|
||||||
|
const model = Array.from(diagram.children).find((child) => child.tagName === "mxGraphModel");
|
||||||
|
return parsePage(model ? serializeElement(model) : decodeDiagramContent(diagram.textContent ?? ""), diagram, index, report);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pages = [parsePage(raw, undefined, 0, report)];
|
||||||
|
}
|
||||||
|
const nodePages = new Map<string, string>();
|
||||||
|
for (const page of pages) for (const node of page.nodes) {
|
||||||
|
const priorPage = nodePages.get(node.id);
|
||||||
|
if (priorPage !== undefined) throw new Error(`Duplicate semantic node ID ${node.id} across pages ${priorPage} and ${page.id}`);
|
||||||
|
nodePages.set(node.id, page.id);
|
||||||
|
}
|
||||||
|
const extensions = root ? mergePreservation(jsonObject(root, "data-extensions"), {
|
||||||
|
host: root.getAttribute("host") ?? undefined,
|
||||||
|
attributes: attrs(root, ROOT_KNOWN),
|
||||||
|
childXml: Array.from(root.children).filter((child) => child.tagName !== "diagram").map(serializeElement),
|
||||||
|
}) : undefined;
|
||||||
|
const ir: DiagramIRV2 = {
|
||||||
|
version: 2,
|
||||||
|
...(root?.getAttribute("data-title") ? { title: root.getAttribute("data-title")! } : {}),
|
||||||
|
pages,
|
||||||
|
...(root ? (jsonObject(root, "data-provenance") ? { provenance: jsonObject(root, "data-provenance") } : {}) : {}),
|
||||||
|
...(root ? (jsonObject(root, "data-assets") ? { assets: jsonObject(root, "data-assets") } : {}) : {}),
|
||||||
|
...(root?.getAttribute("data-theme") ? { theme: root.getAttribute("data-theme")! } : {}),
|
||||||
|
...(root ? (jsonObject(root, "data-properties") ? { properties: jsonObject(root, "data-properties") } : {}) : {}),
|
||||||
|
...(extensions ? { extensions } : {}),
|
||||||
|
};
|
||||||
|
validateDiagramIR(ir);
|
||||||
|
return { ir, lossReport: report };
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { lstatSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { basename, dirname, extname, resolve } from "node:path";
|
||||||
|
import * as yaml from "js-yaml";
|
||||||
|
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
|
||||||
|
import { normalizeDiagramIR, validateDiagramIR } from "../../model/diagram-ir.js";
|
||||||
|
import { importDrawioToIR } from "./import-drawio.js";
|
||||||
|
|
||||||
|
export interface LifecycleActionOptions { spec?: string; base?: string; strict?: boolean; prune?: boolean; dryRun?: boolean; fail?: string; views?: string; kind?: string; property?: string[]; from?: string; to?: string }
|
||||||
|
|
||||||
|
export function loadStructured(path: string): unknown { return yaml.load(readFileSync(path, "utf8"), { schema: yaml.JSON_SCHEMA }); }
|
||||||
|
export function loadIR(path: string): DiagramIRV2 { return /\.(drawio|xml)$/i.test(path) ? importDrawioToIR(path).ir : normalizeDiagramIR(validateDiagramIR(loadStructured(path))); }
|
||||||
|
export function structuredText(value: unknown, path: string): string { return extname(path).toLowerCase() === ".json" ? `${JSON.stringify(value, null, 2)}\n` : yaml.dump(value, { noRefs: true, sortKeys: false, lineWidth: 120 }); }
|
||||||
|
|
||||||
|
function canonicalExisting(path: string): string {
|
||||||
|
const absolute = resolve(path);
|
||||||
|
try { return realpathSync(absolute); } catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return absolute;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertOutputSafe(path: string, inputs: string[] = []): string {
|
||||||
|
const output = resolve(path);
|
||||||
|
try { if (lstatSync(output).isSymbolicLink()) throw new Error("Refusing to overwrite a symbolic link"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||||
|
const canonicalOutput = canonicalExisting(output);
|
||||||
|
if (inputs.some((input) => canonicalExisting(input) === canonicalOutput)) throw new Error("Refusing to overwrite an input path (including traversal aliases)");
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function atomicWrite(path: string, content: string, inputs: string[] = []): string {
|
||||||
|
const output = assertOutputSafe(path, inputs);
|
||||||
|
const temporary = resolve(dirname(output), `.${basename(output)}.${process.pid}.${Date.now()}.tmp`);
|
||||||
|
try { writeFileSync(temporary, content, { encoding: "utf8", flag: "wx" }); renameSync(temporary, output); } finally { rmSync(temporary, { force: true }); }
|
||||||
|
return output;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 <primary>/);
|
||||||
|
assert.match(html, /catalog/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doctor reports optional backend availability without launching commands", () => {
|
||||||
|
let launches = 0;
|
||||||
|
const report = doctorReport({ path: "/definitely/missing", onLaunch: () => launches++ });
|
||||||
|
assert.equal(launches, 0);
|
||||||
|
assert.equal(report.networkRequired, false);
|
||||||
|
assert.equal(report.backends.drawio.status, "missing");
|
||||||
|
assert.equal(report.backends.graphviz.mandatory, false);
|
||||||
|
assert.equal(report.capabilities.semanticLifecycle, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("story fails closed on ambiguous multi-page node IDs", () => {
|
||||||
|
const ambiguous: DiagramIRV2 = {
|
||||||
|
version: 2,
|
||||||
|
pages: [
|
||||||
|
{ id: "one", title: "One", nodes: [{ id: "shared", label: "First" }], edges: [] },
|
||||||
|
{ id: "two", title: "Two", nodes: [{ id: "shared", label: "Second" }], edges: [] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.throws(() => createStoryHtml(ambiguous), /ambiguous.*shared.*one.*two/i);
|
||||||
|
});
|
||||||
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); }
|
||||||
|
function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; if (value && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`; } return JSON.stringify(value); }
|
||||||
|
|
||||||
|
export interface StoryOptions { title?: string; scenario?: { failed: string; impacted: string[] } }
|
||||||
|
|
||||||
|
export function createStoryHtml(ir: DiagramIRV2, options: StoryOptions = {}): string {
|
||||||
|
validateDiagramIR(ir);
|
||||||
|
const title = options.title ?? ir.title ?? "Architecture Story";
|
||||||
|
const pagesByNodeId = new Map<string, string[]>();
|
||||||
|
for (const page of ir.pages) for (const node of page.nodes) {
|
||||||
|
pagesByNodeId.set(node.id, [...(pagesByNodeId.get(node.id) ?? []), page.id]);
|
||||||
|
}
|
||||||
|
for (const [id, pageIds] of pagesByNodeId) {
|
||||||
|
if (pageIds.length > 1) throw new Error(`Ambiguous story node ID ${id} appears on pages ${pageIds.join(", ")}`);
|
||||||
|
}
|
||||||
|
const nodes = ir.pages.flatMap((page) => page.nodes);
|
||||||
|
const edges = ir.pages.flatMap((page) => page.edges);
|
||||||
|
const positions = new Map(nodes.map((node, index) => [node.id, { x: node.geometry?.x ?? 60 + (index % 4) * 220, y: node.geometry?.y ?? 70 + Math.floor(index / 4) * 130, width: node.geometry?.width ?? 160, height: node.geometry?.height ?? 70 }]));
|
||||||
|
const failed = options.scenario?.failed; const impacted = new Set(options.scenario?.impacted ?? []);
|
||||||
|
const edgeSvg = edges.map((edge) => { const a = positions.get(edge.source); const b = positions.get(edge.target); if (!a || !b) return ""; return `<line x1="${a.x + a.width}" y1="${a.y + a.height / 2}" x2="${b.x}" y2="${b.y + b.height / 2}" marker-end="url(#arrow)"/><text x="${(a.x + a.width + b.x) / 2}" y="${(a.y + b.y) / 2}">${escapeHtml(edge.label ?? "")}</text>`; }).join("");
|
||||||
|
const steps = nodes.map((node) => ({ id: node.id, label: node.label, detail: `${node.kind ?? "component"}${node.properties?.owner ? ` · owner: ${String(node.properties.owner)}` : ""}${node.provenance ? ` · provenance: ${stableJson(node.provenance)}` : ""}` }));
|
||||||
|
const nodeSvg = nodes.map((node) => { const p = positions.get(node.id)!; const status = node.id === failed ? "failed" : impacted.has(node.id) ? "impacted" : "normal"; return `<g class="node ${status}" data-id="${escapeHtml(node.id)}" data-status="${status}" tabindex="0" role="button" aria-label="${escapeHtml(`${node.label}, ${node.kind ?? "component"}`)}"><rect x="${p.x}" y="${p.y}" width="${p.width}" height="${p.height}"/><text x="${p.x + p.width / 2}" y="${p.y + p.height / 2}" text-anchor="middle">${escapeHtml(node.label)}</text></g>`; }).join("");
|
||||||
|
const alternatives = steps.map((step) => `<li><strong>${escapeHtml(step.label)}</strong> — ${escapeHtml(step.detail)}</li>`).join("") + edges.map((edge) => `<li>${escapeHtml(edge.source)} → ${escapeHtml(edge.target)}${edge.label ? ` — ${escapeHtml(edge.label)}` : ""}</li>`).join("");
|
||||||
|
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)) };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user