From db4129168b5e8f874938c2efd3c78ad80325324b Mon Sep 17 00:00:00 2001 From: Jarvis Jr Hermes Date: Thu, 3 Sep 2026 19:11:50 +0000 Subject: [PATCH] [verified] feat: add semantic diagram lifecycle --- README.md | 27 +- SKILL.md | 14 +- examples/edit-batch.yaml | 30 +++ examples/policy-rules.yaml | 8 + references/capabilities.md | 24 +- references/maintenance.md | 30 ++- references/semantic-lifecycle.md | 118 +++++++++ schemas/diagram-ir-v2.schema.json | 2 +- scripts/Taskfile.yml | 40 +++ scripts/package.json | 2 +- scripts/pnpm-lock.yaml | 10 +- scripts/src/actions/build/action.ts | 7 +- scripts/src/actions/doctor/action.ts | 2 + scripts/src/actions/edit/action.ts | 17 ++ scripts/src/actions/import/action.ts | 9 + scripts/src/actions/query/action.ts | 6 + scripts/src/actions/story/action.ts | 4 + scripts/src/actions/sync/action.ts | 10 + scripts/src/actions/test/action.ts | 8 + scripts/src/actions/views/action.ts | 16 ++ scripts/src/actions/what-if/action.ts | 3 + scripts/src/authoring/ir-to-drawio.ts | 103 +++++--- scripts/src/cli/commands.test.ts | 9 + scripts/src/cli/commands.ts | 50 +++- scripts/src/cli/semantic-lifecycle.test.ts | 108 ++++++++ scripts/src/model/diagram-ir.ts | 2 +- .../semantic-lifecycle/analysis.test.ts | 71 ++++++ .../services/semantic-lifecycle/analysis.ts | 133 ++++++++++ .../semantic-lifecycle/edit-batch.test.ts | 53 ++++ .../services/semantic-lifecycle/edit-batch.ts | 95 +++++++ .../semantic-lifecycle/import-drawio.test.ts | 101 ++++++++ .../semantic-lifecycle/import-drawio.ts | 237 ++++++++++++++++++ .../semantic-lifecycle/lifecycle-io.ts | 35 +++ .../semantic-lifecycle/publishing.test.ts | 46 ++++ .../services/semantic-lifecycle/publishing.ts | 49 ++++ .../services/semantic-lifecycle/sync.test.ts | 92 +++++++ .../src/services/semantic-lifecycle/sync.ts | 113 +++++++++ 37 files changed, 1626 insertions(+), 58 deletions(-) create mode 100644 examples/edit-batch.yaml create mode 100644 examples/policy-rules.yaml create mode 100644 references/semantic-lifecycle.md create mode 100644 scripts/src/actions/doctor/action.ts create mode 100644 scripts/src/actions/edit/action.ts create mode 100644 scripts/src/actions/import/action.ts create mode 100644 scripts/src/actions/query/action.ts create mode 100644 scripts/src/actions/story/action.ts create mode 100644 scripts/src/actions/sync/action.ts create mode 100644 scripts/src/actions/test/action.ts create mode 100644 scripts/src/actions/views/action.ts create mode 100644 scripts/src/actions/what-if/action.ts create mode 100644 scripts/src/cli/semantic-lifecycle.test.ts create mode 100644 scripts/src/services/semantic-lifecycle/analysis.test.ts create mode 100644 scripts/src/services/semantic-lifecycle/analysis.ts create mode 100644 scripts/src/services/semantic-lifecycle/edit-batch.test.ts create mode 100644 scripts/src/services/semantic-lifecycle/edit-batch.ts create mode 100644 scripts/src/services/semantic-lifecycle/import-drawio.test.ts create mode 100644 scripts/src/services/semantic-lifecycle/import-drawio.ts create mode 100644 scripts/src/services/semantic-lifecycle/lifecycle-io.ts create mode 100644 scripts/src/services/semantic-lifecycle/publishing.test.ts create mode 100644 scripts/src/services/semantic-lifecycle/publishing.ts create mode 100644 scripts/src/services/semantic-lifecycle/sync.test.ts create mode 100644 scripts/src/services/semantic-lifecycle/sync.ts diff --git a/README.md b/README.md index 5afd377..65b9d24 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # drawio-main -TypeScript tools for deterministic YAML-to-Draw.io generation, analysis, and verification. +TypeScript tools for deterministic Diagram IR-to-Draw.io generation, loss-aware round trips, transactional editing, semantic analysis, synchronization, offline publishing, and visual verification. Built with TypeScript using [`@maxgraph/core`](https://github.com/maxGraph/maxGraph) — the official TypeScript successor to mxGraph (the library draw.io is built on) — for accurate XML parsing and absolute coordinate resolution. @@ -38,8 +38,12 @@ task run -- --file="diagram.drawio" --action= [--page ] # or directly: node dist/cli/commands.js --file diagram.drawio --action [--page ] -# Build from semantic YAML Diagram IR: +# Build from semantic YAML/JSON Diagram IR: node dist/cli/commands.js --action build --file architecture.yaml --output architecture.drawio + +# Loss-aware import and transactional edit: +node dist/cli/commands.js --action import --file architecture.drawio --output architecture.yaml +node dist/cli/commands.js --action edit --file architecture.yaml --spec ../examples/edit-batch.yaml --output edited.drawio ``` Output is always YAML to stdout. @@ -62,6 +66,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 #### `summary` @@ -166,3 +188,4 @@ Standard sizes: A4 (1169×827), A3 (1654×1169), A2 (2339×1654), A1 (3307×2339 - Bare `` XML files - `…` 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 diff --git a/SKILL.md b/SKILL.md index 91950cb..fb4be6a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -13,11 +13,12 @@ compatibility: Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, an # 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 2. **Direct XML generation** — create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements 3. **Diagram analysis** — run the `drawio-tools` CLI to analyse an existing `.drawio` file: inventory shapes and connectors, validate layout quality, detect overlaps/orphans, and recommend page sizes +4. **Semantic lifecycle** — loss-aware Draw.io import, transactional stable-ID edits, linked views, semantic query/policy/what-if analysis, three-way synchronization, and self-contained offline story publishing **Reference files** (read these when using this skill): - [references/capabilities.md](./references/capabilities.md) — full list of capabilities and all CLI analysis actions an agent can execute @@ -27,6 +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/routing-best-practices.md](./references/routing-best-practices.md) — corridor planning, routing patterns, overlap verification, swimlane routing, validation workflow - [references/maintenance.md](./references/maintenance.md) — maintaining and rebuilding the skill itself +- [references/semantic-lifecycle.md](./references/semantic-lifecycle.md) — import, edit, views, query, policy, what-if, sync, story, and doctor workflows ## Available scripts @@ -48,6 +50,16 @@ task validate -- --file="/path/to/diagram.drawio" # Build a native .drawio file from a YAML Diagram IR task generate -- --file="/path/to/spec.yaml" --output="/path/to/diagram.drawio" +# Import an editable Draw.io file to loss-aware semantic IR +task import -- --file="/path/to/diagram.drawio" --output="/path/to/model.yaml" + +# Apply one atomic stable-ID edit batch +task edit -- --file="/path/to/model.yaml" --spec="/path/to/edit-batch.yaml" --output="/path/to/edited.drawio" + +# Generate linked audience views or a self-contained offline story +task views -- --file="/path/to/model.yaml" --views="executive,system,security" --output="/path/to/views.drawio" +task story -- --file="/path/to/model.yaml" --output="/path/to/story.html" + # Build TypeScript to dist/ task build ``` diff --git a/examples/edit-batch.yaml b/examples/edit-batch.yaml new file mode 100644 index 0000000..5de19c3 --- /dev/null +++ b/examples/edit-batch.yaml @@ -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 diff --git a/examples/policy-rules.yaml b/examples/policy-rules.yaml new file mode 100644 index 0000000..3f8330f --- /dev/null +++ b/examples/policy-rules.yaml @@ -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 diff --git a/references/capabilities.md b/references/capabilities.md index 430fc7d..a9abb21 100644 --- a/references/capabilities.md +++ b/references/capabilities.md @@ -25,7 +25,15 @@ See [rules-layout.md](./rules-layout.md) for mandatory connector and layout rule --- -## Capability 3 — Diagram analysis (drawio-tools CLI) +## Capability 3 — Semantic lifecycle + +The native TypeScript core supports loss-aware Draw.io import and deterministic round trips; transactional stable-ID edit batches; linked audience views; semantic query, architecture policy, and failure what-if analysis; three-way synchronization; accessible self-contained story HTML; and a non-launching doctor report. + +See [semantic-lifecycle.md](./semantic-lifecycle.md) for command contracts, safety behavior, built-in policy identifiers, and examples. + +--- + +## Capability 4 — Diagram analysis (drawio-tools CLI) A **TypeScript / Node.js** CLI tool for programmatic analysis of `.drawio` files. Entry point: `node dist/cli/commands.js` (run from the skill's `scripts/` directory), or `task run -- --file=… --action=…`. @@ -49,6 +57,20 @@ Always prints YAML to stdout. Exit code `0` on success, `1` on error. |---|---|---| | `build` | YAML Diagram IR | Validate and convert a YAML specification to native `.drawio`; requires `--output` | +#### Semantic lifecycle + +| Action | Scope | Description | +|---|---|---| +| `import` | Bare or wrapped Draw.io | Preserve semantic and unknown XML information in validated IR v2; requires YAML/JSON `--output` | +| `edit` | Diagram IR v2 | Apply one atomic stable-ID batch from `--spec`; supports `--dry-run` and Draw.io/YAML/JSON output | +| `views` | Diagram IR v2 | Project linked `executive`, `system`, `deployment`, `dataflow`, and `security` pages | +| `query` | Diagram IR v2 | Filter by `--kind`/`--property`, or find a deterministic path with `--from` and `--to` | +| `test` | Diagram IR v2 | Execute built-in architecture policy rules; `--strict` treats warnings as failures | +| `what-if` | Diagram IR v2 | Calculate outgoing impact from `--fail`, honoring failure-isolating edges | +| `sync` | Base/manual/incoming IR | Three-way synchronization with explicit `--prune` and `--dry-run` | +| `story` | Diagram IR v2 | Write self-contained accessible offline HTML; optional `--fail` overlay | +| `doctor` | Local environment | Report optional backend availability without launching processes or requiring a model file | + #### Inventory | Action | Scope | Description | diff --git a/references/maintenance.md b/references/maintenance.md index 7a300fe..b6c425d 100644 --- a/references/maintenance.md +++ b/references/maintenance.md @@ -89,13 +89,20 @@ task build # tsc → dist/ src/ ├── cli/ │ └── commands.ts # parseArgs dispatcher → dynamic action imports +├── authoring/ +│ └── ir-to-drawio.ts # deterministic IR v1/v2 serializer + preservation envelopes +├── model/ +│ └── diagram-ir.ts # canonical types, normalization, runtime validation ├── services/ │ ├── drawio-parser/ │ │ ├── parser.ts # parseAllPages() / parseDiagram() — Shape, Edge, ParsedPage │ │ └── page-summary.ts # buildPageSummary() — shared per-page serialisation helper -│ └── hierarchy-builder/ -│ └── hierarchy-builder.ts # buildHierarchy() — shared BFS depth map + containment tree +│ ├── hierarchy-builder/ +│ │ └── hierarchy-builder.ts # buildHierarchy() — shared BFS depth map + containment tree +│ └── semantic-lifecycle/ # import, edit, views/query/policy/what-if, sync, story, atomic I/O └── actions/ + ├── build|import|edit|views|query|test|what-if|sync|story|doctor/ + │ # authoring and semantic lifecycle actions ├── summary/ # all pages inventory ├── page-summary/ # single page inventory (uses --page) ├── page-hierarchy/ # containment tree from parentId @@ -109,7 +116,7 @@ src/ └── validate/ # MANDATORY final gate — XML well-formedness + maxGraph compile + sanity check ``` -Each action exports `run(filePath: string, pageIndex?: number): Record`. +Each action exports `run(filePath, pageIndex?, outputPath?, options?): Record`. Analysis actions may ignore lifecycle-only parameters. File-producing actions must require `--output` and use the shared atomic writer. ### Key modules @@ -129,7 +136,9 @@ Each action exports `run(filePath: string, pageIndex?: number): Record Promise> = { + "build": () => import("../actions/build/action.js"), "summary": () => import("../actions/summary/action.js"), "page-summary": () => import("../actions/page-summary/action.js"), "page-hierarchy": () => import("../actions/page-hierarchy/action.js"), @@ -173,6 +183,16 @@ const ACTIONS: Record Promise> = { "page-recommendations": () => import("../actions/page-recommendations/action.js"), "page-hierarchy-full": () => import("../actions/page-hierarchy-full/action.js"), "page-negative-space-summary":() => import("../actions/page-negative-space-summary/action.js"), - "validate": () => import("../actions/validate/action.js"), + "validate": () => import("../actions/validate/action.js"), + "quality": () => import("../actions/quality/action.js"), + "import": () => import("../actions/import/action.js"), + "edit": () => import("../actions/edit/action.js"), + "views": () => import("../actions/views/action.js"), + "query": () => import("../actions/query/action.js"), + "test": () => import("../actions/test/action.js"), + "what-if": () => import("../actions/what-if/action.js"), + "sync": () => import("../actions/sync/action.js"), + "story": () => import("../actions/story/action.js"), + "doctor": () => import("../actions/doctor/action.js"), }; ``` diff --git a/references/semantic-lifecycle.md b/references/semantic-lifecycle.md new file mode 100644 index 0000000..ff38be2 --- /dev/null +++ b/references/semantic-lifecycle.md @@ -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. diff --git a/schemas/diagram-ir-v2.schema.json b/schemas/diagram-ir-v2.schema.json index 67595c2..4c95610 100644 --- a/schemas/diagram-ir-v2.schema.json +++ b/schemas/diagram-ir-v2.schema.json @@ -22,7 +22,7 @@ "$defs": { "safeId": { "type": "string", - "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$", + "pattern": "^[^\\s\\\"'<>]+$", "not": { "enum": ["0", "1"] } }, "point": { diff --git a/scripts/Taskfile.yml b/scripts/Taskfile.yml index 4e898bf..2f5a8ea 100644 --- a/scripts/Taskfile.yml +++ b/scripts/Taskfile.yml @@ -38,6 +38,46 @@ tasks: CLI_ARGS: "--action=build {{ .CLI_ARGS }}" silent: true + import: + desc: Loss-aware Draw.io import to Diagram IR v2; requires --file and --output. + cmds: + - task: cli:run + vars: + CLI_ARGS: "--action=import {{ .CLI_ARGS }}" + silent: true + + edit: + desc: Apply an atomic stable-ID edit batch; requires --file and --spec. + cmds: + - task: cli:run + vars: + CLI_ARGS: "--action=edit {{ .CLI_ARGS }}" + silent: true + + views: + desc: Project linked audience views to a multi-page Draw.io file. + cmds: + - task: cli:run + vars: + CLI_ARGS: "--action=views {{ .CLI_ARGS }}" + silent: true + + sync: + desc: Three-way stable-ID synchronization; requires --base, --file, and --spec. + cmds: + - task: cli:run + vars: + CLI_ARGS: "--action=sync {{ .CLI_ARGS }}" + silent: true + + story: + desc: Publish a self-contained accessible offline architecture story. + cmds: + - task: cli:run + vars: + CLI_ARGS: "--action=story {{ .CLI_ARGS }}" + silent: true + validate: desc: | Validate a .drawio file (XML well-formedness + required structure). diff --git a/scripts/package.json b/scripts/package.json index 627aa00..ba3d991 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@maxgraph/core": "^0.23.0", - "js-yaml": "^4.1.0", + "js-yaml": "^4.3.1", "jsdom": "^25.0.0", "pako": "^2.1.0" }, diff --git a/scripts/pnpm-lock.yaml b/scripts/pnpm-lock.yaml index 7c75cae..b494285 100644 --- a/scripts/pnpm-lock.yaml +++ b/scripts/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^0.23.0 version: 0.23.0 js-yaml: - specifier: ^4.1.0 - version: 4.3.0 + specifier: ^4.3.1 + version: 4.3.1 jsdom: specifier: ^25.0.0 version: 25.0.1 @@ -373,8 +373,8 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsdom@25.0.1: @@ -779,7 +779,7 @@ snapshots: is-potential-custom-element-name@1.0.1: {} - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 diff --git a/scripts/src/actions/build/action.ts b/scripts/src/actions/build/action.ts index a6e3cc9..10cd407 100644 --- a/scripts/src/actions/build/action.ts +++ b/scripts/src/actions/build/action.ts @@ -1,9 +1,9 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { readFileSync } from "node:fs"; import * as yaml from "js-yaml"; import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js"; import { normalizeDiagramIR, validateDiagramIR } from "../../model/diagram-ir.js"; +import { atomicWrite } from "../../services/semantic-lifecycle/lifecycle-io.js"; export function run(filePath: string, _pageIndex = 0, outputPath?: string): Record { if (!outputPath) throw new Error("build requires --output "); @@ -11,8 +11,7 @@ export function run(filePath: string, _pageIndex = 0, outputPath?: string): Reco const ir = validateDiagramIR(parsed); const normalized = normalizeDiagramIR(ir); const xml = diagramIRToDrawio(normalized); - const resolvedOutput = resolve(outputPath); - writeFileSync(resolvedOutput, xml, "utf8"); + const resolvedOutput = atomicWrite(outputPath, xml, [filePath]); return { action: "build", file: filePath, diff --git a/scripts/src/actions/doctor/action.ts b/scripts/src/actions/doctor/action.ts new file mode 100644 index 0000000..2de1f80 --- /dev/null +++ b/scripts/src/actions/doctor/action.ts @@ -0,0 +1,2 @@ +import { doctorReport } from "../../services/semantic-lifecycle/publishing.js"; +export function run(_filePath = "", _page = 0, _output?: string): Record { return { action: "doctor", ...doctorReport() }; } diff --git a/scripts/src/actions/edit/action.ts b/scripts/src/actions/edit/action.ts new file mode 100644 index 0000000..1dfb374 --- /dev/null +++ b/scripts/src/actions/edit/action.ts @@ -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 { + if (!options.spec) throw new Error("edit requires --spec "); + 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 }; +} diff --git a/scripts/src/actions/import/action.ts b/scripts/src/actions/import/action.ts new file mode 100644 index 0000000..7ec434e --- /dev/null +++ b/scripts/src/actions/import/action.ts @@ -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 { + if (!outputPath) throw new Error("import requires --output "); + 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 }; +} diff --git a/scripts/src/actions/query/action.ts b/scripts/src/actions/query/action.ts new file mode 100644 index 0000000..e8de805 --- /dev/null +++ b/scripts/src/actions/query/action.ts @@ -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 { + 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 }) }; +} diff --git a/scripts/src/actions/story/action.ts b/scripts/src/actions/story/action.ts new file mode 100644 index 0000000..485c92f --- /dev/null +++ b/scripts/src/actions/story/action.ts @@ -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 { if (!outputPath) throw new Error("story requires --output "); 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 }; } diff --git a/scripts/src/actions/sync/action.ts b/scripts/src/actions/sync/action.ts new file mode 100644 index 0000000..832b4a9 --- /dev/null +++ b/scripts/src/actions/sync/action.ts @@ -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 { + if (!options.base || !options.spec) throw new Error("sync requires --base and --spec "); 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 }; +} diff --git a/scripts/src/actions/test/action.ts b/scripts/src/actions/test/action.ts new file mode 100644 index 0000000..6c5c56a --- /dev/null +++ b/scripts/src/actions/test/action.ts @@ -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 { + 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) }; +} diff --git a/scripts/src/actions/views/action.ts b/scripts/src/actions/views/action.ts new file mode 100644 index 0000000..66d67ad --- /dev/null +++ b/scripts/src/actions/views/action.ts @@ -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 { + if (!outputPath) throw new Error("views requires --output "); + const source = loadIR(filePath); + const names = options.views?.split(",").filter(Boolean) as ViewName[] | undefined; + const allowed = new Set(["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 })) }; +} diff --git a/scripts/src/actions/what-if/action.ts b/scripts/src/actions/what-if/action.ts new file mode 100644 index 0000000..eb34e63 --- /dev/null +++ b/scripts/src/actions/what-if/action.ts @@ -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 { if (!options.fail) throw new Error("what-if requires --fail "); return { action: "what-if", ...simulateFailure(loadIR(filePath), options.fail) }; } diff --git a/scripts/src/authoring/ir-to-drawio.ts b/scripts/src/authoring/ir-to-drawio.ts index 81378fd..b0408ed 100644 --- a/scripts/src/authoring/ir-to-drawio.ts +++ b/scripts/src/authoring/ir-to-drawio.ts @@ -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_EDGE_STYLE = "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;"; +type Preservation = { + host?: string; + attributes?: Record; + holderTag?: string; + holderAttributes?: Record; + childXml?: string[]; + geometryAttributes?: Record; + geometryChildXml?: string[]; + styleAbsent?: boolean; + modelAttributes?: Record; + modelChildXml?: string[]; + unknownCells?: string[]; +}; + function escapeXml(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); } function stableJson(value: unknown): string { @@ -26,38 +35,65 @@ function stableJson(value: unknown): string { return JSON.stringify(value); } +function semanticExtensions(value: Record | undefined): Record | 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 | undefined): Preservation { + const raw = value?.$drawio; + return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Preservation : {}; +} + function dataAttributes(values: Record): string { - return Object.entries(values) - .filter(([, value]) => value !== undefined) - .map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`) - .join(""); + return Object.entries(values).filter(([, value]) => value !== undefined).map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`).join(""); +} + +function rawAttributes(values: Record | 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 | undefined): string { + return Object.entries(values ?? {}).filter(([key]) => !key.includes(":")).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` ${escapeXml(key)}="${escapeXml(value)}"`).join(""); } function geometryForNode(node: DiagramNode, index: number, page: DiagramPage): { x: number; y: number; width: number; height: number } { if (node.geometry) return node.geometry; const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal"; - return { - x: horizontal ? 40 + index * 200 : 40, - y: horizontal ? 80 : 80 + index * 120, - width: node.width ?? 120, - height: node.height ?? 60, - }; + return { x: horizontal ? 40 + index * 200 : 40, y: horizontal ? 80 : 80 + index * 120, width: node.width ?? 120, height: node.height ?? 60 }; +} + +function wrapHolder(cell: string, id: string, label: string, ext: Record | undefined, p: Preservation): string { + if (!p.holderTag) return cell; + const metadata = dataAttributes({ kind: undefined, properties: undefined, provenance: undefined, extensions: semanticExtensions(ext) }); + return `<${p.holderTag} id="${escapeXml(id)}" label="${escapeXml(label)}"${rawAttributes(p.holderAttributes)}${metadata}>${cell}`; } function serializeNode(node: DiagramNode, index: number, page: DiagramPage): string { const geometry = geometryForNode(node, index, page); const style = node.style ?? (node.kind === "container" ? DEFAULT_CONTAINER_STYLE : DEFAULT_NODE_STYLE); const parent = node.parentId ?? "1"; - const metadata = dataAttributes({ kind: node.kind, properties: node.properties, provenance: node.provenance, extensions: node.extensions }); - return ` `; + const p = preservation(node.extensions); + const renderedLabel = p.attributes?.["semantic:label"] ?? node.label; + const metadata = dataAttributes({ kind: node.kind, properties: node.properties, provenance: node.provenance, extensions: semanticExtensions(node.extensions) }); + const geometryChildren = (p.geometryChildXml ?? []).join(""); + const relative = p.geometryAttributes?.relative === undefined ? "" : ` relative="${escapeXml(p.geometryAttributes.relative)}"`; + const geometryXml = `${geometryChildren}` : "/>"}`; + const styleAttribute = p.styleAbsent === true ? "" : ` style="${escapeXml(style)}"`; + const cell = `${geometryXml}${(p.childXml ?? []).join("")}`; + return ` ${wrapHolder(cell, node.id, renderedLabel, node.extensions, p)}`; } function serializeEdge(edge: DiagramEdge): string { - const points = edge.waypoints?.length - ? `${edge.waypoints.map((point) => ``).join("")}` - : ""; - const metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: edge.extensions }); - return ` ${points}`; + const p = preservation(edge.extensions); + const points = edge.waypoints?.length ? `${edge.waypoints.map((point) => ``).join("")}` : ""; + const metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: semanticExtensions(edge.extensions) }); + const geometryChildren = `${points}${(p.geometryChildXml ?? []).join("")}`; + const relative = p.geometryAttributes?.relative === undefined ? "1" : p.geometryAttributes.relative; + const geometry = `${geometryChildren}`; + const cell = `${geometry}${(p.childXml ?? []).join("")}`; + return ` ${wrapHolder(cell, edge.id, edge.label ?? "", edge.extensions, p)}`; } 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 points = page.edges.flatMap((edge) => edge.waypoints ?? []); - return { - width: Math.max(0, ...geometries.map((geometry) => geometry.x + geometry.width), ...points.map((point) => point.x)), - height: Math.max(0, ...geometries.map((geometry) => geometry.y + geometry.height), ...points.map((point) => point.y)), - }; + return { width: Math.max(0, ...geometries.map((geometry) => geometry.x + geometry.width), ...points.map((point) => point.x)), height: Math.max(0, ...geometries.map((geometry) => geometry.y + geometry.height), ...points.map((point) => point.y)) }; } function serializePage(input: DiagramPage): string { - const page = routePageEdges(layoutPage(input)); + const page = input.layout?.type === "manual" ? input : routePageEdges(layoutPage(input)); const nodeCells = page.nodes.map((node, index) => serializeNode(node, index, page)); const edgeCells = page.edges.map(serializeEdge); const extent = contentExtent(page); @@ -90,12 +123,18 @@ function serializePage(input: DiagramPage): string { const fit = (value: number) => Math.ceil((value + 40) / grid) * grid; const pageWidth = Math.max(page.width ?? 1169, fit(extent.width)); const pageHeight = Math.max(page.height ?? 827, fit(extent.height)); - const metadata = dataAttributes({ properties: page.properties, extensions: page.extensions }); - return ` \n \n \n \n \n${[...nodeCells, ...edgeCells].join("\n")}\n \n \n `; + const p = preservation(page.extensions); + const metadata = dataAttributes({ properties: page.properties, extensions: semanticExtensions(page.extensions) }); + const diagramChildren = (p.childXml ?? []).map((xml) => ` ${xml}`); + const modelChildren = (p.modelChildXml ?? []).map((xml) => ` ${xml}`); + const modelAttributes = p.modelAttributes ?? {}; + return ` \n${diagramChildren.length ? `${diagramChildren.join("\n")}\n` : ""} \n${modelChildren.length ? `${modelChildren.join("\n")}\n` : ""} \n \n \n${[...nodeCells, ...edgeCells, ...(p.unknownCells ?? []).map((xml) => ` ${xml}`)].join("\n")}\n \n \n `; } export function diagramIRToDrawio(ir: DiagramIR): string { const normalized = normalizeDiagramIR(ir); - const metadata = dataAttributes({ version: normalized.version, title: normalized.title, theme: normalized.theme, provenance: normalized.provenance, assets: normalized.assets, properties: normalized.properties, extensions: normalized.extensions }); - return `\n\n${normalized.pages.map(serializePage).join("\n")}\n\n`; + const p = preservation(normalized.extensions); + const metadata = dataAttributes({ version: normalized.version, title: normalized.title, theme: normalized.theme, provenance: normalized.provenance, assets: normalized.assets, properties: normalized.properties, extensions: semanticExtensions(normalized.extensions) }); + const rootChildren = (p.childXml ?? []).map((xml) => ` ${xml}`); + return `\n\n${rootChildren.length ? `${rootChildren.join("\n")}\n` : ""}${normalized.pages.map(serializePage).join("\n")}\n\n`; } diff --git a/scripts/src/cli/commands.test.ts b/scripts/src/cli/commands.test.ts index 27fa09c..82655e0 100644 --- a/scripts/src/cli/commands.test.ts +++ b/scripts/src/cli/commands.test.ts @@ -34,6 +34,15 @@ test("CLI rejects partially numeric page indexes", () => { assert.match(result.stderr, /non-negative integer/); }); +test("CLI help documents lifecycle options and file-free doctor", () => { + const result = spawnSync(process.execPath, ["--import", "tsx", CLI, "--help"], { cwd: resolve("."), encoding: "utf8" }); + assert.equal(result.status, 0, result.stdout + result.stderr); + for (const option of ["--spec", "--base", "--strict", "--prune", "--dry-run", "--fail", "--views", "--property", "--from", "--to"]) { + assert.match(result.stdout, new RegExp(option)); + } + assert.match(result.stdout, /doctor.*does not require --file/i); +}); + test("CLI validate exits non-zero when the validation summary is invalid", () => { const dir = mkdtempSync(join(tmpdir(), "drawio-cli-validate-test-")); const input = join(dir, "empty.drawio"); diff --git a/scripts/src/cli/commands.ts b/scripts/src/cli/commands.ts index 4fa162a..7cc1fc2 100644 --- a/scripts/src/cli/commands.ts +++ b/scripts/src/cli/commands.ts @@ -22,13 +22,14 @@ import { parseArgs } from "node:util"; import * as yaml from "js-yaml"; +import type { LifecycleActionOptions } from "../services/semantic-lifecycle/lifecycle-io.js"; // --------------------------------------------------------------------------- // Action registry // --------------------------------------------------------------------------- type ActionModule = { - run: (filePath: string, pageIndex?: number, outputPath?: string) => Record; + run: (filePath: string, pageIndex?: number, outputPath?: string, options?: LifecycleActionOptions) => Record; }; const ACTIONS: Record Promise> = { @@ -46,6 +47,15 @@ const ACTIONS: Record Promise> = { "page-negative-space-summary":() => import("../actions/page-negative-space-summary/action.js"), "validate": () => import("../actions/validate/action.js"), "quality": () => import("../actions/quality/action.js"), + "import": () => import("../actions/import/action.js"), + "edit": () => import("../actions/edit/action.js"), + "views": () => import("../actions/views/action.js"), + "query": () => import("../actions/query/action.js"), + "test": () => import("../actions/test/action.js"), + "what-if": () => import("../actions/what-if/action.js"), + "sync": () => import("../actions/sync/action.js"), + "story": () => import("../actions/story/action.js"), + "doctor": () => import("../actions/doctor/action.js"), }; // --------------------------------------------------------------------------- @@ -53,7 +63,7 @@ const ACTIONS: Record Promise> = { // --------------------------------------------------------------------------- async function main(): Promise { - let values: { file?: string; action?: string; page?: string; output?: string; help?: boolean }; + let values: { file?: string; action?: string; page?: string; output?: string; help?: boolean; spec?: string; base?: string; strict?: boolean; prune?: boolean; "dry-run"?: boolean; fail?: string; views?: string; kind?: string; property?: string[]; from?: string; to?: string }; try { ({ values } = parseArgs({ args: process.argv.slice(2), @@ -62,6 +72,17 @@ async function main(): Promise { action: { type: "string", short: "a" }, page: { type: "string", short: "p" }, output: { type: "string", short: "o" }, + spec: { type: "string" }, + base: { type: "string" }, + strict: { type: "boolean" }, + prune: { type: "boolean" }, + "dry-run": { type: "boolean" }, + fail: { type: "string" }, + views: { type: "string" }, + kind: { type: "string" }, + property: { type: "string", multiple: true }, + from: { type: "string" }, + to: { type: "string" }, help: { type: "boolean", short: "h" }, }, strict: true, @@ -74,11 +95,25 @@ async function main(): Promise { if (values.help) { console.log(`drawio-tools --file --action [--page ] [--output ] +Lifecycle options: + --spec Edit batch, policy list, or incoming sync model + --base 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 Failed node for what-if or story overlay + --views executive,system,deployment,dataflow,security + --kind Query node kind + --property k=v Repeatable exact property query + --from Directed path start (use with --to) + --to Directed path destination (use with --from) + +Doctor does not require --file. Actions: ${Object.keys(ACTIONS).join(", ")}`); process.exit(0); } - if (!values.file) { + if (!values.file && values.action !== "doctor") { printError("Missing required argument: --file"); process.exit(1); } @@ -88,7 +123,7 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`); } const actionName = values.action; - const filePath = values.file; + const filePath = values.file ?? ""; // Parse optional --page argument (0-based index, default 0) let pageIndex = 0; @@ -113,7 +148,11 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`); try { const mod = await ACTIONS[actionName](); - const result = mod.run(filePath, pageIndex, values.output); + const result = mod.run(filePath, pageIndex, values.output, { + spec: values.spec, base: values.base, strict: values.strict, prune: values.prune, + dryRun: values["dry-run"], fail: values.fail, views: values.views, kind: values.kind, + property: values.property, from: values.from, to: values.to, + }); process.stdout.write( yaml.dump(result, { noRefs: true, sortKeys: false, lineWidth: 120 }) ); @@ -121,6 +160,7 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`); if (actionName === "validate" && summary && typeof summary === "object" && (summary as Record).valid === false) { process.exitCode = 1; } + if (result.failed === true) process.exitCode = 1; } catch (err) { const errorOutput = { error: true, diff --git a/scripts/src/cli/semantic-lifecycle.test.ts b/scripts/src/cli/semantic-lifecycle.test.ts new file mode 100644 index 0000000..cf975fe --- /dev/null +++ b/scripts/src/cli/semantic-lifecycle.test.ts @@ -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, ``, "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[^]*`, "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 }); } +}); diff --git a/scripts/src/model/diagram-ir.ts b/scripts/src/model/diagram-ir.ts index 62213aa..8c1fa36 100644 --- a/scripts/src/model/diagram-ir.ts +++ b/scripts/src/model/diagram-ir.ts @@ -79,7 +79,7 @@ export interface DiagramIRV2 { export type DiagramIR = DiagramIRV1 | DiagramIRV2; -const SAFE_ID = /^[A-Za-z][A-Za-z0-9_.:-]*$/; +const SAFE_ID = /^[^\s"'<>]+$/; const RESERVED_IDS = new Set(["0", "1"]); const LAYOUT_TYPES = new Set(["linear", "layered", "tree", "grid", "manual"]); const ROOT_V1_FIELDS = new Set(["version", "title", "direction", "nodes", "edges"]); diff --git a/scripts/src/services/semantic-lifecycle/analysis.test.ts b/scripts/src/services/semantic-lifecycle/analysis.test.ts new file mode 100644 index 0000000..51215c1 --- /dev/null +++ b/scripts/src/services/semantic-lifecycle/analysis.test.ts @@ -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); +}); diff --git a/scripts/src/services/semantic-lifecycle/analysis.ts b/scripts/src/services/semantic-lifecycle/analysis.ts new file mode 100644 index 0000000..eef03df --- /dev/null +++ b/scripts/src/services/semantic-lifecycle/analysis.ts @@ -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; 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(); + 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): { 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(); + 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([[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(); + for (const edge of edges) adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target].sort()); + const visiting = new Set(); const done = new Set(); 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 } { + const { nodes, edges } = flatten(ir); if (!nodes.some((node) => node.id === failed)) throw new Error(`Unknown node: ${failed}`); + const queue = [failed]; const paths: Record = { [failed]: [failed] }; const impacted = new Set(); + 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 }; +} diff --git a/scripts/src/services/semantic-lifecycle/edit-batch.test.ts b/scripts/src/services/semantic-lifecycle/edit-batch.test.ts new file mode 100644 index 0000000..b8aa44c --- /dev/null +++ b/scripts/src/services/semantic-lifecycle/edit-batch.test.ts @@ -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"]); +}); diff --git a/scripts/src/services/semantic-lifecycle/edit-batch.ts b/scripts/src/services/semantic-lifecycle/edit-batch.ts new file mode 100644 index 0000000..081d72a --- /dev/null +++ b/scripts/src/services/semantic-lifecycle/edit-batch.ts @@ -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> } + | { 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(); + 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 }; +} diff --git a/scripts/src/services/semantic-lifecycle/import-drawio.test.ts b/scripts/src/services/semantic-lifecycle/import-drawio.test.ts new file mode 100644 index 0000000..abaa9a9 --- /dev/null +++ b/scripts/src/services/semantic-lifecycle/import-drawio.test.ts @@ -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, ``, "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, / 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 = ``; + 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 = ``; + 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 = ``; + 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 = ``; + 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 = ``; + const imported = importDrawioToIR(xml, { sourceIsXml: true }); + assert.equal(imported.lossReport.lossy, false); + const rebuilt = diagramIRToDrawio(imported.ir); + assert.match(rebuilt, //); + assert.match(rebuilt, //); + assert.match(rebuilt, //); + 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 = ``; + 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 = ``; + const result = importDrawioToIR(xml, { sourceIsXml: true }); + assert.match(JSON.stringify(result.ir.pages[0].nodes[0].extensions), /data-properties.*not-json/); +}); diff --git a/scripts/src/services/semantic-lifecycle/import-drawio.ts b/scripts/src/services/semantic-lifecycle/import-drawio.ts new file mode 100644 index 0000000..3989946 --- /dev/null +++ b/scripts/src/services/semantic-lifecycle/import-drawio.ts @@ -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; + holderTag?: string; + holderAttributes?: Record; + childXml?: string[]; + geometryAttributes?: Record; + geometryChildXml?: string[]; + styleAbsent?: boolean; + modelAttributes?: Record; + 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 (/", { 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("", { contentType: "text/xml" }).window.XMLSerializer; + return new XMLSerializer().serializeToString(element); +} + +function attrs(element: Element, known: Set): Record | undefined { + const result: Record = {}; + 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): Record | undefined { + const result: Record = {}; + 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 | 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; + } 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 | undefined, envelope: PreservationEnvelope): Record | 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 "); + 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 (/^ child.tagName === "diagram"); + if (!diagrams.length) throw new Error("No 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(); + 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 }; +} diff --git a/scripts/src/services/semantic-lifecycle/lifecycle-io.ts b/scripts/src/services/semantic-lifecycle/lifecycle-io.ts new file mode 100644 index 0000000..9fb2a46 --- /dev/null +++ b/scripts/src/services/semantic-lifecycle/lifecycle-io.ts @@ -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; +} diff --git a/scripts/src/services/semantic-lifecycle/publishing.test.ts b/scripts/src/services/semantic-lifecycle/publishing.test.ts new file mode 100644 index 0000000..97b0e1f --- /dev/null +++ b/scripts/src/services/semantic-lifecycle/publishing.test.ts @@ -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 ", 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, /^/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, /]+src=|]+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); +}); diff --git a/scripts/src/services/semantic-lifecycle/publishing.ts b/scripts/src/services/semantic-lifecycle/publishing.ts new file mode 100644 index 0000000..c50acb8 --- /dev/null +++ b/scripts/src/services/semantic-lifecycle/publishing.ts @@ -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; 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(); + 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 `${escapeHtml(edge.label ?? "")}`; }).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 `${escapeHtml(node.label)}`; }).join(""); + const alternatives = steps.map((step) => `
  • ${escapeHtml(step.label)} — ${escapeHtml(step.detail)}
  • `).join("") + edges.map((edge) => `
  • ${escapeHtml(edge.source)} → ${escapeHtml(edge.target)}${edge.label ? ` — ${escapeHtml(edge.label)}` : ""}
  • `).join(""); + const data = stableJson(steps).replaceAll("${escapeHtml(title)}

    ${escapeHtml(title)}

    ${nodes.length} components and ${edges.length} relationships.

    Overview
    ${escapeHtml(title)}Architecture containing ${nodes.length} components and ${edges.length} directed relationships.${edgeSvg}${nodeSvg}
    Text alternative
      ${alternatives}
    `; +} + +export interface DoctorBackend { status: "available" | "missing"; path?: string; mandatory: false } +export interface DoctorReport { networkRequired: false; backends: Record; 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 } }; +} diff --git a/scripts/src/services/semantic-lifecycle/sync.test.ts b/scripts/src/services/semantic-lifecycle/sync.test.ts new file mode 100644 index 0000000..a2c0817 --- /dev/null +++ b/scripts/src/services/semantic-lifecycle/sync.test.ts @@ -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")); +}); diff --git a/scripts/src/services/semantic-lifecycle/sync.ts b/scripts/src/services/semantic-lifecycle/sync.ts new file mode 100644 index 0000000..484cc1f --- /dev/null +++ b/scripts/src/services/semantic-lifecycle/sync.ts @@ -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(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(base.id, node.id, field, baseNode, manualNode, incomingNode, conflicts); + if (value === undefined) delete node[field]; else (node as unknown as Record)[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(base.id, edge.id, field, baseEdge, manualEdge, incomingEdge, conflicts); + if (value === undefined) delete edge[field]; else (edge as unknown as Record)[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)) }; +}