diff --git a/README.md b/README.md index 5afd377..f9b808c 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,38 @@ See `examples/platform-v2.yaml` and `schemas/diagram-ir-v2.schema.json`. --- +### Semantic lifecycle + +| Action | Purpose | Key options | +|---|---|---| +| `import` | Loss-aware Draw.io to Diagram IR v2 | `--output model.yaml|json` | +| `edit` | Atomic stable-ID edit batch | `--spec`, `--output`, `--dry-run` | +| `views` | Linked executive/system/deployment/dataflow/security views | `--views`, `--output` | +| `query` | Kind/property filtering and deterministic shortest paths | `--kind`, `--property`, `--from`, `--to` | +| `test` | Seven built-in architecture policies | `--spec`, `--strict` | +| `what-if` | Failure propagation with isolation boundaries | `--fail` | +| `sync` | Three-way synchronization preserving manual presentation | `--base`, `--spec`, `--output`, `--prune`, `--dry-run` | +| `story` | Self-contained accessible offline HTML | `--output`, optional `--fail` | +| `doctor` | Report optional backend availability without launching it | no `--file` required | + +Commands fail closed on ambiguous identities, invalid view/policy names, input/output aliases, and malformed operations. See [`references/semantic-lifecycle.md`](references/semantic-lifecycle.md) for complete contracts and examples. + +--- + +### Native service libraries + +The combined package also exposes tested TypeScript service APIs for capabilities that are not registered as CLI actions: + +| Family | Implemented native services | +|---|---| +| Safe source importers | Python, JavaScript/TypeScript, Go, Rust, Terraform, Kubernetes, Docker Compose, SQL, OpenAPI, and CI job DAG subsets | +| Toolbox | Five themes, generic offline shape search, Mermaid and Markdown reverse transforms, semantic diff, strict relabeling, and accessible metric heatmaps | +| Specialized profiles | C4, sequence, tube map, executive compression, runbook HTML, timelapse, and dependency-ordered build-up | + +These are conservative, bounded service contracts rather than claims of complete language parsers or peer-compatible command-line interfaces. See [`references/agents365-capability-coverage.md`](references/agents365-capability-coverage.md) for the strict 42-tool comparison and remaining gaps. + +--- + ### Inventory #### `summary` @@ -166,3 +202,4 @@ Standard sizes: A4 (1169×827), A3 (1654×1169), A2 (2339×1654), A1 (3307×2339 - Bare `` 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..695c130 100644 --- a/SKILL.md +++ b/SKILL.md @@ -13,11 +13,15 @@ compatibility: Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, an # Draw.io Diagram Skill -This skill covers three capabilities: +This skill covers seven capability families: 1. **Deterministic YAML generation** — validate v1/v2 semantic Diagram IR and build native multi-page `.drawio` XML with stable IDs, dependency-aware layout, and obstacle-aware routing 2. **Direct XML generation** — create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements 3. **Diagram analysis** — run the `drawio-tools` CLI to analyse an existing `.drawio` file: inventory shapes and connectors, validate layout quality, detect overlaps/orphans, and recommend page sizes +4. **Semantic lifecycle** — loss-aware Draw.io import, transactional stable-ID edits, linked views, semantic query/policy/what-if analysis, three-way synchronization, and self-contained offline story publishing +5. **Safe source importers** — bounded native TypeScript subsets for Python, JavaScript/TypeScript, Go, Rust, Terraform, Kubernetes, Docker Compose, SQL, OpenAPI, and CI dependency graphs +6. **Toolbox transforms** — themes, offline generic shape search, reverse Mermaid/Markdown, semantic diff, strict relabeling, and accessible heatmaps +7. **Specialized profiles** — C4, sequence, tube map, compression, runbook, timelapse, and dependency-ordered build-up services **Reference files** (read these when using this skill): - [references/capabilities.md](./references/capabilities.md) — full list of capabilities and all CLI analysis actions an agent can execute @@ -27,6 +31,8 @@ This skill covers three capabilities: - [references/negative-space-diagram.md](./references/negative-space-diagram.md) — rules for generating negative space companion diagrams from `page-negative-space-summary` output - [references/routing-best-practices.md](./references/routing-best-practices.md) — corridor planning, routing patterns, overlap verification, swimlane routing, validation workflow - [references/maintenance.md](./references/maintenance.md) — maintaining and rebuilding the skill itself +- [references/semantic-lifecycle.md](./references/semantic-lifecycle.md) — import, edit, views, query, policy, what-if, sync, story, and doctor workflows +- [references/agents365-capability-coverage.md](./references/agents365-capability-coverage.md) — strict evidence matrix for all 42 compared peer tools, including partial and deferred scope ## Available scripts @@ -48,12 +54,24 @@ task validate -- --file="/path/to/diagram.drawio" # Build a native .drawio file from a YAML Diagram IR task generate -- --file="/path/to/spec.yaml" --output="/path/to/diagram.drawio" +# Import an editable Draw.io file to loss-aware semantic IR +task import -- --file="/path/to/diagram.drawio" --output="/path/to/model.yaml" + +# Apply one atomic stable-ID edit batch +task edit -- --file="/path/to/model.yaml" --spec="/path/to/edit-batch.yaml" --output="/path/to/edited.drawio" + +# Generate linked audience views or a self-contained offline story +task views -- --file="/path/to/model.yaml" --views="executive,system,security" --output="/path/to/views.drawio" +task story -- --file="/path/to/model.yaml" --output="/path/to/story.html" + # Build TypeScript to dist/ task build ``` See [references/capabilities.md](./references/capabilities.md) for what every action outputs. +Source importers, toolbox transforms, and specialized profiles are currently native TypeScript service APIs, not additional CLI actions. Do not invent action names for them; use the exported services or the documented lifecycle actions. + --- Generate draw.io diagrams as native `.drawio` files. Optionally export to PNG, SVG, or PDF with the diagram XML embedded (so the exported file remains editable in draw.io). 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/agents365-capability-coverage.md b/references/agents365-capability-coverage.md new file mode 100644 index 0000000..5d7b91d --- /dev/null +++ b/references/agents365-capability-coverage.md @@ -0,0 +1,77 @@ +# Agents365 capability coverage + +> Evidence basis: clean-room behavioral comparison of 42 peer tools. Peer source and bundled assets were not copied because the inspected mirror had no complete license file. + +## Reading the classifications + +- **Implemented** — full mapped peer acceptance contract is available. +- **Partial** — useful native behavior is implemented, but some peer options, adapters, or Command-Line Interface (CLI) exposure remain. +- **Optional deferred** — environment-heavy capability remains outside the mandatory offline core. +- **Rejected** — capability was deliberately excluded from this phase. + +These are strict peer-parity labels. A `partial` row can still contain substantial production-ready native functionality. + +## Aggregate + +| Classification | Count | +|---|---:| +| Implemented | 0 | +| Partial | 29 | +| Optional deferred | 11 | +| Rejected | 2 | +| **Total** | **42** | + +## Capability matrix + +| Peer tool | Classification | Native evidence | Remaining gap | +|---|---|---|---| +| `aiicons.py` | **optional-deferred** | `scripts/src/services/shape-catalog/shape-catalog.ts`
`scripts/src/services/shape-catalog/shape-catalog.test.ts` | Only an eight-entry generic offline shape catalog exists; there is no AI/LLM brand manifest, variant selection, embedding adapter, URL allowlist, or CLI action. | +| `autolayout.py` | **partial** | `scripts/src/services/layout/layout-engine.ts`
`scripts/src/services/layout/layout-engine.test.ts`
`scripts/src/actions/build/action.ts` | Deterministic native layouts exist, but there is no peer-compatible graph-JSON adapter, group-tree contract, monochrome mode, or objective TB/LR tuning action. | +| `buildup.py` | **partial** | `scripts/src/services/profiles/buildup.ts`
`scripts/src/services/profiles/buildup.test.ts` | The service computes deterministic cumulative IR frames, but it does not parse Draw.io through a buildup action, render PNG frames, publish the player HTML, encode GIF, or expose CLI availability semantics. | +| `c4.py` | **partial** | `scripts/src/services/profiles/c4.ts`
`scripts/src/services/profiles/c4.test.ts`
`scripts/src/services/profiles/index.ts` | The service validates hierarchy and creates linked C4 pages, but no C4 CLI/action or input-file adapter is registered, and canonical peer style/direction options are not fully exposed. | +| `ciimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | The service imports a bounded generic jobs/needs object, but lacks CLI registration, repository workflow discovery, triggers, runners, matrices, reusable workflows, GitLab stages, and inferred stage dependencies. | +| `composeimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | Services, depends_on, and simple named volumes are covered through a service API; links, volumes_from, long-form mounts, network grouping, conventional-file discovery, and CLI exposure remain absent. | +| `compress.py` | **partial** | `scripts/src/services/profiles/compression.ts`
`scripts/src/services/profiles/compression.test.ts` | A deterministic BFS clustering service emits summary and full IR pages, but there is no Draw.io-facing action, exact loss-aware detail-file workflow, label-propagation parity, or member-cell drill-down target contract. | +| `dbxicons.py` | **optional-deferred** | `scripts/src/services/shape-catalog/shape-catalog.ts`
clean-room 42-tool mapping audit | No licensed Databricks manifest, aliases, variants, pinned-ref embedding, refresh operation, host allowlist, or CLI action was delivered. | +| `diagram_ir.py` | **partial** | `scripts/src/model/diagram-ir.ts`
`scripts/src/services/semantic-lifecycle/import-drawio.ts`
`scripts/src/services/semantic-lifecycle/analysis.ts`
`scripts/src/services/semantic-lifecycle/sync.ts`
`scripts/src/services/semantic-lifecycle/publishing.ts` | Versioned IR, loss-aware import, views, query, policies, failure impact, sync, and story publishing exist, but articulation analysis, a unified architecture-review contract, contrast analysis, multilingual labeling, and explicit peer-IR-v1 compatibility are incomplete. | +| `diagramctl.py` | **partial** | `scripts/src/cli/commands.ts`
`scripts/src/cli/semantic-lifecycle.test.ts`
`scripts/src/actions/doctor/action.ts`
`scripts/src/actions/sync/action.ts` | Lifecycle actions are registered in the existing --action CLI, but integrated importer, profile, transform, and reverse-export services are not registered. There is no uniform peer-equivalent result envelope. | +| `diagramctl_mcp.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
clean-room 42-tool mapping audit | No optional MCP package, JSON-RPC initialization, tools/list, tools/call bridge, closed schemas, timeout handling, or MCP entrypoint exists. | +| `dockerimports.py` | **optional-deferred** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | The importer registry has Docker Compose but no Docker inspect snapshot kind, container/network/volume instance normalization, redaction contract, stdin parity, or action. | +| `drawio2mermaid.py` | **partial** | `scripts/src/services/transforms/reverse.ts`
`scripts/src/services/transforms/reverse.test.ts`
`scripts/src/services/semantic-lifecycle/import-drawio.ts` | A deterministic IR-to-Mermaid service exists, but there is no Draw.io-to-Mermaid CLI/action, shape-form mapping, direction/fence controls, or lossy-conversion report. | +| `drawio2pptx.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
clean-room 42-tool mapping audit | No optional Draw.io renderer adapter, per-page raster loop, PPTX writer, slide sizing, scale option, or structured unavailable result exists. | +| `drawiodiff.py` | **partial** | `scripts/src/services/transforms/semantic-diff.ts`
`scripts/src/services/transforms/semantic-diff.test.ts`
`scripts/src/services/semantic-lifecycle/import-drawio.ts` | The IR service classifies added, removed, changed, moved, and rerouted entities, but no CLI/action composes Draw.io import with diffing, no by-label ambiguity mode exists, and no color-coded graph output or summary diagram is emitted. | +| `drawiohtml.py` | **optional-deferred** | `scripts/src/services/semantic-lifecycle/publishing.ts`
`scripts/src/actions/story/action.ts` | Story HTML is a different semantic publisher; there is no page-to-SVG export adapter, SVG sanitizer, tabbed viewer, pan/zoom/search UI, drill-down link rewrite, or publish-viewer action. | +| `edgeports.py` | **rejected** | `scripts/src/services/authoring-router/orthogonal-router.ts`
`scripts/src/services/authoring-router/orthogonal-router.test.ts`
clean-room 42-tool mapping audit | Only routing during authoring exists. The post-import boundary-port editor, pinned-port preservation, dry-run, idempotence, and transactional Draw.io write path were not selected for this post-phase implementation. | +| `encode_drawio_url.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
`scripts/package.json` | Compression primitives are available transitively, but there is no byte-compatible URL encoder, viewer/edit modes, size policy, privacy warning, or CLI action. | +| `explain.py` | **partial** | `scripts/src/services/transforms/reverse.ts`
`scripts/src/services/transforms/reverse.test.ts` | Structured Markdown for IR pages, nodes, and flows exists as a service, but no Draw.io-facing explain action, tier/type inference, C4 context, unknown-section reporting, or output-file contract is exposed. | +| `goimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | Bounded Go import extraction exists, but it emits imported paths as library nodes rather than resolving only intra-module packages; module discovery, grouping, transitive reduction, and CLI exposure are absent. | +| `heatmap.py` | **partial** | `scripts/src/services/transforms/heatmap.ts`
`scripts/src/services/transforms/heatmap.test.ts` | The IR service validates bounded metrics and applies deterministic accessible colors with legend metadata, but it lacks CSV/JSON loading, label matching, geometry scaling, a rendered legend node, Draw.io transactional output, and CLI exposure. | +| `jsimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | A bounded scanner handles static import/export and require while avoiding tested comments and strings, but dynamic string imports, project module resolution, external exclusion, directory grouping, transitive reduction, and CLI exposure remain incomplete. | +| `k8simports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | Local object/YAML parsing, namespace-qualified identity, Service selectors, Secret references, and Secret payload redaction are tested. Ingress, ConfigMap, PVC, HPA, list-wrapper normalization, ambiguity diagnostics, icon suppression/grouping, stdin action parity, and CLI registration remain absent. | +| `openapiimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | OpenAPI operations and local component-schema references are imported through a service API, but Swagger 2 definitions, nested schema-to-schema edges, method styling, tag groups, no-schema projection, version diagnostics, and CLI exposure are missing. | +| `prdiff.py` | **optional-deferred** | `scripts/src/services/transforms/semantic-diff.ts`
`scripts/src/cli/commands.ts` | Semantic IR diff alone does not provide scoped git revision discovery, A/M/D classification, safe extraction, Draw.io rendering, artifact naming, Markdown reporting, or unavailable-render handling. | +| `pyclasses.py` | **rejected** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | The Python importer extracts imports only. Class declarations, inheritance resolution, multiple inheritance, nested module grouping, ambiguity diagnostics, and transitive reduction were not implemented or registered. | +| `pyimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | Bounded import/from extraction and dynamic-import diagnostics exist, but relative and absolute intra-project resolution, stdlib/third-party exclusion, package grouping, transitive reduction, syntax-error diagnostics, and CLI exposure are absent. | +| `raster2drawio.py` | **partial** | `scripts/src/actions/build/action.ts`
`scripts/src/authoring/ir-to-drawio.ts`
`scripts/src/services/layout/layout-engine.ts`
`scripts/src/model/diagram-ir.ts` | Generic IR build supports explicit geometry, styles, edges, and automatic layout, but there is no raster-extracted graph compatibility schema/action, x/y/w/h shorthand conversion, partial-coordinate policy, confidence/provenance convention, or extraction-warning envelope. | +| `relabel.py` | **partial** | `scripts/src/services/transforms/relabel.ts`
`scripts/src/services/transforms/relabel.test.ts` | A strict complete-map IR relabel service preserves non-label structure, but there is no extraction mode, page-name handling, UserObject traversal contract, partial-map/unmatched reporting, Draw.io transactional write, or CLI action. | +| `repair_png.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
clean-room 42-tool mapping audit | No PNG chunk validator, signature-specific repair, atomic in-place replacement, idempotence gate, version gate, or optional action exists. | +| `restyle.py` | **partial** | `scripts/src/services/themes/theme-service.ts`
`scripts/src/services/themes/theme-service.test.ts` | Five validated built-in themes and immutable IR application exist, but the peer-style palette-slot schema, user preset loader, hue/neutral color remapping, global extras, versioned JSON schema, Draw.io transactional action, and CLI exposure are incomplete. | +| `runbook.py` | **partial** | `scripts/src/services/profiles/runbook.ts`
`scripts/src/services/profiles/runbook.test.ts` | The service emits escaped self-contained interactive HTML from an explicit RunbookGraph, but it does not parse Draw.io, infer node types/start nodes/choices, report fallback selection, or expose a publish-runbook action. | +| `rustimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | A bounded subset recognizes mod and simple use roots and diagnoses macros, but crate/self/super resolution, module-file discovery, complete brace expansion, external-crate exclusion, grouping, reduction, and CLI exposure are absent. | +| `seqlayout.py` | **partial** | `scripts/src/services/profiles/sequence.ts`
`scripts/src/services/profiles/sequence.test.ts` | Participants, ordered messages, lifelines, activations, return validation, and editable geometry are implemented as a service, but notes are unsupported and no sequence schema file, input action, direction/options contract, or CLI registration exists. | +| `shapesearch.py` | **partial** | `scripts/src/services/shape-catalog/shape-catalog.ts`
`scripts/src/services/shape-catalog/shape-catalog.test.ts` | Deterministic exact/alias/fuzzy search exists for eight hand-curated generic shapes, not the licensed 10k+ palette index; compound/tag/Soundex ranking, dimensions, gzip integrity controls, expected ecosystem queries, and CLI output are missing. | +| `sqlerd.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | A narrow line-oriented subset finds simple tables and REFERENCES edges, but columns/types, PK/FK markers, quoted/schema identifiers, composite keys, schema grouping, crow's-foot styles, unsupported-syntax diagnostics, and CLI exposure are missing. | +| `svgflow.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
clean-room 42-tool mapping audit | No optional Draw.io SVG export adapter, SVG parser/sanitizer, connector detection, animation injection, reduced-motion handling, or export-flow-svg action exists. | +| `tfimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | A bounded line-oriented resource/reference subset exists, but modules, multiline/nested HCL handling, comments/string false-positive guarantees, diagnostics for dynamic expressions, cloud styles, grouping, transitive reduction, no-icons mode, and CLI exposure are incomplete. | +| `tfstate.py` | **optional-deferred** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | No Terraform show-JSON snapshot source kind, nested module traversal, count/for_each instance expansion, sensitive-value redaction, state relationship extraction, stdin parity, or optional action exists. | +| `timelapse.py` | **partial** | `scripts/src/services/profiles/timelapse.ts`
`scripts/src/services/profiles/timelapse.test.ts` | The service classifies changes across caller-supplied IR snapshots, but it has no scoped git history/archive adapter, deterministic commit sampling, importer allowlist, Draw.io frame rendering, HTML player, resource limits, or CLI action. | +| `tubemap.py` | **partial** | `scripts/src/services/profiles/tube-map.ts`
`scripts/src/services/profiles/tube-map.test.ts` | The native service provides deterministic grid-aligned octilinear routes, interchange semantics, validation, and tested obstacle avoidance, but no versioned input schema, build-tubemap action, file adapter, or CLI exposure exists. | +| `validate.py` | **partial** | `scripts/src/actions/validate/action.ts`
`scripts/src/actions/quality/action.ts`
`scripts/src/actions/page-connectors-validation/action.ts`
`scripts/src/actions/page-shape-bbox-validation/action.ts`
`scripts/src/actions/page-orphans/action.ts` | Structural compilation and several specialized quality checks exist, but findings remain fragmented rather than one stable code/severity/subject/fix schema; duplicate/reserved ID, parent, off-grid, strict-warning, JSON projection, and comparable readability-score acceptance coverage is incomplete. | + +## Architectural boundary + +The retained implementation stays in the existing TypeScript/Node.js stack with pnpm, Taskfile, `@maxgraph/core`, and the established action-based CLI. Python, Graphviz, Eclipse Layout Kernel (ELK), Model Context Protocol (MCP), browser services, network icon retrieval, and Draw.io Desktop are not mandatory dependencies. Optional adapters must report availability honestly. + +## Delivery note + +This matrix describes the combined integrated candidate and deliberate scope decisions. Gitea publication and Hermes runtime installation are separate gates and must not be inferred from this document. \ No newline at end of file diff --git a/references/capabilities.md b/references/capabilities.md index 430fc7d..1730259 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 | @@ -77,3 +99,16 @@ Always prints YAML to stdout. Exit code `0` on success, `1` on error. | `page-recommendations` | Page 0 | Smallest standard page size (A4→A3→A2→A1→custom) that fits content with 80 px margin | > For install/build instructions, source structure, and how to add new actions, see [maintenance.md](./maintenance.md). + +--- + +## Capability 5 — Native service libraries + +The package includes additional strict TypeScript APIs under `scripts/src/services/`: + +- `source-importers/` — conservative, bounded source/configuration topology extraction with diagnostics and no code execution; +- `themes/` and `shape-catalog/` — five validated themes and an offline generic shape catalog; +- `transforms/` — reverse Mermaid/Markdown, semantic diff, complete-map relabeling, and accessible bounded heatmaps; +- `profiles/` — C4, sequence, tube map, compression, runbook, timelapse, and build-up profiles. + +These services are covered by the native test suite but are not registered CLI actions. Their exact peer-relative coverage and deliberate omissions are listed in [agents365-capability-coverage.md](./agents365-capability-coverage.md). 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.test.ts b/scripts/src/actions/build/action.test.ts index 4263645..7cce73b 100644 --- a/scripts/src/actions/build/action.test.ts +++ b/scripts/src/actions/build/action.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { JSDOM } from "jsdom"; import { parseAllPages } from "../../services/drawio-parser/parser.js"; import { run } from "./action.js"; @@ -101,3 +102,45 @@ test("build serializes every v2 page and honors explicit geometry and waypoints" rmSync(dir, { recursive: true, force: true }); } }); + +test("build emits parseable XML when generic model attributes collide with fixed attributes", () => { + const dir = mkdtempSync(join(tmpdir(), "drawio-build-model-attributes-test-")); + const input = join(dir, "model-attributes.yaml"); + const output = join(dir, "model-attributes.drawio"); + try { + writeFileSync(input, `version: 2 +pages: + - id: page + title: Page + layout: { type: manual, gridSize: 20 } + extensions: + $drawio: + attributes: + model:grid: "0" + model:gridSize: "99" + model:page: "0" + model:pageWidth: "1" + model:pageHeight: "2" + model:background: "#ffffff" + nodes: + - { id: node, label: Node, geometry: { x: 0, y: 0, width: 100, height: 60 } } + edges: [] +`, "utf8"); + run(input, 0, output); + + const xml = readFileSync(output, "utf8"); + const dom = new JSDOM(""); + const document = new dom.window.DOMParser().parseFromString(xml, "text/xml"); + assert.equal(document.querySelector("parsererror"), null); + const model = document.querySelector("mxGraphModel"); + assert.ok(model); + assert.equal(model.getAttribute("grid"), "1"); + assert.equal(model.getAttribute("gridSize"), "20"); + assert.equal(model.getAttribute("page"), "1"); + assert.equal(model.getAttribute("pageWidth"), "1169"); + assert.equal(model.getAttribute("pageHeight"), "827"); + assert.equal(model.getAttribute("background"), "#ffffff"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); 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..8d783bc 100644 --- a/scripts/src/authoring/ir-to-drawio.ts +++ b/scripts/src/authoring/ir-to-drawio.ts @@ -6,14 +6,24 @@ import { layoutPage } from "../services/layout/layout-engine.js"; const DEFAULT_NODE_STYLE = "whiteSpace=wrap;html=1;rounded=0;fillColor=#f5f5f5;strokeColor=#666666;"; const DEFAULT_CONTAINER_STYLE = "swimlane;html=1;rounded=0;startSize=30;fillColor=#f5f5f5;strokeColor=#666666;"; const DEFAULT_EDGE_STYLE = "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;"; +const FIXED_MODEL_ATTRIBUTES = new Set(["grid", "gridSize", "page", "pageWidth", "pageHeight"]); + +type Preservation = { + host?: string; + attributes?: Record; + 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 +36,72 @@ 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))}"`) + 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, reserved = new Set()): string { + return Object.entries(values ?? {}) + .filter(([key]) => !prefix || key.startsWith(prefix)) + .map(([key, value]) => [prefix ? key.slice(prefix.length) : key, value] as const) + .filter(([key]) => !reserved.has(key) && /^[A-Za-z_][\w:.-]*$/.test(key)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => ` ${escapeXml(key)}="${escapeXml(value)}"`) .join(""); } +function unprefixedAttributes(values: Record | 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 +119,11 @@ function contentExtent(page: DiagramPage): { width: number; height: number } { }; const geometries = page.nodes.map((node) => resolve(node.id)).filter((geometry) => geometry !== undefined); const points = page.edges.flatMap((edge) => edge.waypoints ?? []); - return { - width: Math.max(0, ...geometries.map((geometry) => geometry.x + geometry.width), ...points.map((point) => point.x)), - height: Math.max(0, ...geometries.map((geometry) => geometry.y + geometry.height), ...points.map((point) => point.y)), - }; + return { width: Math.max(0, ...geometries.map((geometry) => geometry.x + geometry.width), ...points.map((point) => point.x)), height: Math.max(0, ...geometries.map((geometry) => geometry.y + geometry.height), ...points.map((point) => point.y)) }; } function serializePage(input: DiagramPage): string { - const page = routePageEdges(layoutPage(input)); + const page = input.layout?.type === "manual" ? input : routePageEdges(layoutPage(input)); const nodeCells = page.nodes.map((node, index) => serializeNode(node, index, page)); const edgeCells = page.edges.map(serializeEdge); const extent = contentExtent(page); @@ -90,12 +131,18 @@ function serializePage(input: DiagramPage): string { const fit = (value: number) => Math.ceil((value + 40) / grid) * grid; const pageWidth = Math.max(page.width ?? 1169, fit(extent.width)); const pageHeight = Math.max(page.height ?? 827, fit(extent.height)); - const metadata = dataAttributes({ properties: page.properties, extensions: page.extensions }); - return ` \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/profiles/buildup.test.ts b/scripts/src/services/profiles/buildup.test.ts new file mode 100644 index 0000000..e74deca --- /dev/null +++ b/scripts/src/services/profiles/buildup.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { DiagramPage } from "../../model/diagram-ir.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; +import { createBuildup } from "./buildup.js"; + +const PAGE: DiagramPage = { id: "deploy", title: "Deploy", nodes: [{ id: "db", label: "DB", provenance: { source: "infra" } }, { id: "api", label: "API" }, { id: "web", label: "Web" }], edges: [{ id: "db-api", source: "db", target: "api" }, { id: "api-web", source: "api", target: "web" }] }; + +test("buildup uses deterministic topological order and valid cumulative IR frames", () => { + const before = structuredClone(PAGE); const result = createBuildup(PAGE); + assert.deepEqual(result, createBuildup(PAGE)); assert.deepEqual(PAGE, before); assert.deepEqual(result.order, ["db", "api", "web"]); assert.deepEqual(result.cycleFallback, []); + result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame), frame)); + assert.deepEqual(result.frames.map((frame) => frame.pages[0].nodes.map((node) => node.id)), [["db"], ["db", "api"], ["db", "api", "web"]]); + assert.equal(result.frames[0].pages[0].nodes[0].provenance?.source, "infra"); +}); + +test("buildup explicitly falls back to stable ID order for cycles", () => { + const cyclic: DiagramPage = { id: "cycle", title: "Cycle", nodes: [{ id: "b", label: "B" }, { id: "a", label: "A" }], edges: [{ id: "ab", source: "a", target: "b" }, { id: "ba", source: "b", target: "a" }] }; + const result = createBuildup(cyclic); assert.deepEqual(result.order, ["a", "b"]); assert.deepEqual(result.cycleFallback, ["a", "b"]); + result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame), frame)); +}); + +test("buildup cycle fallback keeps containers before children so every frame remains valid", () => { + const cyclic: DiagramPage = { id: "nested", title: "Nested", nodes: [{ id: "a-child", label: "Child", parentId: "z-parent" }, { id: "z-parent", label: "Parent", kind: "container" }], edges: [{ id: "reverse", source: "a-child", target: "z-parent" }] }; + const result = createBuildup(cyclic); + assert.deepEqual(result.order, ["z-parent", "a-child"]); + result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame), frame)); +}); + +test("buildup rejects empty and invalid input", () => { + assert.throws(() => createBuildup({ id: "empty", title: "Empty", nodes: [], edges: [] }), /non-empty/i); + assert.throws(() => createBuildup({ id: "bad", title: "Bad", nodes: [{ id: "a", label: "A" }], edges: [{ id: "e", source: "x", target: "a" }] }), /unknown source/i); +}); diff --git a/scripts/src/services/profiles/buildup.ts b/scripts/src/services/profiles/buildup.ts new file mode 100644 index 0000000..70d5cb8 --- /dev/null +++ b/scripts/src/services/profiles/buildup.ts @@ -0,0 +1,36 @@ +import type { DiagramIRV2, DiagramPage } from "../../model/diagram-ir.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; + +export interface BuildupResult { order: string[]; cycleFallback: string[]; frames: DiagramIRV2[] } + +export function createBuildup(page: DiagramPage): BuildupResult { + validateDiagramIR({ version: 2, pages: [structuredClone(page)] }); + const ids = page.nodes.map((node) => node.id); + const adjacency = new Map(ids.map((id) => [id, new Set()])); + const indegree = new Map(ids.map((id) => [id, 0])); + const addDependency = (source: string, target: string) => { + const next = adjacency.get(source)!; if (next.has(target)) return; next.add(target); indegree.set(target, indegree.get(target)! + 1); + }; + page.edges.forEach((edge) => addDependency(edge.source, edge.target)); + page.nodes.forEach((node) => { if (node.parentId) addDependency(node.parentId, node.id); }); + const ready = ids.filter((id) => indegree.get(id) === 0).sort(); const order: string[] = []; + while (ready.length) { + const id = ready.shift()!; order.push(id); + for (const target of [...adjacency.get(id)!].sort()) { indegree.set(target, indegree.get(target)! - 1); if (indegree.get(target) === 0) { ready.push(target); ready.sort(); } } + } + const nodeById = new Map(page.nodes.map((node) => [node.id, node])); + const parentDepth = (id: string): number => { + let depth = 0; let current = nodeById.get(id)?.parentId; + while (current) { depth += 1; current = nodeById.get(current)?.parentId; } + return depth; + }; + const cycleFallback = ids.filter((id) => !order.includes(id)).sort((a, b) => parentDepth(a) - parentDepth(b) || a.localeCompare(b)); + order.push(...cycleFallback); + const frames = order.map((_, index) => { + const includedOrder = order.slice(0, index + 1); const included = new Set(includedOrder); + const framePage: DiagramPage = { ...structuredClone(page), id: `${page.id}-step-${String(index + 1).padStart(3, "0")}`, title: `${page.title} — Step ${index + 1}`, nodes: includedOrder.map((id) => structuredClone(nodeById.get(id)!)), edges: page.edges.filter((edge) => included.has(edge.source) && included.has(edge.target)).map((edge) => structuredClone(edge)), properties: { ...(page.properties ?? {}), profile: "buildup", step: index + 1, addedNodeId: order[index], cycleFallback: cycleFallback.includes(order[index]) } }; + const ir: DiagramIRV2 = { version: 2, title: page.title, pages: [framePage], properties: { profile: "buildup", order: [...order], cycleFallback: [...cycleFallback] } }; + return validateDiagramIR(ir) as DiagramIRV2; + }); + return { order, cycleFallback, frames }; +} diff --git a/scripts/src/services/profiles/c4.test.ts b/scripts/src/services/profiles/c4.test.ts new file mode 100644 index 0000000..8312b3b --- /dev/null +++ b/scripts/src/services/profiles/c4.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; +import { projectC4 } from "./c4.js"; + +const MODEL = { + title: "Shop", + provenance: { source: "architecture.yaml" }, + elements: [ + { id: "buyer", label: "Buyer", type: "person" as const }, + { id: "shop", label: "Shop", type: "system" as const, provenance: { line: 2 } }, + { id: "api", label: "API", type: "container" as const, parentId: "shop" }, + { id: "orders", label: "Orders", type: "component" as const, parentId: "api" }, + ], + relationships: [ + { id: "uses", source: "buyer", target: "shop", label: "Uses" }, + { id: "contains-api", source: "shop", target: "api", label: "Contains" }, + { id: "contains-orders", source: "api", target: "orders", label: "Contains" }, + ], +}; + +test("C4 projects a common model into deterministic linked pages with stable identity", () => { + const before = structuredClone(MODEL); + const first = projectC4(MODEL); + assert.deepEqual(first, projectC4(MODEL)); + assert.deepEqual(MODEL, before); + assert.deepEqual(first.pages.map((page) => page.id), ["c4-context", "c4-containers-shop", "c4-components-api"]); + assert.ok(first.pages[0].nodes.some((node) => node.id === "shop")); + assert.ok(first.pages[1].nodes.some((node) => node.id === "api")); + assert.ok(first.pages[2].nodes.some((node) => node.id === "orders")); + assert.deepEqual(first.pages[0].nodes.find((node) => node.id === "shop")?.provenance, { line: 2 }); + assert.equal(first.pages[0].nodes.find((node) => node.id === "shop")?.properties?.drillDownPage, "c4-containers-shop"); + assert.deepEqual(Object.fromEntries(MODEL.elements.map((element) => [ + element.id, + first.pages.filter((page) => page.nodes.some((node) => node.id === element.id)).map((page) => page.id), + ])), { + buyer: ["c4-context"], + shop: ["c4-context", "c4-containers-shop"], + api: ["c4-containers-shop", "c4-components-api"], + orders: ["c4-components-api"], + }); + assert.ok(first.pages.flatMap((page) => page.nodes).every((node) => /rounded=0/.test(node.style ?? ""))); + assert.equal(validateDiagramIR(first), first); +}); + +test("C4 rejects empty and structurally invalid models", () => { + assert.throws(() => projectC4({ title: "Empty", elements: [], relationships: [] }), /element/i); + assert.throws(() => projectC4({ title: "Bad", elements: [{ id: "child", label: "Child", type: "component", parentId: "missing" }], relationships: [] }), /parent/i); +}); + +test("C4 rejects unsupported runtime element types", () => { + assert.throws(() => projectC4({ + title: "Unsupported", + elements: [{ id: "queue", label: "Queue", type: "database" as never }], + relationships: [], + }), /unsupported.*type/i); +}); + +test("C4 enforces legal parent hierarchy and required parents", () => { + const invalid = [ + [{ id: "system", label: "System", type: "system" as const, parentId: "person" }, { id: "person", label: "Person", type: "person" as const }], + [{ id: "person", label: "Person", type: "person" as const, parentId: "system" }, { id: "system", label: "System", type: "system" as const }], + [{ id: "container", label: "Container", type: "container" as const }], + [{ id: "person", label: "Person", type: "person" as const }, { id: "container", label: "Container", type: "container" as const, parentId: "person" }], + [{ id: "component", label: "Component", type: "component" as const }], + [{ id: "system", label: "System", type: "system" as const }, { id: "component", label: "Component", type: "component" as const, parentId: "system" }], + ]; + for (const elements of invalid) { + assert.throws(() => projectC4({ title: "Invalid", elements, relationships: [] }), /parent|hierarchy/i); + } +}); + +test("C4 rejects parent cycles", () => { + assert.throws(() => projectC4({ + title: "Cycle", + elements: [ + { id: "container", label: "Container", type: "container", parentId: "component" }, + { id: "component", label: "Component", type: "component", parentId: "container" }, + ], + relationships: [], + }), /cycle/i); +}); + +test("C4 retains a person-to-container relationship on one projection without dangling endpoints", () => { + const model = { + title: "Cross-level", + elements: [ + { id: "operator", label: "Operator", type: "person" as const }, + { id: "platform", label: "Platform", type: "system" as const }, + { id: "console", label: "Console", type: "container" as const, parentId: "platform" }, + ], + relationships: [{ id: "administers", source: "operator", target: "console", label: "Administers" }], + }; + const ir = projectC4(model); + assert.deepEqual(ir, projectC4(model)); + const containingPages = ir.pages.filter((page) => page.edges.some((edge) => edge.id === "administers")); + assert.equal(containingPages.length, 1); + const page = containingPages[0]; + const nodeIds = new Set(page.nodes.map((node) => node.id)); + assert.ok(nodeIds.has("operator")); + assert.ok(nodeIds.has("console")); + assert.ok(page.edges.every((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target))); +}); + +test("C4 deterministically retains adversarial cross-level relationships with projected endpoints", () => { + const model = { + title: "Cross-level matrix", + elements: [ + { id: "operator", label: "Operator", type: "person" as const }, + { id: "platform", label: "Platform", type: "system" as const }, + { id: "api", label: "API", type: "container" as const, parentId: "platform" }, + { id: "ui", label: "UI", type: "component" as const, parentId: "api" }, + { id: "service", label: "Service", type: "component" as const, parentId: "api" }, + ], + relationships: [ + { id: "person-component", source: "operator", target: "ui" }, + { id: "system-component", source: "platform", target: "service" }, + { id: "container-system", source: "api", target: "platform" }, + { id: "component-component", source: "ui", target: "service" }, + ], + }; + const first = projectC4(model); + assert.deepEqual(first, projectC4(model)); + for (const relationship of model.relationships) { + const pages = first.pages.filter((page) => page.edges.some((edge) => edge.id === relationship.id)); + assert.ok(pages.length >= 1, `${relationship.id} was dropped`); + assert.ok(pages.some((page) => { + const ids = new Set(page.nodes.map((node) => node.id)); + return ids.has(relationship.source) && ids.has(relationship.target) + && page.edges.every((edge) => ids.has(edge.source) && ids.has(edge.target)); + }), `${relationship.id} has dangling projected endpoints`); + } +}); diff --git a/scripts/src/services/profiles/c4.ts b/scripts/src/services/profiles/c4.ts new file mode 100644 index 0000000..52d419e --- /dev/null +++ b/scripts/src/services/profiles/c4.ts @@ -0,0 +1,112 @@ +import type { DiagramEdge, DiagramIRV2, DiagramNode, DiagramPage } from "../../model/diagram-ir.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; + +export type C4ElementType = "person" | "system" | "container" | "component"; +export interface C4Element { id: string; label: string; type: C4ElementType; parentId?: string; provenance?: Record; properties?: Record } +export interface C4Relationship { id: string; source: string; target: string; label?: string; provenance?: Record } +export interface C4Model { title: string; elements: readonly C4Element[]; relationships: readonly C4Relationship[]; provenance?: Record } + +function cloneRecord(value: Record | undefined): Record | undefined { + return value === undefined ? undefined : structuredClone(value); +} + +export function projectC4(model: C4Model): DiagramIRV2 { + if (!model || !Array.isArray(model.elements) || model.elements.length === 0) throw new Error("C4 model requires at least one element"); + if (!Array.isArray(model.relationships)) throw new Error("C4 relationships must be an array"); + const validTypes = new Set(["person", "system", "container", "component"]); + for (const element of model.elements) { + if (!validTypes.has(element.type)) throw new Error(`C4 element ${element.id} has unsupported type: ${String(element.type)}`); + } + const byId = new Map(model.elements.map((element) => [element.id, element])); + if (byId.size !== model.elements.length) throw new Error("C4 element IDs must be unique"); + for (const element of model.elements) { + if (element.parentId && !byId.has(element.parentId)) throw new Error(`C4 element ${element.id} has unknown parent ${element.parentId}`); + } + for (const element of model.elements) { + const seen = new Set(); + let current: C4Element | undefined = element; + while (current?.parentId) { + if (seen.has(current.id)) throw new Error(`C4 element ${element.id} participates in a parent cycle`); + seen.add(current.id); + current = byId.get(current.parentId); + } + } + for (const element of model.elements) { + const parent = element.parentId ? byId.get(element.parentId) : undefined; + if ((element.type === "person" || element.type === "system") && element.parentId) { + throw new Error(`C4 ${element.type} ${element.id} must not have a parent`); + } + if (element.type === "container" && parent?.type !== "system") { + throw new Error(`C4 container ${element.id} requires a system parent`); + } + if (element.type === "component" && parent?.type !== "container") { + throw new Error(`C4 component ${element.id} requires a container parent`); + } + } + for (const relationship of model.relationships) { + if (!byId.has(relationship.source) || !byId.has(relationship.target)) throw new Error(`C4 relationship ${relationship.id} has an unknown endpoint`); + } + + const systems = model.elements.filter((element) => element.type === "system"); + const containers = model.elements.filter((element) => element.type === "container"); + const pageFor = (prefix: string, id: string) => `${prefix}-${id}`; + const toNode = (element: C4Element, included: Set): DiagramNode => { + const drillDownPage = element.type === "system" && containers.some((item) => item.parentId === element.id) + ? pageFor("c4-containers", element.id) + : element.type === "container" && model.elements.some((item) => item.type === "component" && item.parentId === element.id) + ? pageFor("c4-components", element.id) : undefined; + return { + id: element.id, + label: element.label, + kind: model.elements.some((child) => child.parentId === element.id && included.has(child.id)) ? "container" : `c4-${element.type}`, + ...(element.parentId && included.has(element.parentId) ? { parentId: element.parentId } : {}), + style: "rounded=0;whiteSpace=wrap;html=1", + properties: { ...cloneRecord(element.properties), c4Type: element.type, ...(drillDownPage ? { drillDownPage } : {}) }, + provenance: cloneRecord(element.provenance), + }; + }; + const makePage = (id: string, title: string, elements: C4Element[]): DiagramPage => { + const included = new Set(elements.map((element) => element.id)); + const edges: DiagramEdge[] = model.relationships.filter((edge) => included.has(edge.source) && included.has(edge.target)).map((edge) => ({ + id: edge.id, source: edge.source, target: edge.target, label: edge.label, kind: "c4-relationship", provenance: cloneRecord(edge.provenance), + })); + return { id, title, nodes: elements.map((element) => toNode(element, included)), edges, layout: { type: "layered", direction: "horizontal", gridSize: 10, nodeGap: 40, layerGap: 80 }, properties: { c4Level: id.split("-")[1] } }; + }; + + const context = model.elements.filter((element) => element.type === "person" || element.type === "system"); + const pageSpecs: Array<{ id: string; title: string; elements: C4Element[] }> = [ + { id: "c4-context", title: `${model.title} — Context`, elements: context.length ? context : model.elements.slice(0, 1) }, + ]; + for (const system of systems) { + const children = containers.filter((element) => element.parentId === system.id); + if (children.length) { + const childIds = new Set(children.map((element) => element.id)); + const relatedPeople = model.elements.filter((element) => element.type === "person" && model.relationships.some((relationship) => + (relationship.source === element.id && childIds.has(relationship.target)) + || (relationship.target === element.id && childIds.has(relationship.source)))); + pageSpecs.push({ id: pageFor("c4-containers", system.id), title: `${system.label} — Containers`, elements: [system, ...children, ...relatedPeople] }); + } + } + for (const container of containers) { + const children = model.elements.filter((element) => element.type === "component" && element.parentId === container.id); + if (children.length) pageSpecs.push({ id: pageFor("c4-components", container.id), title: `${container.label} — Components`, elements: [container, ...children] }); + } + + for (const relationship of model.relationships) { + if (pageSpecs.some((page) => page.elements.some((element) => element.id === relationship.source) + && page.elements.some((element) => element.id === relationship.target))) continue; + const candidates = pageSpecs.filter((page) => page.elements.some((element) => + element.id === relationship.source || element.id === relationship.target)); + const page = candidates.at(-1) ?? pageSpecs[0]; + const included = new Set(page.elements.map((element) => element.id)); + for (const endpoint of [relationship.source, relationship.target]) { + if (!included.has(endpoint)) { + page.elements.push(byId.get(endpoint)!); + included.add(endpoint); + } + } + } + const pages = pageSpecs.map((page) => makePage(page.id, page.title, page.elements)); + const ir: DiagramIRV2 = { version: 2, title: model.title, pages, provenance: cloneRecord(model.provenance), properties: { profile: "c4" } }; + return validateDiagramIR(ir) as DiagramIRV2; +} diff --git a/scripts/src/services/profiles/compression.test.ts b/scripts/src/services/profiles/compression.test.ts new file mode 100644 index 0000000..4cd2780 --- /dev/null +++ b/scripts/src/services/profiles/compression.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { DiagramPage } from "../../model/diagram-ir.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; +import { compressExecutiveView } from "./compression.js"; + +const PAGE: DiagramPage = { id: "platform", title: "Platform", nodes: [{ id: "a", label: "A", provenance: { source: "a.yaml" } }, { id: "b", label: "B" }, { id: "c", label: "C" }, { id: "d", label: "D" }], edges: [{ id: "ab", source: "a", target: "b" }, { id: "bc", source: "b", target: "c" }, { id: "cd", source: "c", target: "d" }] }; + +test("compression deterministically clusters topology into linked summary and full pages", () => { + const before = structuredClone(PAGE); const ir = compressExecutiveView(PAGE, { maxClusterSize: 2 }); + assert.deepEqual(ir, compressExecutiveView(PAGE, { maxClusterSize: 2 })); assert.deepEqual(PAGE, before); assert.equal(validateDiagramIR(ir), ir); + assert.deepEqual(ir.pages.map((p) => p.id), ["executive-summary", "platform-full"]); + assert.deepEqual(ir.pages[0].nodes.map((n) => n.properties?.memberIds), [["a", "b"], ["c", "d"]]); + assert.equal(ir.pages[0].nodes[0].properties?.drillDownPage, "platform-full"); + assert.equal(ir.pages[1].nodes[0].provenance?.source, "a.yaml"); + assert.equal(ir.pages[0].edges.length, 1); +}); + +test("compression rejects invalid and empty pages", () => { + assert.throws(() => compressExecutiveView({ id: "empty", title: "Empty", nodes: [], edges: [] }), /non-empty/i); + assert.throws(() => compressExecutiveView({ id: "bad", title: "Bad", nodes: [{ id: "a", label: "A" }], edges: [{ id: "e", source: "a", target: "missing" }] }), /unknown target/i); +}); diff --git a/scripts/src/services/profiles/compression.ts b/scripts/src/services/profiles/compression.ts new file mode 100644 index 0000000..3b9e653 --- /dev/null +++ b/scripts/src/services/profiles/compression.ts @@ -0,0 +1,36 @@ +import type { DiagramEdge, DiagramIRV2, DiagramPage } from "../../model/diagram-ir.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; + +export interface CompressionOptions { maxClusterSize?: number } + +export function compressExecutiveView(page: DiagramPage, options: CompressionOptions = {}): DiagramIRV2 { + validateDiagramIR({ version: 2, pages: [structuredClone(page)] }); + const maxSize = options.maxClusterSize ?? 6; + if (!Number.isInteger(maxSize) || maxSize < 1) throw new Error("maxClusterSize must be a positive integer"); + const nodeById = new Map(page.nodes.map((node) => [node.id, node])); + const adjacency = new Map(page.nodes.map((node) => [node.id, new Set()])); + for (const edge of page.edges) { adjacency.get(edge.source)!.add(edge.target); adjacency.get(edge.target)!.add(edge.source); } + const remaining = new Set([...nodeById.keys()].sort()); + const clusters: string[][] = []; + while (remaining.size) { + const seed = [...remaining][0]; const queue = [seed]; const members: string[] = []; + while (queue.length && members.length < maxSize) { + const id = queue.shift()!; if (!remaining.delete(id)) continue; members.push(id); + const neighbors = [...adjacency.get(id)!].filter((item) => remaining.has(item)).sort(); + queue.push(...neighbors); + } + members.sort(); clusters.push(members); + } + const fullPageId = `${page.id}-full`; + const clusterByNode = new Map(); clusters.forEach((members, index) => members.forEach((id) => clusterByNode.set(id, index))); + const summaryNodes = clusters.map((members, index) => ({ id: `cluster-${String(index + 1).padStart(3, "0")}`, label: members.map((id) => nodeById.get(id)!.label).join(" + "), kind: "executive-cluster", properties: { memberIds: members, memberCount: members.length, drillDownPage: fullPageId }, provenance: { sources: members.map((id) => nodeById.get(id)!.provenance ?? {}).filter((value) => Object.keys(value).length > 0) } })); + const aggregate = new Map(); + for (const edge of [...page.edges].sort((a, b) => a.id.localeCompare(b.id))) { + const source = clusterByNode.get(edge.source)!; const target = clusterByNode.get(edge.target)!; if (source === target) continue; + const key = `${source}:${target}`; const item = aggregate.get(key) ?? { source, target, edgeIds: [] }; item.edgeIds.push(edge.id); aggregate.set(key, item); + } + const summaryEdges: DiagramEdge[] = [...aggregate.values()].sort((a, b) => a.source - b.source || a.target - b.target).map((item, index) => ({ id: `cluster-link-${String(index + 1).padStart(3, "0")}`, source: summaryNodes[item.source].id, target: summaryNodes[item.target].id, kind: "executive-aggregate", properties: { edgeIds: item.edgeIds, edgeCount: item.edgeIds.length } })); + const full: DiagramPage = structuredClone(page); full.id = fullPageId; full.properties = { ...(full.properties ?? {}), profile: "compression-full", summaryPage: "executive-summary" }; + const ir: DiagramIRV2 = { version: 2, title: page.title, pages: [{ id: "executive-summary", title: `${page.title} — Executive Summary`, nodes: summaryNodes, edges: summaryEdges, layout: { type: "layered", direction: "horizontal", gridSize: 10, nodeGap: 40, layerGap: 80 }, properties: { profile: "executive-compression", fullPage: fullPageId } }, full], properties: { profile: "executive-compression", algorithm: "deterministic-topology-bfs" } }; + return validateDiagramIR(ir) as DiagramIRV2; +} diff --git a/scripts/src/services/profiles/index.test.ts b/scripts/src/services/profiles/index.test.ts new file mode 100644 index 0000000..223eda4 --- /dev/null +++ b/scripts/src/services/profiles/index.test.ts @@ -0,0 +1,7 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as profiles from "./index.js"; + +test("specialized profile barrel exposes all seven native profiles", () => { + assert.deepEqual(Object.keys(profiles).filter((key) => key.startsWith("create") || key.startsWith("project") || key.startsWith("compress")).sort(), ["compressExecutiveView", "createArchitectureTimelapse", "createBuildup", "createRunbookHtml", "createSequenceDiagram", "createTubeMap", "projectC4"]); +}); diff --git a/scripts/src/services/profiles/index.ts b/scripts/src/services/profiles/index.ts new file mode 100644 index 0000000..75eee71 --- /dev/null +++ b/scripts/src/services/profiles/index.ts @@ -0,0 +1,14 @@ +export { projectC4 } from "./c4.js"; +export type { C4Element, C4ElementType, C4Model, C4Relationship } from "./c4.js"; +export { createSequenceDiagram } from "./sequence.js"; +export type { SequenceInput, SequenceMessage, SequenceParticipant } from "./sequence.js"; +export { createTubeMap } from "./tube-map.js"; +export type { TubeLine, TubeMapInput, TubeStation } from "./tube-map.js"; +export { compressExecutiveView } from "./compression.js"; +export type { CompressionOptions } from "./compression.js"; +export { createRunbookHtml } from "./runbook.js"; +export type { RunbookChoice, RunbookGraph, RunbookNode } from "./runbook.js"; +export { createArchitectureTimelapse } from "./timelapse.js"; +export type { ArchitectureSnapshot, ArchitectureTimelapse, ChangeSet, TimelapseFrame } from "./timelapse.js"; +export { createBuildup } from "./buildup.js"; +export type { BuildupResult } from "./buildup.js"; diff --git a/scripts/src/services/profiles/runbook.test.ts b/scripts/src/services/profiles/runbook.test.ts new file mode 100644 index 0000000..1ed5fbc --- /dev/null +++ b/scripts/src/services/profiles/runbook.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRunbookHtml } from "./runbook.js"; + +const GRAPH = { title: "Ops ", startId: "start", nodes: [{ id: "start", label: "Is it healthy? ", choices: [{ label: "No & investigate", target: "check" }] }, { id: "check", label: "Check logs", choices: [{ label: "Retry", target: "start" }] }] }; + +test("runbook HTML is deterministic, self-contained and escapes malicious labels", () => { + const before = structuredClone(GRAPH); const html = createRunbookHtml(GRAPH); + assert.equal(html, createRunbookHtml(GRAPH)); assert.deepEqual(GRAPH, before); + assert.ok(html.includes("default-src 'none'")); assert.ok(html.includes("Breadcrumb")); assert.ok(html.includes("Back")); assert.ok(html.includes("Restart")); assert.ok(html.includes("keydown")); + assert.ok(html.includes("border-radius:0")); assert.equal(/border-radius:(?!0(?:[;}]))/.test(html), false); + assert.equal(html.includes(" { + assert.throws(() => createRunbookHtml({ title: "Empty", startId: "x", nodes: [] }), /node/i); + assert.throws(() => createRunbookHtml({ title: "Bad", startId: "a", nodes: [{ id: "a", label: "A", choices: [{ label: "Go", target: "b" }] }] }), /target/i); +}); diff --git a/scripts/src/services/profiles/runbook.ts b/scripts/src/services/profiles/runbook.ts new file mode 100644 index 0000000..4bb5bfb --- /dev/null +++ b/scripts/src/services/profiles/runbook.ts @@ -0,0 +1,19 @@ +export interface RunbookChoice { label: string; target: string } +export interface RunbookNode { id: string; label: string; detail?: string; choices?: readonly RunbookChoice[] } +export interface RunbookGraph { title: string; startId: string; nodes: readonly RunbookNode[] } + +function stripMarkup(value: string): string { return value.replace(/<[^>]*>/g, "").replace(/on\w+\s*=\s*/gi, ""); } +function escapeHtml(value: string): string { return stripMarkup(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } +function safeJson(value: unknown): string { return JSON.stringify(value).replace(//g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); } + +export function createRunbookHtml(graph: RunbookGraph): string { + if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) throw new Error("Runbook requires at least one node"); + const nodes: readonly RunbookNode[] = graph.nodes; + const ids = new Set(nodes.map((node) => node.id)); + if (ids.size !== nodes.length) throw new Error("Runbook node IDs must be unique"); + if (!ids.has(graph.startId)) throw new Error("Runbook startId references an unknown node"); + for (const node of nodes) for (const choice of node.choices ?? []) if (!ids.has(choice.target)) throw new Error(`Runbook choice has unknown target ${choice.target}`); + const data = { title: stripMarkup(graph.title), startId: graph.startId, nodes: nodes.map((node) => ({ id: node.id, label: stripMarkup(node.label), detail: node.detail === undefined ? undefined : stripMarkup(node.detail), choices: (node.choices ?? []).map((choice) => ({ label: stripMarkup(choice.label), target: choice.target })) })) }; + return ` +${escapeHtml(graph.title)}

${escapeHtml(graph.title)}

`; +} diff --git a/scripts/src/services/profiles/sequence.test.ts b/scripts/src/services/profiles/sequence.test.ts new file mode 100644 index 0000000..b6b4082 --- /dev/null +++ b/scripts/src/services/profiles/sequence.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { run as validateConnectors } from "../../actions/page-connectors-validation/action.js"; +import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; +import { createSequenceDiagram } from "./sequence.js"; + +const INPUT = { + title: "Checkout", + participants: [{ id: "web", label: "Web" }, { id: "api", label: "API" }, { id: "db", label: "DB" }], + messages: [ + { id: "m1", from: "web", to: "api", label: "submit", type: "call" as const }, + { id: "m2", from: "api", to: "db", label: "save", type: "call" as const }, + { id: "m3", from: "db", to: "api", label: "ok", type: "return" as const }, + ], +}; + +test("sequence profile emits deterministic non-overlapping lifelines and activation metadata", () => { + const before = structuredClone(INPUT); + const ir = createSequenceDiagram(INPUT); + assert.deepEqual(ir, createSequenceDiagram(INPUT)); + assert.deepEqual(INPUT, before); + assert.equal(validateDiagramIR(ir), ir); + const nodes = ir.pages[0].nodes.filter((node) => node.kind === "sequence-participant"); + for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) { + const a = nodes[i].geometry!; const b = nodes[j].geometry!; + assert.equal(a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y, false); + } + assert.deepEqual(nodes.find((node) => node.id === "api")?.properties?.activations, [{ start: 1, end: 3, depth: 1 }]); + assert.deepEqual(ir.pages[0].edges.map((edge) => edge.properties?.sequence), [1, 2, 3]); + assert.deepEqual(ir.pages[0].edges.map((edge) => edge.properties?.activationDepth), [1, 1, 1]); +}); + +test("sequence profile rejects empty participants and unknown message endpoints", () => { + assert.throws(() => createSequenceDiagram({ title: "Empty", participants: [], messages: [] }), /participant/i); + assert.throws(() => createSequenceDiagram({ title: "Bad", participants: [{ id: "a", label: "A" }], messages: [{ id: "m", from: "a", to: "b", label: "bad" }] }), /endpoint/i); +}); + +test("sequence profile rejects returns that do not match the top active caller and callee", () => { + const participants = [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }]; + assert.throws(() => createSequenceDiagram({ + title: "Mismatched", + participants, + messages: [ + { id: "one", from: "a", to: "b", label: "one", type: "call" }, + { id: "two", from: "b", to: "c", label: "two", type: "call" }, + { id: "bad", from: "b", to: "a", label: "bad", type: "return" }, + ], + }), /return.*top active call/i); + assert.throws(() => createSequenceDiagram({ + title: "Orphan", + participants, + messages: [{ id: "bad", from: "b", to: "a", label: "bad", type: "return" }], + }), /return.*top active call/i); +}); + +test("sequence output contains editable dashed lifelines, activation bars, and 120x80 headers", () => { + const ir = createSequenceDiagram(INPUT); + const headers = ir.pages[0].nodes.filter((node) => node.kind === "sequence-participant"); + assert.equal(headers.length, INPUT.participants.length); + for (const header of headers) { + assert.ok(header.geometry!.width >= 120); + assert.ok(header.geometry!.height >= 80); + assert.match(header.style ?? "", /rounded=0/); + } + const lifelines = ir.pages[0].nodes.filter((node) => node.kind === "sequence-lifeline"); + assert.equal(lifelines.length, INPUT.participants.length); + assert.ok(lifelines.every((node) => /dashed=1/.test(node.style ?? ""))); + const activations = ir.pages[0].nodes.filter((node) => node.kind === "sequence-activation"); + assert.ok(activations.length > 0); + assert.ok(activations.every((node) => /rounded=0/.test(node.style ?? ""))); + + const xml = diagramIRToDrawio(ir); + for (const participant of INPUT.participants) assert.match(xml, new RegExp(`id="${participant.id}-lifeline"[^>]*dashed=1`)); + assert.match(xml, /id="api-activation-1"[^>]*vertex="1"/); +}); + +test("sequence messages avoid connector-shape overlaps in rendered editable output", () => { + const dir = mkdtempSync(join(tmpdir(), "sequence-routing-")); + const file = join(dir, "sequence.drawio"); + try { + writeFileSync(file, diagramIRToDrawio(createSequenceDiagram(INPUT)), "utf8"); + const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number } }; + assert.equal(validation.summary.connectorShapeOverlaps, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("sequence page width includes the deepest nested activation geometry", () => { + const nestedCalls = Array.from({ length: 15 }, (_, index) => ({ id: `nested-${index + 1}`, from: "worker", to: "worker", label: "recurse", type: "call" as const })); + const nestedReturns = Array.from({ length: 15 }, (_, index) => ({ id: `nested-return-${index + 1}`, from: "worker", to: "worker", label: "return", type: "return" as const })); + const ir = createSequenceDiagram({ + title: "Deep recursion", + participants: [{ id: "caller", label: "Caller" }, { id: "worker", label: "Worker" }], + messages: [ + { id: "start", from: "caller", to: "worker", label: "start", type: "call" }, + ...nestedCalls, + ...nestedReturns, + { id: "done", from: "worker", to: "caller", label: "done", type: "return" }, + ], + }); + const deepestRight = Math.max(...ir.pages[0].nodes + .filter((node) => node.kind === "sequence-activation") + .map((node) => node.geometry!.x + node.geometry!.width)); + assert.equal(deepestRight, 455); + assert.ok(ir.pages[0].width! >= deepestRight + 40); +}); + +test("sequence page width includes nested self-call waypoint extents plus margin", () => { + const calls = Array.from({ length: 15 }, (_, index) => ({ + id: `self-${index + 1}`, from: "worker", to: "worker", label: "recurse", type: "call" as const, + })); + const returns = Array.from({ length: 15 }, (_, index) => ({ + id: `return-${index + 1}`, from: "worker", to: "worker", label: "return", type: "return" as const, + })); + const ir = createSequenceDiagram({ + title: "Waypoint-aware recursion", + participants: [{ id: "caller", label: "Caller" }, { id: "worker", label: "Worker" }], + messages: [ + { id: "start", from: "caller", to: "worker", label: "start", type: "call" }, + ...calls, + ...returns, + { id: "done", from: "worker", to: "caller", label: "done", type: "return" }, + ], + }); + const waypointRight = Math.max(...ir.pages[0].edges.flatMap((edge) => edge.waypoints?.map((point) => point.x) ?? [])); + assert.equal(waypointRight, 530); + assert.ok(ir.pages[0].width! >= waypointRight + 40); +}); diff --git a/scripts/src/services/profiles/sequence.ts b/scripts/src/services/profiles/sequence.ts new file mode 100644 index 0000000..b1d22aa --- /dev/null +++ b/scripts/src/services/profiles/sequence.ts @@ -0,0 +1,94 @@ +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; + +export interface SequenceParticipant { id: string; label: string; provenance?: Record } +export interface SequenceMessage { id: string; from: string; to: string; label: string; type?: "call" | "return" | "async"; provenance?: Record } +export interface SequenceInput { title: string; participants: readonly SequenceParticipant[]; messages: readonly SequenceMessage[]; provenance?: Record } +interface Activation { start: number; end: number; depth: number } + +export function createSequenceDiagram(input: SequenceInput): DiagramIRV2 { + if (!input || !Array.isArray(input.participants) || input.participants.length === 0) throw new Error("Sequence diagram requires participants"); + if (!Array.isArray(input.messages)) throw new Error("Sequence messages must be an array"); + const ids = new Set(input.participants.map((participant) => participant.id)); + if (ids.size !== input.participants.length) throw new Error("Sequence participant IDs must be unique"); + for (const message of input.messages) if (!ids.has(message.from) || !ids.has(message.to)) throw new Error(`Sequence message ${message.id} has unknown endpoint`); + + const open = new Map>(); + const activeCalls: Array<{ caller: string; callee: string }> = []; + const activations = new Map(input.participants.map((p) => [p.id, []])); + const messageDepths: number[] = []; + input.messages.forEach((message, index) => { + const sequence = index + 1; + if ((message.type ?? "call") === "return") { + const expected = activeCalls.at(-1); + if (!expected || message.from !== expected.callee || message.to !== expected.caller) { + throw new Error(`Sequence return ${message.id} must match the top active call's original callee and caller`); + } + activeCalls.pop(); + const stack = open.get(message.from) ?? []; + const active = stack.pop(); + messageDepths.push(active?.depth ?? 0); + if (active) activations.get(message.from)!.push({ ...active, end: sequence }); + } else if (message.type === "call" || message.type === undefined) { + const stack = open.get(message.to) ?? []; + const depth = stack.length + 1; + stack.push({ start: sequence, depth }); + activeCalls.push({ caller: message.from, callee: message.to }); + messageDepths.push(depth); + open.set(message.to, stack); + } else { + messageDepths.push(0); + } + }); + for (const [participant, stack] of open) for (const active of stack) activations.get(participant)!.push({ ...active, end: input.messages.length || 1 }); + for (const list of activations.values()) list.sort((a, b) => a.start - b.start || a.depth - b.depth); + + const xById = new Map(); + const participantNodes = input.participants.map((participant, index) => { + const x = 40 + index * 200; xById.set(participant.id, x); + return { id: participant.id, label: participant.label, kind: "sequence-participant", geometry: { x, y: 40, width: 120, height: 80 }, style: "rounded=0;whiteSpace=wrap;html=1", properties: { lifelineX: x + 60, lifelineStart: 120, lifelineEnd: 180 + input.messages.length * 60, activations: activations.get(participant.id)! }, provenance: participant.provenance === undefined ? undefined : structuredClone(participant.provenance) }; + }); + const lifelineNodes = input.participants.map((participant) => ({ + id: `${participant.id}-lifeline`, label: "\u200B", kind: "sequence-lifeline", + geometry: { x: xById.get(participant.id)! + 59, y: 120, width: 2, height: 60 + input.messages.length * 60 }, + style: "shape=line;direction=south;dashed=1;dashPattern=8 8;strokeWidth=2;rounded=0;html=1", + properties: { participantId: participant.id }, + })); + const activationNodes = input.participants.flatMap((participant) => activations.get(participant.id)!.map((activation, index) => ({ + id: `${participant.id}-activation-${index + 1}`, label: "\u200B", kind: "sequence-activation", + geometry: { x: xById.get(participant.id)! + 55 + (activation.depth - 1) * 10, y: 140 + (activation.start - 1) * 60, width: 10, height: Math.max(20, (activation.end - activation.start) * 60) }, + style: "rounded=0;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#000000", + properties: { participantId: participant.id, ...activation }, + }))); + const maxDepthById = new Map(input.participants.map((participant) => [participant.id, Math.max(0, ...activations.get(participant.id)!.map((activation) => activation.depth))])); + const anchorNodes = input.messages.flatMap((message, index) => { + const y = 140 + index * 60; + const fromX = xById.get(message.from)! + 60; + const toX = xById.get(message.to)! + 60; + const direction = Math.sign(toX - fromX) || 1; + const clearX = (id: string, centerX: number, side: number) => centerX + side * (10 + maxDepthById.get(id)! * 10); + const sourceX = clearX(message.from, fromX, direction); + const targetX = clearX(message.to, toX, -direction); + return [ + { id: `${message.id}-source-anchor`, label: "\u200B", kind: "sequence-message-anchor", geometry: { x: sourceX - 1, y: y - 1, width: 2, height: 2 }, style: "opacity=0;fillOpacity=0;strokeOpacity=0;connectable=1", properties: { participantId: message.from, messageId: message.id, role: "source" } }, + { id: `${message.id}-target-anchor`, label: "\u200B", kind: "sequence-message-anchor", geometry: { x: targetX - 1, y: y - 1, width: 2, height: 2 }, style: "opacity=0;fillOpacity=0;strokeOpacity=0;connectable=1", properties: { participantId: message.to, messageId: message.id, role: "target" } }, + ]; + }); + const nodes = [...participantNodes, ...lifelineNodes, ...activationNodes, ...anchorNodes]; + const edges = input.messages.map((message, index) => { + const y = 140 + index * 60; + const fromX = xById.get(message.from)! + 60; + const toX = xById.get(message.to)! + 60; + const direction = Math.sign(toX - fromX) || 1; + const sourceX = fromX + direction * (10 + maxDepthById.get(message.from)! * 10); + const targetX = toX - direction * (10 + maxDepthById.get(message.to)! * 10); + const waypoints = message.from === message.to ? [{ x: sourceX + 60, y }, { x: sourceX + 60, y: y + 30 }, { x: targetX, y: y + 30 }] : [{ x: sourceX, y }, { x: targetX, y }]; + return { id: message.id, source: `${message.id}-source-anchor`, target: `${message.id}-target-anchor`, label: message.label, kind: `sequence-${message.type ?? "call"}`, waypoints, properties: { sequence: index + 1, activationDepth: messageDepths[index], semanticSource: message.from, semanticTarget: message.to }, provenance: message.provenance === undefined ? undefined : structuredClone(message.provenance) }; + }); + const activationRight = Math.max(0, ...activationNodes.map((node) => node.geometry.x + node.geometry.width)); + const waypointRight = Math.max(0, ...edges.flatMap((edge) => edge.waypoints.map((point) => point.x))); + const contentRight = Math.max(activationRight, waypointRight); + const pageWidth = Math.max(320, 200 * input.participants.length, Math.ceil((contentRight + 40) / 10) * 10); + const ir: DiagramIRV2 = { version: 2, title: input.title, pages: [{ id: "sequence", title: input.title, nodes, edges, layout: { type: "manual", gridSize: 10 }, width: pageWidth, height: 240 + 60 * input.messages.length, properties: { profile: "sequence" } }], provenance: input.provenance === undefined ? undefined : structuredClone(input.provenance), properties: { profile: "sequence" } }; + return validateDiagramIR(ir) as DiagramIRV2; +} diff --git a/scripts/src/services/profiles/timelapse.test.ts b/scripts/src/services/profiles/timelapse.test.ts new file mode 100644 index 0000000..ccedf88 --- /dev/null +++ b/scripts/src/services/profiles/timelapse.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; +import { createArchitectureTimelapse } from "./timelapse.js"; + +const one: DiagramIRV2 = { version: 2, title: "Arch", pages: [{ id: "p", title: "P", nodes: [{ id: "a", label: "A", provenance: { source: "model" } }, { id: "b", label: "B" }], edges: [{ id: "ab", source: "a", target: "b" }] }] }; +const two: DiagramIRV2 = { version: 2, title: "Arch", pages: [{ id: "p", title: "P", nodes: [{ id: "a", label: "A2", provenance: { source: "model" } }, { id: "c", label: "C" }], edges: [{ id: "ac", source: "a", target: "c" }] }] }; + +test("timelapse emits valid immutable frames with stable change classifications", () => { + const input = [{ id: "v1", label: "Before", ir: one }, { id: "v2", label: "After", ir: two }]; const before = structuredClone(input); + const result = createArchitectureTimelapse(input); assert.deepEqual(result, createArchitectureTimelapse(input)); assert.deepEqual(input, before); + result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame.ir), frame.ir)); + assert.deepEqual(result.frames[1].changes.nodes, { added: ["p/c"], removed: ["p/b"], modified: ["p/a"], unchanged: [] }); + assert.deepEqual(result.frames[1].changes.edges, { added: ["p/ac"], removed: ["p/ab"], modified: [], unchanged: [] }); + assert.equal(result.frames[1].ir.pages[0].nodes[0].provenance?.source, "model"); +}); + +test("timelapse rejects empty and invalid snapshots", () => { + assert.throws(() => createArchitectureTimelapse([]), /snapshot/i); + assert.throws(() => createArchitectureTimelapse([{ id: "bad", label: "Bad", ir: { version: 2, pages: [] } as DiagramIRV2 }]), /non-empty/i); +}); diff --git a/scripts/src/services/profiles/timelapse.ts b/scripts/src/services/profiles/timelapse.ts new file mode 100644 index 0000000..129a7bb --- /dev/null +++ b/scripts/src/services/profiles/timelapse.ts @@ -0,0 +1,37 @@ +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; + +export interface ArchitectureSnapshot { id: string; label: string; ir: DiagramIRV2; provenance?: Record } +export interface ChangeSet { added: string[]; removed: string[]; modified: string[]; unchanged: string[] } +export interface TimelapseFrame { id: string; label: string; index: number; ir: DiagramIRV2; changes: { nodes: ChangeSet; edges: ChangeSet }; provenance?: Record } +export interface ArchitectureTimelapse { frames: TimelapseFrame[] } + +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value && typeof value === "object") return `{${Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`; + return JSON.stringify(value); +} +function inventory(ir: DiagramIRV2, kind: "nodes" | "edges"): Map { + return new Map(ir.pages.flatMap((page) => page[kind].map((item) => [`${page.id}/${item.id}`, item] as const))); +} +function classify(previous: Map, current: Map): ChangeSet { + const added: string[] = [], removed: string[] = [], modified: string[] = [], unchanged: string[] = []; + for (const id of [...current.keys()].sort()) { + if (!previous.has(id)) added.push(id); else if (canonical(previous.get(id)) === canonical(current.get(id))) unchanged.push(id); else modified.push(id); + } + for (const id of [...previous.keys()].sort()) if (!current.has(id)) removed.push(id); + return { added, removed, modified, unchanged }; +} + +export function createArchitectureTimelapse(snapshots: readonly ArchitectureSnapshot[]): ArchitectureTimelapse { + if (!Array.isArray(snapshots) || snapshots.length === 0) throw new Error("Timelapse requires at least one snapshot"); + const ids = new Set(snapshots.map((snapshot) => snapshot.id)); if (ids.size !== snapshots.length) throw new Error("Snapshot IDs must be unique"); + let previousNodes = new Map(), previousEdges = new Map(); + const frames = snapshots.map((snapshot, index) => { + const ir = structuredClone(snapshot.ir); validateDiagramIR(ir); + const currentNodes = inventory(ir, "nodes"), currentEdges = inventory(ir, "edges"); + const frame: TimelapseFrame = { id: snapshot.id, label: snapshot.label, index, ir, changes: { nodes: classify(previousNodes, currentNodes), edges: classify(previousEdges, currentEdges) }, provenance: snapshot.provenance === undefined ? undefined : structuredClone(snapshot.provenance) }; + previousNodes = currentNodes; previousEdges = currentEdges; return frame; + }); + return { frames }; +} diff --git a/scripts/src/services/profiles/tube-map.test.ts b/scripts/src/services/profiles/tube-map.test.ts new file mode 100644 index 0000000..6384ca5 --- /dev/null +++ b/scripts/src/services/profiles/tube-map.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { run as validateConnectors } from "../../actions/page-connectors-validation/action.js"; +import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; +import { createTubeMap } from "./tube-map.js"; + +const MAP = { title: "Transit", stations: [{ id: "a", label: "A" }, { id: "x", label: "Exchange" }, { id: "b", label: "B" }, { id: "c", label: "C" }], lines: [{ id: "red", label: "Red", color: "#e53935", stations: ["a", "x", "b"] }, { id: "blue", label: "Blue", color: "#1e88e5", stations: ["c", "x"] }] }; + +test("tube map is deterministic, grid-aligned and octilinear with interchange semantics", () => { + const before = structuredClone(MAP); const ir = createTubeMap(MAP); + assert.deepEqual(ir, createTubeMap(MAP)); assert.deepEqual(MAP, before); assert.equal(validateDiagramIR(ir), ir); + const x = ir.pages[0].nodes.find((node) => node.id === "x")!; + assert.equal(x.properties?.interchange, true); assert.deepEqual(x.properties?.lines, ["blue", "red"]); + const centers = new Map(ir.pages[0].nodes.map((n) => [n.id, { x: n.geometry!.x + n.geometry!.width / 2, y: n.geometry!.y + n.geometry!.height / 2 }])); + for (const edge of ir.pages[0].edges) { + const points = [centers.get(edge.source)!, ...(edge.waypoints ?? []), centers.get(edge.target)!]; + for (let i = 1; i < points.length; i++) { const dx = Math.abs(points[i].x - points[i - 1].x); const dy = Math.abs(points[i].y - points[i - 1].y); assert.ok(dx === 0 || dy === 0 || dx === dy); } + } +}); + +test("tube map rejects empty and invalid routes", () => { + assert.throws(() => createTubeMap({ title: "Empty", stations: [], lines: [] }), /station/i); + assert.throws(() => createTubeMap({ title: "Bad", stations: [{ id: "a", label: "A" }], lines: [{ id: "l", label: "L", color: "red", stations: ["a", "z"] }] }), /unknown station/i); +}); + +test("tube map rejects colors that could inject draw.io style directives", () => { + const stations = [{ id: "a", label: "A" }, { id: "b", label: "B" }]; + for (const color of ["red", "#abc", "#123456;rounded=1", "#123456\nfillColor=#000000"]) { + assert.throws( + () => createTubeMap({ title: "Unsafe", stations, lines: [{ id: "line", label: "Line", color, stations: ["a", "b"] }] }), + /color.*#RRGGBB/i, + ); + } +}); + +test("tube routes avoid unrelated station boxes even when route order is a,c,b", () => { + const ir = createTubeMap({ + title: "Nonlinear route", + stations: [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }], + lines: [{ id: "line", label: "Line", color: "#123456", stations: ["a", "c", "b"] }], + }); + const dir = mkdtempSync(join(tmpdir(), "tube-map-routing-")); + const file = join(dir, "route.drawio"); + try { + writeFileSync(file, diagramIRToDrawio(ir), "utf8"); + const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number } }; + assert.equal(validation.summary.connectorShapeOverlaps, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("tube lines use deterministic distinct parallel corridors without station intersections", () => { + const first = createTubeMap(MAP); + assert.deepEqual(first, createTubeMap(MAP)); + const corridorByLine = new Map(); + for (const edge of first.pages[0].edges) { + const lineId = edge.properties!.lineId as string; + const horizontal = edge.waypoints!.find((point, index, points) => index > 0 && point.y === points[index - 1].y); + assert.ok(horizontal); + const previous = corridorByLine.get(lineId); + if (previous === undefined) corridorByLine.set(lineId, horizontal.y); + else assert.equal(horizontal.y, previous); + } + assert.equal(new Set(corridorByLine.values()).size, MAP.lines.length); + + const dir = mkdtempSync(join(tmpdir(), "tube-map-corridors-")); + const file = join(dir, "corridors.drawio"); + try { + writeFileSync(file, diagramIRToDrawio(first), "utf8"); + const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number } }; + assert.equal(validation.summary.connectorShapeOverlaps, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("tube lines sharing stations have distinct complete routes including station approaches", () => { + const input = { + title: "Shared route", + stations: [{ id: "a", label: "A" }, { id: "unrelated", label: "Unrelated" }, { id: "b", label: "B" }], + lines: [ + { id: "red", label: "Red", color: "#e53935", stations: ["a", "b"] }, + { id: "blue", label: "Blue", color: "#1e88e5", stations: ["a", "b"] }, + ], + }; + const first = createTubeMap(input); + assert.deepEqual(first, createTubeMap(input)); + const centers = new Map(first.pages[0].nodes.map((node) => [node.id, { + x: node.geometry!.x + node.geometry!.width / 2, + y: node.geometry!.y + node.geometry!.height / 2, + }])); + const routes = first.pages[0].edges.map((edge) => [centers.get(edge.source)!, ...edge.waypoints!, centers.get(edge.target)!]); + const segments = routes.map((points) => points.slice(1).map((point, index) => [points[index], point] as const)); + const overlapsWithPositiveLength = (a: readonly [{ x: number; y: number }, { x: number; y: number }], b: readonly [{ x: number; y: number }, { x: number; y: number }]) => { + const [a1, a2] = a; const [b1, b2] = b; + const adx = a2.x - a1.x; const ady = a2.y - a1.y; + const bdx = b2.x - b1.x; const bdy = b2.y - b1.y; + if (adx * bdy !== ady * bdx || adx * (b1.y - a1.y) !== ady * (b1.x - a1.x)) return false; + const axis = Math.abs(adx) >= Math.abs(ady) ? "x" : "y"; + const [aMin, aMax] = [a1[axis], a2[axis]].sort((x, y) => x - y); + const [bMin, bMax] = [b1[axis], b2[axis]].sort((x, y) => x - y); + return Math.min(aMax, bMax) > Math.max(aMin, bMin); + }; + for (const redSegment of segments[0]) for (const blueSegment of segments[1]) { + assert.equal(overlapsWithPositiveLength(redSegment, blueSegment), false, "complete routes share a segment"); + } + const dir = mkdtempSync(join(tmpdir(), "tube-map-shared-route-")); + const file = join(dir, "shared.drawio"); + try { + writeFileSync(file, diagramIRToDrawio(first), "utf8"); + const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number } }; + assert.equal(validation.summary.connectorShapeOverlaps, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/src/services/profiles/tube-map.ts b/scripts/src/services/profiles/tube-map.ts new file mode 100644 index 0000000..a5aadf1 --- /dev/null +++ b/scripts/src/services/profiles/tube-map.ts @@ -0,0 +1,50 @@ +import type { DiagramIRV2, DiagramPoint } from "../../model/diagram-ir.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; + +export interface TubeStation { id: string; label: string; provenance?: Record } +export interface TubeLine { id: string; label: string; color: string; stations: readonly string[]; provenance?: Record } +export interface TubeMapInput { title: string; stations: readonly TubeStation[]; lines: readonly TubeLine[]; provenance?: Record } + +export function createTubeMap(input: TubeMapInput): DiagramIRV2 { + if (!input || !Array.isArray(input.stations) || input.stations.length === 0) throw new Error("Tube map requires stations"); + if (!Array.isArray(input.lines)) throw new Error("Tube map lines must be an array"); + const stations: readonly TubeStation[] = input.stations; + const lines: readonly TubeLine[] = input.lines; + const stationIds = new Set(stations.map((station) => station.id)); + if (stationIds.size !== stations.length) throw new Error("Tube station IDs must be unique"); + for (const line of lines) { + if (line.stations.length < 2) throw new Error(`Tube line ${line.id} requires at least two stations`); + for (const id of line.stations) if (!stationIds.has(id)) throw new Error(`Tube line ${line.id} references unknown station ${id}`); + if (!/^#[0-9A-Fa-f]{6}$/.test(line.color)) throw new Error(`Tube line ${line.id} color must use strict #RRGGBB format`); + } + const memberships = new Map(stations.map((station) => [station.id, [] as string[]])); + lines.forEach((line) => line.stations.forEach((id) => memberships.get(id)!.push(line.id))); + memberships.forEach((lines) => lines.sort()); + const firstLine = new Map(); + lines.forEach((line, lineIndex) => line.stations.forEach((id) => { if (!firstLine.has(id)) firstLine.set(id, lineIndex); })); + const centers = new Map(); + const nodes = stations.map((station, index) => { + const center = { x: 100 + index * 160, y: 100 + (firstLine.get(station.id) ?? lines.length) * 160 }; centers.set(station.id, center); + const stationLines = memberships.get(station.id)!; + return { id: station.id, label: station.label, kind: stationLines.length > 1 ? "tube-interchange" : "tube-station", geometry: { x: center.x - 20, y: center.y - 20, width: 40, height: 40 }, style: `ellipse;whiteSpace=wrap;html=1;strokeWidth=${stationLines.length > 1 ? 6 : 3}`, properties: { interchange: stationLines.length > 1, lines: stationLines }, provenance: station.provenance === undefined ? undefined : structuredClone(station.provenance) }; + }); + const edges = lines.flatMap((line, lineIndex) => line.stations.slice(0, -1).map((source, index) => { + const routeCorridorY = 40 + lineIndex * 20; + const target = line.stations[index + 1]; const a = centers.get(source)!; const b = centers.get(target)!; + const laneOffset = 30 + Math.floor(lineIndex / 2) * 10; + const approachX = (lineIndex % 2 === 0 ? 1 : -1) * laneOffset; + const sourceApproachY = a.y + Math.sign(routeCorridorY - a.y) * laneOffset; + const targetApproachY = b.y + Math.sign(routeCorridorY - b.y) * laneOffset; + const waypoints = source === target + ? [{ x: a.x + 40, y: a.y }, { x: a.x + 40, y: a.y + 40 }, { x: a.x, y: a.y + 40 }] + : [ + { x: a.x + approachX, y: sourceApproachY }, + { x: a.x + approachX, y: routeCorridorY }, + { x: b.x + approachX, y: routeCorridorY }, + { x: b.x + approachX, y: targetApproachY }, + ]; + return { id: `${line.id}-segment-${index + 1}`, source, target, kind: "tube-line", style: `edgeStyle=none;rounded=0;strokeColor=${line.color};strokeWidth=8;endArrow=none`, waypoints, properties: { lineId: line.id, lineLabel: line.label, segment: index + 1 }, provenance: line.provenance === undefined ? undefined : structuredClone(line.provenance) }; + })); + const ir: DiagramIRV2 = { version: 2, title: input.title, pages: [{ id: "tube-map", title: input.title, nodes, edges, layout: { type: "manual", gridSize: 10 }, width: Math.max(320, stations.length * 160 + 40), height: Math.max(320, (lines.length + 1) * 160), properties: { profile: "tube-map", lines: lines.map((line) => ({ id: line.id, label: line.label, color: line.color })) } }], provenance: input.provenance === undefined ? undefined : structuredClone(input.provenance), properties: { profile: "tube-map" } }; + return validateDiagramIR(ir) as DiagramIRV2; +} 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)) }; +} diff --git a/scripts/src/services/shape-catalog/shape-catalog.test.ts b/scripts/src/services/shape-catalog/shape-catalog.test.ts new file mode 100644 index 0000000..e68790c --- /dev/null +++ b/scripts/src/services/shape-catalog/shape-catalog.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { OFFLINE_SHAPE_CATALOG, searchShapes } from "./shape-catalog.js"; + +test("offline catalog entries carry explicit public provenance and licensing", () => { + assert.ok(OFFLINE_SHAPE_CATALOG.length >= 6); + for (const entry of OFFLINE_SHAPE_CATALOG) { + assert.equal(entry.provenance.license, "Apache-2.0"); + assert.match(entry.provenance.sourceUrl, /^https:\/\//); + assert.match(entry.style, /shape=/); + } +}); + +test("shape search ranks exact, alias, and fuzzy matches deterministically", () => { + assert.deepEqual(searchShapes("database"), searchShapes("database")); + assert.equal(searchShapes("database").matches[0].id, "cylinder"); + assert.equal(searchShapes("database").matches[0].matchType, "alias"); + assert.equal(searchShapes("rhombus").matches[0].matchType, "exact"); + const fuzzy = searchShapes("proces box").matches[0]; + assert.equal(fuzzy.id, "rectangle"); + assert.equal(fuzzy.matchType, "fuzzy"); + assert.ok(fuzzy.confidence > 0 && fuzzy.confidence < 0.9); +}); + +test("shape search returns an explicit generic fallback for unknown or blank queries", () => { + for (const query of ["quantum-flibbertigibbet", " "]) { + const result = searchShapes(query); + assert.equal(result.matches.length, 0); + assert.equal(result.fallback.id, "rectangle"); + assert.equal(result.fallback.matchType, "fallback"); + assert.equal(result.fallback.confidence, 0); + } +}); diff --git a/scripts/src/services/shape-catalog/shape-catalog.ts b/scripts/src/services/shape-catalog/shape-catalog.ts new file mode 100644 index 0000000..e9c0610 --- /dev/null +++ b/scripts/src/services/shape-catalog/shape-catalog.ts @@ -0,0 +1,98 @@ +export interface ShapeProvenance { + sourceName: string; + sourceUrl: string; + license: "Apache-2.0"; + note: string; +} + +export interface ShapeCatalogEntry { + id: string; + name: string; + aliases: readonly string[]; + style: string; + provenance: ShapeProvenance; +} + +export type ShapeMatchType = "exact" | "alias" | "fuzzy" | "fallback"; +export interface ShapeSearchMatch extends ShapeCatalogEntry { + confidence: number; + matchType: ShapeMatchType; +} +export interface ShapeSearchResult { + query: string; + matches: ShapeSearchMatch[]; + fallback: ShapeSearchMatch; +} + +const PROVENANCE: ShapeProvenance = { + sourceName: "diagrams.net generic built-in shape vocabulary", + sourceUrl: "https://github.com/jgraph/drawio", + license: "Apache-2.0", + note: "Hand-curated metadata for generic native Draw.io shapes; no third-party assets are bundled.", +}; + +export const OFFLINE_SHAPE_CATALOG: readonly ShapeCatalogEntry[] = Object.freeze([ + entry("actor", "Actor", ["person", "user", "human"], "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;"), + entry("cloud", "Cloud", ["internet", "external cloud"], "shape=cloud;"), + entry("cylinder", "Cylinder", ["database", "data store", "storage"], "shape=cylinder;"), + entry("document", "Document", ["file", "report", "paper"], "shape=document;"), + entry("ellipse", "Ellipse", ["oval", "start", "end"], "shape=ellipse;"), + entry("hexagon", "Hexagon", ["preparation", "compute"], "shape=hexagon;"), + entry("rectangle", "Rectangle", ["process", "box", "generic"], "shape=rectangle;"), + entry("rhombus", "Rhombus", ["decision", "diamond", "choice"], "shape=rhombus;"), +]); + +function entry(id: string, name: string, aliases: readonly string[], style: string): ShapeCatalogEntry { + return Object.freeze({ id, name, aliases: Object.freeze([...aliases]), style, provenance: Object.freeze({ ...PROVENANCE }) }); +} + +function normalize(value: string): string { + return value.trim().toLocaleLowerCase("en-US").replace(/[_-]+/g, " ").replace(/\s+/g, " "); +} + +function similarity(left: string, right: string): number { + if (left === right) return 1; + const a = [...left]; + const b = [...right]; + if (a.length === 0 || b.length === 0) return 0; + const previous = Array.from({ length: b.length + 1 }, (_, index) => index); + for (let row = 1; row <= a.length; row += 1) { + const current = [row]; + for (let column = 1; column <= b.length; column += 1) { + current[column] = Math.min( + current[column - 1] + 1, + previous[column] + 1, + previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1), + ); + } + previous.splice(0, previous.length, ...current); + } + return 1 - previous[b.length] / Math.max(a.length, b.length); +} + +function match(entryValue: ShapeCatalogEntry, query: string): ShapeSearchMatch | undefined { + const id = normalize(entryValue.id); + const name = normalize(entryValue.name); + const aliases = entryValue.aliases.map(normalize); + if (query === id || query === name) return { ...entryValue, confidence: 1, matchType: "exact" }; + if (aliases.includes(query)) return { ...entryValue, confidence: 0.95, matchType: "alias" }; + const terms = [id, name, ...aliases]; + const score = Math.max(...terms.map((term) => similarity(query, term))); + if (score < 0.45) return undefined; + return { ...entryValue, confidence: Number((score * 0.85).toFixed(6)), matchType: "fuzzy" }; +} + +export function searchShapes(rawQuery: string, limit = 5): ShapeSearchResult { + if (!Number.isInteger(limit) || limit < 1) throw new Error("Shape search limit must be a positive integer"); + const query = normalize(rawQuery); + const generic = OFFLINE_SHAPE_CATALOG.find(({ id }) => id === "rectangle")!; + const fallback: ShapeSearchMatch = { ...generic, confidence: 0, matchType: "fallback" }; + const matches = query === "" ? [] : OFFLINE_SHAPE_CATALOG + .map((candidate) => match(candidate, query)) + .filter((candidate): candidate is ShapeSearchMatch => candidate !== undefined) + .sort((left, right) => right.confidence - left.confidence || left.id.localeCompare(right.id, "en-US")) + .slice(0, limit); + return { query: rawQuery, matches, fallback }; +} + +export const searchShapeCatalog = searchShapes; diff --git a/scripts/src/services/source-importers/index.test.ts b/scripts/src/services/source-importers/index.test.ts new file mode 100644 index 0000000..3100215 --- /dev/null +++ b/scripts/src/services/source-importers/index.test.ts @@ -0,0 +1,385 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { importSource } from "./index.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; + +const textInput = (sourceKind: Parameters[0]["sourceKind"], path: string, text: string) => + importSource({ sourceKind, path, input: { type: "text", text } }); + +test("imports Python modules with exact line provenance", async () => { + const result = await textInput("python", "src/app.py", "import os\nfrom pkg.db import Client\n"); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => [n.label, n.kind]), [ + ["src/app.py", "module"], ["os", "library"], ["pkg.db", "library"], + ]); + assert.equal(result.diagram.pages[0].edges[1].provenance?.line, 2); + assert.equal(validateDiagramIR(result.diagram), result.diagram); +}); + +test("imports JavaScript and TypeScript static and require dependencies", async () => { + const js = await textInput("javascript", "src/app.js", "import express from 'express';\nconst x = require('./local.js');\n"); + const ts = await textInput("typescript", "src/app.ts", "export { x } from '@scope/pkg';\nimport type { T } from './types.js';\n"); + assert.deepEqual(js.diagram.pages[0].nodes.slice(1).map((n) => n.label), ["express", "./local.js"]); + assert.deepEqual(ts.diagram.pages[0].nodes.slice(1).map((n) => n.label), ["@scope/pkg", "./types.js"]); + assert.ok(js.diagram.pages[0].nodes.every((n) => ["module", "library"].includes(n.kind ?? ""))); +}); + +test("JavaScript scanner ignores comments and strings while preserving real static imports", async () => { + const source = [ + "// import commented from 'commented';", + "/* require('blocked');", + " export { x } from 'also-blocked'; */", + "const text = \"import fake from 'inside-string'\";", + "const template = `require('inside-template')`;", + "const pattern = /import regexFake from 'inside-regex'/;", + "loader.require('member-call');", + "const metadata = { import: 'property', from: 'property-from' };", + "import real from 'real';", + "export { thing } from './actual.js';", + "const required = require(\"required\");", + "import type {", + " Value", + "} from './types.js';", + "// const dynamic = import(name);", + ].join("\n"); + const result = await textInput("typescript", "src/app.ts", source); + assert.deepEqual(result.diagram.pages[0].nodes.slice(1).map((node) => node.label), ["real", "./actual.js", "required", "./types.js"]); + assert.deepEqual(result.diagnostics, []); +}); + +test("JavaScript scanner treats arrow-expression regex literals as inert without hiding division or imports", async () => { + const source = [ + "const matcher = () => /require('phantom')/;", + "const ratio = numerator / denominator;", + "import real from 'real';", + "const actual = require('./actual.js');", + ].join("\n"); + const result = await textInput("typescript", "src/regex.ts", source); + assert.deepEqual(result.diagram.pages[0].nodes.slice(1).map((node) => node.label), ["real", "./actual.js"]); + assert.deepEqual(result.diagnostics, []); +}); + +test("JavaScript scanner diagnoses unsupported dynamic and unterminated constructs", async () => { + const result = await textInput("javascript", "src/app.js", "const dynamic = import(name);\nconst template = `${load(name)}`;\n/* unterminated"); + assert.ok(result.diagnostics.length >= 3); + assert.ok(result.diagnostics.every((diagnostic) => diagnostic.code === "unknown-construct")); +}); + +test("imports Go single and grouped imports", async () => { + const result = await textInput("go", "cmd/main.go", "package main\nimport \"fmt\"\nimport (\n alias \"example.com/acme/lib\"\n _ \"net/http/pprof\"\n)\n"); + assert.deepEqual(result.diagram.pages[0].nodes.slice(1).map((n) => n.label), ["fmt", "example.com/acme/lib", "net/http/pprof"]); + assert.equal(result.diagram.pages[0].edges[2].provenance?.line, 5); +}); + +test("imports Rust mod declarations and use roots without guessing macros", async () => { + const result = await textInput("rust", "src/lib.rs", "mod api;\nuse crate::db::Pool;\nuse serde::{Serialize, Deserialize};\ncustom!(unknown);\n"); + assert.deepEqual(result.diagram.pages[0].nodes.slice(1).map((n) => n.label), ["api", "crate::db", "serde"]); + assert.equal(result.diagnostics[0].code, "unknown-construct"); +}); + +test("imports Terraform resources and explicit references", async () => { + const result = await textInput("terraform", "main.tf", "resource \"aws_vpc\" \"main\" {}\nresource \"aws_subnet\" \"web\" {\n vpc_id = aws_vpc.main.id\n}\n"); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["aws_subnet.web", "aws_vpc.main"]); + assert.deepEqual(result.diagram.pages[0].edges.map((e) => [e.source, e.target, e.kind]), [["resource-6177735f7375626e65742e776562", "resource-6177735f7670632e6d61696e", "reference"]]); + assert.equal(result.diagram.pages[0].edges[0].provenance?.line, 3); +}); + +test("non-JavaScript scanners ignore inert constructs and diagnose unterminated regions", async () => { + const [python, unterminatedPython, go, rust, sql, terraform] = await Promise.all([ + textInput("python", "app.py", "'''\nimport phantom\n'''\nimport real\n"), + textInput("python", "broken.py", "\"\"\"unterminated\nimport phantom\n"), + textInput("go", "main.go", "package main\n/*\nimport \"phantom\"\n*/\nimport \"fmt\"\n/* unterminated\n"), + textInput("rust", "lib.rs", "/*\nmod phantom;\nuse fake::Thing;\n*/\nmod api;\nuse serde::Serialize;\n/* unterminated\n"), + textInput("sql", "schema.sql", "/*\nCREATE TABLE phantom (id INT REFERENCES ghost(id));\n*/\nCREATE TABLE real (id INT);\n/* unterminated\n"), + textInput("terraform", "main.tf", [ + "/*", "resource \"aws_vpc\" \"phantom\" {}", "*/", + "resource \"aws_vpc\" \"main\" {}", + "resource \"aws_subnet\" \"web\" {", + " description = \"aws_vpc.phantom.id\"", + " # fake = aws_vpc.phantom.id", + " vpc_id = aws_vpc.main.id", + "}", "/* unterminated", + ].join("\n")), + ]); + assert.deepEqual(python.diagram.pages[0].nodes.map((node) => node.label), ["app.py", "real"]); + assert.deepEqual(unterminatedPython.diagram.pages[0].nodes.map((node) => node.label), ["broken.py"]); + assert.deepEqual(go.diagram.pages[0].nodes.map((node) => node.label), ["main.go", "fmt"]); + assert.deepEqual(rust.diagram.pages[0].nodes.map((node) => node.label), ["lib.rs", "api", "serde"]); + assert.deepEqual(sql.diagram.pages[0].nodes.map((node) => node.label), ["real"]); + assert.deepEqual(terraform.diagram.pages[0].nodes.map((node) => node.label), ["aws_subnet.web", "aws_vpc.main"]); + assert.equal(terraform.diagram.pages[0].edges.length, 1); + for (const result of [unterminatedPython, go, rust, sql, terraform]) { + assert.ok(result.diagnostics.some((diagnostic) => diagnostic.code === "unknown-construct")); + } +}); + +test("multiline literals mask phantom topology and diagnose only unterminated regions", async () => { + const [go, rust, sql, terraform] = await Promise.all([ + textInput("go", "main.go", [ + "package main", "var first = `", "import \"phantom\"", "`", "import \"fmt\"", + ].join("\n")), + textInput("rust", "lib.rs", [ + "const FIRST: &str = r\"", "mod phantom_one;", "\";", + "const SECOND: &str = r##\"", "use phantom_two::Thing;", "\"##;", + "mod api;", "use serde::Serialize;", + ].join("\n")), + textInput("sql", "schema.sql", [ + "CREATE TABLE real (id INT);", "INSERT INTO real(note) VALUES ('", + "CREATE TABLE phantom (id INT REFERENCES ghost(id));", "escaped '' quote", "');", + ].join("\n")), + textInput("terraform", "main.tf", [ + "resource \"aws_vpc\" \"main\" {}", "resource \"aws_subnet\" \"web\" {", + " user_data = <<-EOT", " resource \"aws_instance\" \"phantom\" {}", + " aws_vpc.phantom.id", " EOT", " vpc_id = aws_vpc.main.id", "}", + ].join("\n")), + ]); + assert.deepEqual(go.diagram.pages[0].nodes.map((node) => node.label), ["main.go", "fmt"]); + assert.deepEqual(rust.diagram.pages[0].nodes.map((node) => node.label), ["lib.rs", "api", "serde"]); + assert.deepEqual(sql.diagram.pages[0].nodes.map((node) => node.label), ["real"]); + assert.deepEqual(terraform.diagram.pages[0].nodes.map((node) => node.label), ["aws_subnet.web", "aws_vpc.main"]); + assert.equal(terraform.diagram.pages[0].edges.length, 1); + for (const result of [go, rust, sql, terraform]) assert.deepEqual(result.diagnostics, []); + + const unterminated = await Promise.all([ + textInput("go", "broken.go", "package main\nvar value = `unterminated\nimport \"phantom\""), + textInput("rust", "broken.rs", "const VALUE: &str = r###\"unterminated\nmod phantom;"), + textInput("sql", "broken.sql", "CREATE TABLE real (id INT);\nINSERT INTO real(note) VALUES ('unterminated\nCREATE TABLE phantom (id INT);"), + textInput("terraform", "broken.tf", "resource \"aws_vpc\" \"main\" {}\nvalue = < node.label.includes("phantom")), false); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["unknown-construct"]); + } +}); + +test("imports Kubernetes relationships and redacts Secret payloads", async () => { + const objects = [ + { apiVersion: "v1", kind: "Secret", metadata: { name: "db-secret" }, data: { password: "c2VjcmV0" }, stringData: { token: "plain" } }, + { apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "api" }, spec: { selector: { matchLabels: { app: "api" } }, template: { metadata: { labels: { app: "api" } }, spec: { containers: [{ envFrom: [{ secretRef: { name: "db-secret" } }] }] } } } }, + { apiVersion: "v1", kind: "Service", metadata: { name: "api-svc" }, spec: { selector: { app: "api" } } }, + ]; + const result = await importSource({ sourceKind: "kubernetes", path: "k8s.json", input: { type: "object", value: objects } }); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["Deployment/default/api", "Secret/default/db-secret", "Service/default/api-svc"]); + assert.deepEqual(result.diagram.pages[0].edges.map((e) => e.kind), ["uses-secret", "selects"]); + assert.equal(JSON.stringify(result).includes("c2VjcmV0"), false); + assert.equal(JSON.stringify(result).includes("plain"), false); + assert.equal(result.diagnostics[0].code, "secret-redacted"); +}); + +test("Kubernetes ignores secretRef-shaped data outside supported workload fields", async () => { + const objects = [ + { kind: "Secret", metadata: { name: "real" } }, + { kind: "Secret", metadata: { name: "phantom" } }, + { + kind: "Deployment", metadata: { name: "api" }, status: { secretRef: { name: "phantom" } }, + spec: { template: { spec: { containers: [{ envFrom: [{ secretRef: { name: "real" } }] }] } } }, + }, + ]; + const result = await importSource({ sourceKind: "kubernetes", path: "k8s.json", input: { type: "object", value: objects } }); + const labelById = new Map(result.diagram.pages[0].nodes.map((node) => [node.id, node.label])); + assert.deepEqual(result.diagram.pages[0].edges.map((edge) => [edge.kind, labelById.get(edge.target)]), [ + ["uses-secret", "Secret/default/real"], + ]); +}); + +test("scopes Kubernetes identity, secret references, and non-empty Service selectors by namespace", async () => { + const objects = [ + { kind: "Secret", metadata: { name: "shared" } }, + { kind: "Secret", metadata: { namespace: "team-b", name: "shared" } }, + { kind: "Deployment", metadata: { name: "api" }, spec: { selector: { matchLabels: { selectorOnly: "wrong" } }, template: { metadata: { labels: { app: "api" } }, spec: { containers: [{ envFrom: [{ secretRef: { name: "shared" } }] }] } } } }, + { kind: "Deployment", metadata: { namespace: "team-b", name: "api" }, spec: { template: { metadata: { labels: { app: "api" } }, spec: { containers: [{ envFrom: [{ secretRef: { name: "shared" } }] }] } } } }, + { kind: "Deployment", metadata: { name: "selector-only" }, spec: { selector: { matchLabels: { app: "api" } }, template: { metadata: { labels: { app: "other" } } } } }, + { kind: "Service", metadata: { name: "api" }, spec: { selector: { app: "api" } } }, + { kind: "Service", metadata: { name: "empty" }, spec: { selector: {} } }, + ]; + const result = await importSource({ sourceKind: "kubernetes", path: "k8s.json", input: { type: "object", value: objects } }); + const nodes = result.diagram.pages[0].nodes; + assert.equal(new Set(nodes.map((node) => node.id)).size, nodes.length); + assert.ok(nodes.some((node) => node.label === "Secret/default/shared")); + assert.ok(nodes.some((node) => node.label === "Secret/team-b/shared")); + const labelById = new Map(nodes.map((node) => [node.id, node.label])); + const relationships = result.diagram.pages[0].edges.map((edge) => [labelById.get(edge.source), edge.kind, labelById.get(edge.target)]); + assert.deepEqual(relationships, [ + ["Deployment/default/api", "uses-secret", "Secret/default/shared"], + ["Deployment/team-b/api", "uses-secret", "Secret/team-b/shared"], + ["Service/default/api", "selects", "Deployment/default/api"], + ]); +}); + +test("imports Docker Compose service dependencies and named volumes", async () => { + const result = await importSource({ sourceKind: "docker-compose", path: "compose.json", input: { type: "object", value: { + services: { api: { depends_on: ["db"], volumes: ["data:/var/lib/app"] }, db: { image: "postgres" } }, volumes: { data: {} }, + } } }); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => [n.label, n.kind]), [["api", "service"], ["db", "service"], ["data", "volume"]]); + assert.deepEqual(result.diagram.pages[0].edges.map((e) => e.kind), ["depends-on", "mounts"]); +}); + +test("imports SQL tables and foreign keys", async () => { + const result = await textInput("sql", "schema.sql", "CREATE TABLE users (id INT PRIMARY KEY);\nCREATE TABLE orders (\n user_id INT,\n FOREIGN KEY (user_id) REFERENCES users(id)\n);\n"); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["orders", "users"]); + assert.deepEqual(result.diagram.pages[0].edges.map((e) => [e.source, e.target, e.kind]), [["table-6f7264657273", "table-7573657273", "foreign-key"]]); + assert.equal(result.diagram.pages[0].edges[0].provenance?.line, 4); +}); + +test("orders importer output by Unicode code point without locale-sensitive comparison", async () => { + const sql = await textInput("sql", "schema.sql", "CREATE TABLE a (id INT);\nCREATE TABLE Z (id INT);\n"); + assert.deepEqual(sql.diagram.pages[0].nodes.map((node) => node.label), ["Z", "a"]); + const kubernetes = await importSource({ sourceKind: "kubernetes", path: "k8s.json", input: { type: "object", value: [ + { kind: "Service", metadata: { name: "a" }, spec: { selector: {} } }, + { kind: "Service", metadata: { name: "Z" }, spec: { selector: {} } }, + { kind: "Service", metadata: { name: "😀" }, spec: { selector: {} } }, + { kind: "Service", metadata: { name: "\uE000" }, spec: { selector: {} } }, + ] } }); + assert.deepEqual(kubernetes.diagram.pages[0].nodes.map((node) => node.label), ["Service/default/Z", "Service/default/a", "Service/default/\uE000", "Service/default/😀"]); +}); + +test("imports OpenAPI operations and schema references", async () => { + const result = await importSource({ sourceKind: "openapi", path: "openapi.json", input: { type: "object", value: { + openapi: "3.1.0", paths: { "/pets": { get: { operationId: "listPets", responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Pet" } } } } } } } }, + components: { schemas: { Pet: { type: "object" } } }, + } } }); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => [n.label, n.kind]), [["GET /pets", "command"], ["Pet", "library"]]); + assert.equal(result.diagram.pages[0].edges[0].kind, "schema-reference"); +}); + +test("OpenAPI inherited schema names remain unknown references", async () => { + const result = await importSource({ sourceKind: "openapi", path: "openapi.json", input: { type: "object", value: { + paths: { "/unsafe": { get: { responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/toString" } } } } } } } }, + components: { schemas: {} }, + } } }); + assert.deepEqual(result.diagram.pages[0].nodes.map((node) => node.label), ["GET /unsafe"]); + assert.deepEqual(result.diagram.pages[0].edges, []); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["unknown-reference"]); +}); + +test("imports CI job DAG needs", async () => { + const result = await importSource({ sourceKind: "ci", path: ".github/workflows/ci.json", input: { type: "object", value: { + jobs: { deploy: { needs: ["build", "test"] }, test: { needs: "build" }, build: { "runs-on": "ubuntu-latest" } }, + } } }); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => [n.label, n.kind]), [["build", "command"], ["deploy", "command"], ["test", "command"]]); + assert.deepEqual(result.diagram.pages[0].edges.map((e) => [e.source, e.target]), [["command-6275696c64", "command-6465706c6f79"], ["command-74657374", "command-6465706c6f79"], ["command-6275696c64", "command-74657374"]]); +}); + +test("keeps generated IDs unique for normalized names, ambiguous composites, and repeated relationships", async () => { + const result = await importSource({ sourceKind: "ci", path: "ci.json", input: { type: "object", value: { + jobs: { + A: {}, + "A": {}, + a: {}, + "a-needs-b": {}, + "b-needs-c": { needs: ["a", "a"] }, + c: { needs: "a-needs-b" }, + }, + } } }); + const nodes = result.diagram.pages[0].nodes; + const edges = result.diagram.pages[0].edges; + assert.equal(new Set(nodes.map((node) => node.id)).size, nodes.length); + assert.equal(new Set(edges.map((edge) => edge.id)).size, edges.length); + assert.equal(edges.length, 3); + assert.doesNotThrow(() => validateDiagramIR(result.diagram)); +}); + +test("keeps sanitized IDs unique and reports duplicate logical definitions", async () => { + const openapi = await importSource({ sourceKind: "openapi", path: "api.json", input: { type: "object", value: { paths: { "/x": { get: {} } }, components: { schemas: { "A/B": {}, "A-B": {} } } } } }); + assert.equal(new Set(openapi.diagram.pages[0].nodes.map((n) => n.id)).size, openapi.diagram.pages[0].nodes.length); + const sql = await textInput("sql", "schema.sql", "CREATE TABLE users (id INT);\nCREATE TABLE users (id INT);\n"); + assert.equal(sql.diagnostics[0].code, "duplicate-id"); + assert.doesNotThrow(() => validateDiagramIR(openapi.diagram)); +}); + +test("disambiguates every repeated relationship occurrence deterministically", async () => { + const sqlText = "CREATE TABLE users (id INT PRIMARY KEY);\nCREATE TABLE orders (\n a INT REFERENCES users(id),\n b INT REFERENCES users(id)\n);\n"; + const terraformText = "resource \"aws_vpc\" \"main\" {}\nresource \"aws_subnet\" \"web\" {\n first = aws_vpc.main.id\n second = aws_vpc.main.id\n}\n"; + const composeValue = { services: { api: { depends_on: ["db", "db"], volumes: ["data:/one", "data:/two"] }, db: {} }, volumes: { data: {} } }; + const results = await Promise.all([ + textInput("sql", "schema.sql", sqlText), + textInput("terraform", "main.tf", terraformText), + importSource({ sourceKind: "docker-compose", path: "compose.json", input: { type: "object", value: composeValue } }), + ]); + const expectedEdgeCounts = [2, 2, 4]; + results.forEach((result, index) => { + const edges = result.diagram.pages[0].edges; + assert.equal(edges.length, expectedEdgeCounts[index]); + assert.equal(new Set(edges.map((edge) => edge.id)).size, edges.length); + assert.doesNotThrow(() => validateDiagramIR(result.diagram)); + }); + const again = await textInput("terraform", "main.tf", terraformText); + assert.deepEqual(again.diagram.pages[0].edges.map((edge) => edge.id), results[1].diagram.pages[0].edges.map((edge) => edge.id)); +}); + +test("rejects oversized and malformed inputs without executing object values", async () => { + await assert.rejects(() => importSource({ sourceKind: "python", path: "x.py", maxBytes: 4, input: { type: "text", text: "import os" } }), /exceeds.*4/i); + await assert.rejects(() => importSource({ sourceKind: "ci", path: "ci.json", input: { type: "object", value: { jobs: null } } }), /malformed CI/i); + let accessed = false; + const tagged = Object.create({ yamlTag: "!exec" }) as Record; + Object.defineProperty(tagged, "jobs", { enumerable: true, get() { accessed = true; return {}; } }); + await assert.rejects(() => importSource({ sourceKind: "ci", path: "ci.json", input: { type: "object", value: tagged } }), /plain JSON/i); + assert.equal(accessed, false); +}); + +test("rejects object input beyond the conservative nesting bound without stack overflow", async () => { + const root: Record = {}; + let cursor = root; + for (let depth = 0; depth < 10_000; depth += 1) { + const child: Record = {}; + cursor.child = child; + cursor = child; + } + await assert.rejects( + () => importSource({ sourceKind: "ci", path: "ci.json", input: { type: "object", value: root } }), + /nesting.*128/i, + ); +}); + +test("rejects file traversal and symlink escapes from the declared root", async () => { + const base = await mkdtemp(join(tmpdir(), "source-import-")); + const root = join(base, "root"); await mkdir(root); + await writeFile(join(base, "outside.py"), "import os\n"); + await symlink(join(base, "outside.py"), join(root, "escape.py")); + await assert.rejects(() => importSource({ sourceKind: "python", path: "../outside.py", input: { type: "file", root } }), /outside.*root|escape/i); + await assert.rejects(() => importSource({ sourceKind: "python", path: "escape.py", input: { type: "file", root } }), /outside.*root|symlink.*escape/i); +}); + +test("refuses even root-confined symlinks so substitution cannot change an opened source", async () => { + const root = await mkdtemp(join(tmpdir(), "source-no-follow-")); + await writeFile(join(root, "real.py"), "import safe\n"); + await symlink("real.py", join(root, "substituted.py")); + await assert.rejects( + () => importSource({ sourceKind: "python", path: "substituted.py", input: { type: "file", root } }), + /symlink|symbolic|safe source/i, + ); +}); + +test("ingests directories in deterministic path order with confined provenance", async () => { + const root = await mkdtemp(join(tmpdir(), "source-directory-")); + await mkdir(join(root, "src")); + await writeFile(join(root, "src", "b.py"), "import zlib\n"); + await writeFile(join(root, "src", "a.py"), "from pkg import x\n"); + const request = { sourceKind: "python" as const, path: "src", input: { type: "file" as const, root } }; + const first = await importSource(request); const second = await importSource(request); + assert.equal(JSON.stringify(first), JSON.stringify(second)); + assert.deepEqual(first.diagram.pages[0].nodes.filter((n) => n.kind === "module").map((n) => n.label), ["src/a.py", "src/b.py"]); + assert.deepEqual(first.diagram.pages[0].nodes.filter((n) => n.kind === "library").map((n) => n.label), ["pkg", "zlib"]); + assert.equal(first.diagram.pages[0].edges[0].provenance?.path, "src/a.py"); + assert.doesNotThrow(() => validateDiagramIR(first.diagram)); +}); + +test("reports dynamic code imports as unknown constructs instead of guessing", async () => { + const python = await textInput("python", "dynamic.py", "module = __import__(name)\n"); + const javascript = await textInput("javascript", "dynamic.js", "const module = await import(name);\n"); + assert.equal(python.diagnostics[0].code, "unknown-construct"); + assert.equal(javascript.diagnostics[0].code, "unknown-construct"); + assert.deepEqual(python.diagram.pages[0].nodes.map((n) => n.label), ["dynamic.py"]); +}); + +test("loads YAML data without permitting custom tags", async () => { + const root = await mkdtemp(join(tmpdir(), "source-yaml-")); + await writeFile(join(root, "objects.yaml"), "apiVersion: v1\nkind: Service\nmetadata:\n name: api\nspec:\n selector:\n app: api\n"); + await writeFile(join(root, "tagged.yaml"), "jobs: !exec dangerous\n"); + const result = await importSource({ sourceKind: "kubernetes", path: "objects.yaml", input: { type: "file", root } }); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["Service/default/api"]); + await assert.rejects(() => importSource({ sourceKind: "ci", path: "tagged.yaml", input: { type: "file", root } }), /tag|malformed YAML/i); +}); diff --git a/scripts/src/services/source-importers/index.ts b/scripts/src/services/source-importers/index.ts new file mode 100644 index 0000000..5232af9 --- /dev/null +++ b/scripts/src/services/source-importers/index.ts @@ -0,0 +1,745 @@ +import { constants } from "node:fs"; +import { open, readdir, realpath } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { isAbsolute, relative, resolve } from "node:path"; + +import { JSON_SCHEMA, loadAll } from "js-yaml"; + +import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; + +export type SourceKind = "python" | "javascript" | "typescript" | "go" | "rust" | "terraform" | "kubernetes" | "docker-compose" | "sql" | "openapi" | "ci"; +export type SourceInput = { type: "text"; text: string } | { type: "object"; value: unknown } | { type: "file"; root: string }; +export interface SourceImportRequest { sourceKind: SourceKind; path: string; input: SourceInput; maxBytes?: number } +export interface ImportDiagnostic { code: string; severity: "warning" | "error"; message: string; path: string; line?: number } +export interface SourceImportResult { diagram: DiagramIRV2; diagnostics: ImportDiagnostic[]; provenance: { sourceKind: SourceKind; path: string } } + +function id(prefix: string, ...values: string[]): string { + const encoded = values.map((value) => Buffer.from(value, "utf8").toString("hex") || "0"); + return `${prefix}-${encoded.join(".")}`; +} + +function codePointCompare(left: string, right: string): number { + const leftPoints = [...left]; + const rightPoints = [...right]; + const length = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < length; index += 1) { + const difference = leftPoints[index].codePointAt(0)! - rightPoints[index].codePointAt(0)!; + if (difference !== 0) return difference; + } + return leftPoints.length - rightPoints.length; +} + +function diagramFor(request: SourceImportRequest, nodes: DiagramNode[], edges: DiagramEdge[]): DiagramIRV2 { + return validateDiagramIR({ version: 2, title: request.path, pages: [{ id: "source-import", title: request.path, nodes, edges, layout: { type: "layered", direction: "horizontal" } }], provenance: { sourceKind: request.sourceKind, path: request.path } }) as DiagramIRV2; +} + +function codeImports(kind: "python" | "go", text: string): Array<{ name: string; line: number }> { + const imports: Array<{ name: string; line: number }> = []; + let goBlock = false; + text.split(/\r?\n/).forEach((line, index) => { + let name: string | undefined; + if (kind === "python") { + const match = line.match(/^\s*(?:import\s+([A-Za-z_][\w.]*)|from\s+([A-Za-z_][\w.]*)\s+import\s+)/); + name = match?.[1] ?? match?.[2]; + } else if (kind === "go") { + if (/^\s*import\s*\(\s*$/.test(line)) { goBlock = true; return; } + if (goBlock && /^\s*\)/.test(line)) { goBlock = false; return; } + const match = goBlock ? line.match(/^\s*(?:[._A-Za-z]\w*\s+)?"([^"]+)"/) : line.match(/^\s*import\s+(?:[._A-Za-z]\w*\s+)?"([^"]+)"/); + name = match?.[1]; + } + if (name) imports.push({ name, line: index + 1 }); + }); + return imports; +} + +interface MaskedSource { text: string; unknownLines: number[] } + +function maskRange(characters: string[], start: number, end: number): void { + for (let index = start; index < end; index += 1) if (characters[index] !== "\n" && characters[index] !== "\r") characters[index] = " "; +} + +function maskPythonTripleStrings(text: string): MaskedSource { + const characters = text.split(""); + const unknownLines: number[] = []; + let offset = 0; let line = 1; + while (offset < text.length) { + if (text[offset] === "\n") { line += 1; offset += 1; continue; } + const quote = text.startsWith("'''", offset) ? "'''" : text.startsWith('"""', offset) ? '"""' : undefined; + if (!quote) { offset += 1; continue; } + const start = offset; const startLine = line; + offset += 3; + let closed = false; + while (offset < text.length) { + if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; } + if (text[offset] === "\n") line += 1; + if (text.startsWith(quote, offset)) { offset += 3; closed = true; break; } + offset += 1; + } + maskRange(characters, start, offset); + if (!closed) unknownLines.push(startLine); + } + return { text: characters.join(""), unknownLines }; +} + +function maskComments(text: string, lineMarkers: string[], nestedBlock = false, maskedQuotes = "", rustRawStrings = false): MaskedSource { + const characters = text.split(""); + const unknownLines: number[] = []; + let offset = 0; let line = 1; + while (offset < text.length) { + if (text[offset] === "\n") { line += 1; offset += 1; continue; } + const marker = lineMarkers.find((candidate) => text.startsWith(candidate, offset)); + if (marker) { + const start = offset; + while (offset < text.length && text[offset] !== "\n") offset += 1; + maskRange(characters, start, offset); + continue; + } + if (text.startsWith("/*", offset)) { + const start = offset; const startLine = line; + offset += 2; + let depth = 1; + while (offset < text.length && depth > 0) { + if (text[offset] === "\n") line += 1; + if (nestedBlock && text.startsWith("/*", offset)) { depth += 1; offset += 2; continue; } + if (text.startsWith("*/", offset)) { depth -= 1; offset += 2; continue; } + offset += 1; + } + maskRange(characters, start, offset); + if (depth > 0) unknownLines.push(startLine); + continue; + } + if (rustRawStrings && text[offset] === "r") { + let delimiterEnd = offset + 1; + while (text[delimiterEnd] === "#") delimiterEnd += 1; + if (text[delimiterEnd] === '"') { + const start = offset; const startLine = line; + const terminator = `"${"#".repeat(delimiterEnd - offset - 1)}`; + offset = delimiterEnd + 1; + const rawEnd = text.indexOf(terminator, offset); + const closed = rawEnd >= 0; + const next = closed ? rawEnd + terminator.length : text.length; + line += (text.slice(offset, next).match(/\n/g) ?? []).length; + offset = next; + maskRange(characters, start, offset); + if (!closed) unknownLines.push(startLine); + continue; + } + } + if (text[offset] === "'" || text[offset] === '"' || text[offset] === "`") { + const quote = text[offset]; const start = offset; const startLine = line; + offset += 1; + let closed = false; + while (offset < text.length) { + if (text[offset] === "\\" && quote !== "`" && offset + 1 < text.length) { offset += 2; continue; } + if (quote === "'" && text[offset] === "'" && text[offset + 1] === "'") { offset += 2; continue; } + if (text[offset] === "\n") line += 1; + if (text[offset] === quote) { offset += 1; closed = true; break; } + offset += 1; + } + if (maskedQuotes.includes(quote)) maskRange(characters, start, offset); + if (!closed) unknownLines.push(startLine); + continue; + } + offset += 1; + } + return { text: characters.join(""), unknownLines }; +} + +function maskTerraformHeredocs(text: string): MaskedSource { + const characters = text.split(""); + const unknownLines: number[] = []; + const heredocOpening = /<<(-?)([A-Za-z_]\w*)[ \t]*(?:\r?\n)/y; + let offset = 0; let line = 1; + while (offset < text.length) { + if (text[offset] === "\n") { line += 1; offset += 1; continue; } + if (text[offset] === "#" || text.startsWith("//", offset)) { + while (offset < text.length && text[offset] !== "\n") offset += 1; + continue; + } + if (text.startsWith("/*", offset)) { + const end = text.indexOf("*/", offset + 2); + const next = end < 0 ? text.length : end + 2; + line += (text.slice(offset, next).match(/\n/g) ?? []).length; + offset = next; + continue; + } + if (text[offset] === '"') { + offset += 1; + while (offset < text.length) { + if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; } + if (text[offset] === "\n") line += 1; + if (text[offset] === '"') { offset += 1; break; } + offset += 1; + } + continue; + } + heredocOpening.lastIndex = offset; + const opening = heredocOpening.exec(text); + if (!opening) { offset += 1; continue; } + const start = offset; const startLine = line; + const indented = opening[1] === "-"; + const delimiter = opening[2]; + offset += opening[0].length; + line += 1; + let closed = false; + while (offset < text.length) { + const newline = text.indexOf("\n", offset); + const end = newline < 0 ? text.length : newline; + const candidate = text.slice(offset, end).replace(/\r$/, ""); + const expected = indented ? candidate.trim() : candidate.trimEnd(); + if (expected === delimiter && (indented || candidate === expected)) { + offset = end; + closed = true; + break; + } + offset = newline < 0 ? text.length : newline + 1; + if (newline >= 0) line += 1; + } + maskRange(characters, start, offset); + if (!closed) unknownLines.push(startLine); + } + return { text: characters.join(""), unknownLines }; +} + +function maskTerraformStrings(text: string): MaskedSource { + const characters = text.split(""); + const unknownLines: number[] = []; + let offset = 0; let line = 1; + while (offset < text.length) { + if (text[offset] === "\n") { line += 1; offset += 1; continue; } + if (text[offset] !== '"') { offset += 1; continue; } + const start = offset; const startLine = line; + offset += 1; + let closed = false; + while (offset < text.length) { + if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; } + if (text[offset] === "\n") line += 1; + if (text[offset] === '"') { offset += 1; closed = true; break; } + offset += 1; + } + maskRange(characters, start, offset); + if (!closed) unknownLines.push(startLine); + } + return { text: characters.join(""), unknownLines }; +} + +interface JavaScriptToken { kind: "identifier" | "string" | "punctuation"; value: string; line: number } + +function scanJavaScriptImports(text: string): { imports: Array<{ name: string; line: number }>; unknownLines: number[] } { + const tokens: JavaScriptToken[] = []; + const unknownLines = new Set(); + let offset = 0; + let line = 1; + while (offset < text.length) { + const character = text[offset]; + if (character === "\n") { line += 1; offset += 1; continue; } + if (/\s/.test(character)) { offset += 1; continue; } + if (character === "/" && text[offset + 1] === "/") { + offset += 2; + while (offset < text.length && text[offset] !== "\n") offset += 1; + continue; + } + if (character === "/" && text[offset + 1] === "*") { + const startLine = line; + offset += 2; + let closed = false; + while (offset < text.length) { + if (text[offset] === "\n") line += 1; + if (text[offset] === "*" && text[offset + 1] === "/") { offset += 2; closed = true; break; } + offset += 1; + } + if (!closed) unknownLines.add(startLine); + continue; + } + if (character === "/") { + const previous = tokens[tokens.length - 1]; + const followsArrow = previous?.value === ">" && tokens[tokens.length - 2]?.value === "="; + const regexPrefix = !previous || followsArrow || (previous.kind === "punctuation" && "=([{,:;!&|?".includes(previous.value)) || (previous.kind === "identifier" && new Set(["return", "case", "throw", "typeof", "instanceof", "in", "of", "yield", "await"]).has(previous.value)); + if (regexPrefix) { + const startLine = line; + let inCharacterClass = false; + let closed = false; + offset += 1; + while (offset < text.length) { + if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; } + if (text[offset] === "\n") break; + if (text[offset] === "[") inCharacterClass = true; + else if (text[offset] === "]") inCharacterClass = false; + else if (text[offset] === "/" && !inCharacterClass) { offset += 1; closed = true; break; } + offset += 1; + } + while (closed && offset < text.length && /[A-Za-z]/.test(text[offset])) offset += 1; + if (!closed) unknownLines.add(startLine); + continue; + } + } + if (character === "'" || character === '"') { + const quote = character; + const startLine = line; + let value = ""; + offset += 1; + let closed = false; + while (offset < text.length) { + const child = text[offset]; + if (child === "\\" && offset + 1 < text.length) { value += child + text[offset + 1]; offset += 2; continue; } + if (child === quote) { offset += 1; closed = true; break; } + if (child === "\n") line += 1; + value += child; + offset += 1; + } + if (closed) tokens.push({ kind: "string", value, line: startLine }); + else unknownLines.add(startLine); + continue; + } + if (character === "`") { + const startLine = line; + let closed = false; + let hasExpression = false; + offset += 1; + while (offset < text.length) { + if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; } + if (text[offset] === "$" && text[offset + 1] === "{") hasExpression = true; + if (text[offset] === "\n") line += 1; + if (text[offset] === "`") { offset += 1; closed = true; break; } + offset += 1; + } + if (hasExpression || !closed) unknownLines.add(startLine); + continue; + } + if (/[A-Za-z_$]/.test(character)) { + const start = offset; + offset += 1; + while (offset < text.length && /[A-Za-z0-9_$]/.test(text[offset])) offset += 1; + tokens.push({ kind: "identifier", value: text.slice(start, offset), line }); + continue; + } + tokens.push({ kind: "punctuation", value: character, line }); + offset += 1; + } + + const imports: Array<{ name: string; line: number }> = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.kind !== "identifier") continue; + if (token.value === "require" && tokens[index - 1]?.value !== "." && tokens[index + 1]?.value === "(") { + const argument = tokens[index + 2]; + if (argument?.kind === "string" && tokens[index + 3]?.value === ")") imports.push({ name: argument.value, line: token.line }); + else unknownLines.add(token.line); + continue; + } + if (token.value !== "import" && token.value !== "export") continue; + if (tokens[index - 1]?.value === ".") continue; + const next = tokens[index + 1]; + if (token.value === "import" && next?.value === "(") { unknownLines.add(token.line); continue; } + const previous = tokens[index - 1]; + const statementPosition = !previous || previous.value === ";" || previous.value === "}" || previous.line < token.line; + if (!statementPosition) continue; + if (token.value === "import" && next?.kind === "string") { imports.push({ name: next.value, line: token.line }); continue; } + let found = false; + for (let cursor = index + 1; cursor < tokens.length && cursor <= index + 256; cursor += 1) { + if (tokens[cursor].value === ";") break; + if (tokens[cursor].value === "from" && tokens[cursor + 1]?.kind === "string") { + imports.push({ name: tokens[cursor + 1].value, line: token.line }); + found = true; + break; + } + } + if (!found && token.value === "import") unknownLines.add(token.line); + } + return { imports, unknownLines: [...unknownLines].sort((a, b) => a - b) }; +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +} + +function objectName(value: unknown): string | undefined { + const name = record(record(value)?.metadata)?.name; + return typeof name === "string" && name.length > 0 ? name : undefined; +} + +function collectWorkloadSecretRefs(podSpecValue: unknown, refs: Set): void { + const podSpec = record(podSpecValue); + if (!podSpec) return; + const addName = (value: unknown): void => { + const name = record(value)?.name; + if (typeof name === "string" && name.length > 0) refs.add(name); + }; + for (const pullSecret of Array.isArray(podSpec.imagePullSecrets) ? podSpec.imagePullSecrets : []) addName(pullSecret); + for (const containerKey of ["initContainers", "containers", "ephemeralContainers"]) { + for (const containerValue of Array.isArray(podSpec[containerKey]) ? podSpec[containerKey] as unknown[] : []) { + const container = record(containerValue); + if (!container) continue; + for (const source of Array.isArray(container.envFrom) ? container.envFrom : []) addName(record(source)?.secretRef); + for (const environment of Array.isArray(container.env) ? container.env : []) addName(record(record(environment)?.valueFrom)?.secretKeyRef); + } + } + for (const volumeValue of Array.isArray(podSpec.volumes) ? podSpec.volumes : []) { + const volume = record(volumeValue); + const secretName = record(volume?.secret)?.secretName; + if (typeof secretName === "string" && secretName.length > 0) refs.add(secretName); + const sources = record(volume?.projected)?.sources; + for (const source of Array.isArray(sources) ? sources : []) addName(record(source)?.secret); + } +} + +function collectSchemaRefs(value: unknown, refs: Set, seen = new WeakSet()): void { + if (value === null || typeof value !== "object" || seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { value.forEach((item) => collectSchemaRefs(item, refs, seen)); return; } + for (const [key, child] of Object.entries(value as Record)) { + if (key === "$ref" && typeof child === "string" && child.startsWith("#/components/schemas/")) refs.add(child.slice("#/components/schemas/".length)); + else collectSchemaRefs(child, refs, seen); + } +} + +function validateBoundedInput(request: SourceImportRequest): void { + const limit = request.maxBytes ?? 1_048_576; + if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 8_388_608) throw new Error("maxBytes must be an integer between 1 and 8388608"); + if (!request.path || request.path.includes("\0")) throw new Error("Source path must be a non-empty safe string"); + if (request.input.type === "file") return; + if (request.input.type === "text") { + const size = new TextEncoder().encode(request.input.text).byteLength; + if (size > limit) throw new Error(`Source input exceeds maxBytes ${limit}`); + return; + } + let size = 0; + const seen = new WeakSet(); + const pending: Array<{ value: unknown; depth: number }> = [{ value: request.input.value, depth: 0 }]; + while (pending.length > 0) { + const { value, depth } = pending.pop()!; + if (depth > 128) throw new Error("Object input nesting exceeds maximum depth 128"); + if (value === null || typeof value === "boolean") size += 4; + else if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("Object input must contain finite JSON values"); + size += 8; + } else if (typeof value === "string") size += new TextEncoder().encode(value).byteLength; + else { + if (typeof value !== "object") throw new Error("Object input must contain plain JSON values"); + if (seen.has(value)) throw new Error("Object input must contain acyclic plain JSON values"); + seen.add(value); + if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error("Object input must contain plain JSON objects"); + const descriptors = Object.getOwnPropertyDescriptors(value); + for (const [key, descriptor] of Object.entries(descriptors)) { + if (key === "length") continue; + if (!("value" in descriptor)) throw new Error("Object input must contain plain JSON data properties"); + size += new TextEncoder().encode(key).byteLength; + if (size > limit) throw new Error(`Source input exceeds maxBytes ${limit}`); + pending.push({ value: descriptor.value, depth: depth + 1 }); + } + } + if (size > limit) throw new Error(`Source input exceeds maxBytes ${limit}`); + } +} + +async function importInline(request: SourceImportRequest): Promise { + validateBoundedInput(request); + if (request.sourceKind === "ci" && request.input.type === "object") { + const jobs = record(record(request.input.value)?.jobs); + if (!jobs || Object.keys(jobs).length === 0) throw new Error("Malformed CI input: jobs must be a non-empty object"); + const names = Object.keys(jobs).sort(codePointCompare); + const nodes: DiagramNode[] = names.map((name) => ({ id: id("command", name), label: name, kind: "command", provenance: { path: request.path } })); + const edges: DiagramEdge[] = []; const diagnostics: ImportDiagnostic[] = []; + for (const name of names) { + const rawNeeds = record(jobs[name])?.needs; + const needs = (Array.isArray(rawNeeds) ? rawNeeds : rawNeeds === undefined ? [] : [rawNeeds]).filter((value): value is string => typeof value === "string").sort(codePointCompare); + for (const dependency of needs) { + if (!names.includes(dependency)) { diagnostics.push({ code: "unknown-reference", severity: "warning", message: `Unknown CI job: ${dependency}`, path: request.path }); continue; } + edges.push({ id: id("edge", dependency, "needs", name, String(edges.length)), source: id("command", dependency), target: id("command", name), kind: "needs", provenance: { path: request.path } }); + } + } + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; + } + if (request.sourceKind === "openapi" && request.input.type === "object") { + const root = record(request.input.value); const paths = record(root?.paths); const schemas = record(record(root?.components)?.schemas) ?? {}; + if (!root || !paths) throw new Error("Malformed OpenAPI input: paths must be an object"); + const nodes: DiagramNode[] = []; const edges: DiagramEdge[] = []; const diagnostics: ImportDiagnostic[] = []; + const methods = new Set(["get", "put", "post", "delete", "patch", "options", "head", "trace"]); + for (const path of Object.keys(paths).sort(codePointCompare)) { + const pathItem = record(paths[path]) ?? {}; + for (const method of Object.keys(pathItem).filter((key) => methods.has(key.toLowerCase())).sort(codePointCompare)) { + const operationId = id("command", method, path); + nodes.push({ id: operationId, label: `${method.toUpperCase()} ${path}`, kind: "command", provenance: { path: request.path } }); + const refs = new Set(); collectSchemaRefs(pathItem[method], refs); + for (const schema of [...refs].sort(codePointCompare)) { + if (!Object.prototype.hasOwnProperty.call(schemas, schema)) { diagnostics.push({ code: "unknown-reference", severity: "warning", message: `Unknown schema: ${schema}`, path: request.path }); continue; } + edges.push({ id: id("edge", method, path, schema, String(edges.length)), source: operationId, target: id("schema", schema), kind: "schema-reference", provenance: { path: request.path } }); + } + } + } + for (const schema of Object.keys(schemas).sort(codePointCompare)) nodes.push({ id: id("schema", schema), label: schema, kind: "library", provenance: { path: request.path } }); + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; + } + if (request.sourceKind === "sql" && request.input.type === "text") { + const scanned = maskComments(request.input.text, ["--"], false, "'"); + const tables = new Map(); const refs: Array<{ source: string; target: string; line: number }> = []; + const diagnostics: ImportDiagnostic[] = scanned.unknownLines.map((line) => ({ code: "unknown-construct", severity: "warning", message: "SQL construct is outside the bounded static subset", path: request.path, line })); + let current: string | undefined; + scanned.text.split(/\r?\n/).forEach((line, index) => { + const declaration = line.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`\[]?([A-Za-z_]\w*)/i)?.[1]; + if (declaration) { + if (tables.has(declaration)) diagnostics.push({ code: "duplicate-id", severity: "warning", message: `Duplicate table: ${declaration}`, path: request.path, line: index + 1 }); + else tables.set(declaration, index + 1); + current = declaration; + } + const target = line.match(/\bREFERENCES\s+["`\[]?([A-Za-z_]\w*)/i)?.[1]; + if (current && target) refs.push({ source: current, target, line: index + 1 }); + if (/\)\s*;/.test(line)) current = undefined; + }); + const nodes: DiagramNode[] = [...tables].sort(([a], [b]) => codePointCompare(a, b)).map(([name, line]) => ({ id: id("table", name), label: name, kind: "table", provenance: { path: request.path, line } })); + const edges: DiagramEdge[] = refs.filter((ref) => tables.has(ref.target)).map((ref, occurrence) => ({ id: id("edge", ref.source, "fk", ref.target, String(occurrence)), source: id("table", ref.source), target: id("table", ref.target), kind: "foreign-key", provenance: { path: request.path, line: ref.line } })); + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; + } + if (request.sourceKind === "docker-compose" && request.input.type === "object") { + const root = record(request.input.value); + const services = record(root?.services); + const volumes = record(root?.volumes) ?? {}; + if (!services || Object.keys(services).length === 0) throw new Error("Malformed Docker Compose input: services must be a non-empty object"); + const serviceNames = Object.keys(services).sort(codePointCompare); const volumeNames = Object.keys(volumes).sort(codePointCompare); + const nodes: DiagramNode[] = [ + ...serviceNames.map((name) => ({ id: id("service", name), label: name, kind: "service", provenance: { path: request.path } })), + ...volumeNames.map((name) => ({ id: id("volume", name), label: name, kind: "volume", provenance: { path: request.path } })), + ]; + const edges: DiagramEdge[] = []; const diagnostics: ImportDiagnostic[] = []; + for (const name of serviceNames) { + const service = record(services[name]) ?? {}; + const depends = Array.isArray(service.depends_on) ? service.depends_on : Object.keys(record(service.depends_on) ?? {}); + for (const dependency of depends.filter((value): value is string => typeof value === "string").sort(codePointCompare)) { + if (!serviceNames.includes(dependency)) { diagnostics.push({ code: "unknown-reference", severity: "warning", message: `Unknown service: ${dependency}`, path: request.path }); continue; } + edges.push({ id: id("edge", name, "depends", dependency, String(edges.length)), source: id("service", name), target: id("service", dependency), kind: "depends-on", provenance: { path: request.path } }); + } + for (const mount of (Array.isArray(service.volumes) ? service.volumes : []).filter((value): value is string => typeof value === "string").sort(codePointCompare)) { + const volume = mount.split(":", 1)[0]; + if (volumeNames.includes(volume)) edges.push({ id: id("edge", name, "mounts", volume, String(edges.length)), source: id("service", name), target: id("volume", volume), kind: "mounts", provenance: { path: request.path } }); + } + } + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; + } + if (request.sourceKind === "kubernetes" && request.input.type === "object") { + const values = Array.isArray(request.input.value) ? request.input.value : [request.input.value]; + const rawObjects = values.map(record).filter((value): value is Record => Boolean(value && typeof value.kind === "string" && objectName(value))); + const occurrences = new Map(); + const objects = rawObjects.map((value) => { + const kind = String(value.kind); + const name = objectName(value)!; + const rawNamespace = record(value.metadata)?.namespace; + const namespace = typeof rawNamespace === "string" && rawNamespace.length > 0 ? rawNamespace : "default"; + const key = JSON.stringify([kind, namespace, name]); + const occurrence = occurrences.get(key) ?? 0; + occurrences.set(key, occurrence + 1); + return { value, kind, name, namespace, nodeId: id("k8s", kind, namespace, name, String(occurrence)) }; + }); + const diagnostics: ImportDiagnostic[] = objects.filter((object) => object.kind === "Secret").map(() => ({ code: "secret-redacted", severity: "warning", message: "Kubernetes Secret payload omitted", path: request.path })); + const nodes: DiagramNode[] = objects.map((object) => ({ + id: object.nodeId, + label: `${object.kind}/${object.namespace}/${object.name}`, + kind: object.kind.toLowerCase(), + provenance: { path: request.path }, + })).sort((a, b) => codePointCompare(a.label, b.label)); + const byKey = new Map(); + for (const object of objects) { + const key = JSON.stringify([object.kind, object.namespace, object.name]); + if (!byKey.has(key)) byKey.set(key, object); + } + const workloadKinds = new Set(["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet", "Job"]); + const edges: DiagramEdge[] = []; + for (const object of objects) { + const secretRefs = new Set(); + if (workloadKinds.has(object.kind)) collectWorkloadSecretRefs(record(record(object.value.spec)?.template)?.spec, secretRefs); + for (const target of [...secretRefs].sort(codePointCompare)) { + const secret = byKey.get(JSON.stringify(["Secret", object.namespace, target])); + if (secret) edges.push({ id: id("edge", object.nodeId, "secret", secret.nodeId, String(edges.length)), source: object.nodeId, target: secret.nodeId, kind: "uses-secret", provenance: { path: request.path } }); + } + if (object.kind === "Service") { + const selector = record(record(object.value.spec)?.selector); + if (!selector || Object.keys(selector).length === 0) continue; + for (const workload of objects.filter((candidate) => workloadKinds.has(candidate.kind) && candidate.namespace === object.namespace)) { + const labels = record(record(record(record(workload.value.spec)?.template)?.metadata)?.labels); + if (labels && Object.entries(selector).every(([key, selected]) => labels[key] === selected)) { + edges.push({ id: id("edge", object.nodeId, "selects", workload.nodeId, String(edges.length)), source: object.nodeId, target: workload.nodeId, kind: "selects", provenance: { path: request.path } }); + } + } + } + } + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; + } + if (request.sourceKind === "terraform" && request.input.type === "text") { + const heredocs = maskTerraformHeredocs(request.input.text); + const structural = maskComments(heredocs.text, ["#", "//"]); + const referencesOnly = maskTerraformStrings(structural.text); + const unknownLines = [...new Set([...heredocs.unknownLines, ...structural.unknownLines, ...referencesOnly.unknownLines])].sort((a, b) => a - b); + const diagnostics: ImportDiagnostic[] = unknownLines.map((line) => ({ code: "unknown-construct", severity: "warning", message: "Terraform construct is outside the bounded static subset", path: request.path, line })); + const resources = new Map(); + const references: Array<{ source: string; target: string; line: number }> = []; + const referenceLines = referencesOnly.text.split(/\r?\n/); + let current: string | undefined; + structural.text.split(/\r?\n/).forEach((line, index) => { + const declaration = line.match(/^\s*resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/); + if (declaration) { + current = `${declaration[1]}.${declaration[2]}`; + resources.set(current, index + 1); + if (/\{\s*\}/.test(line)) current = undefined; + return; + } + if (current) { + for (const match of referenceLines[index].matchAll(/\b([A-Za-z_]\w*\.[A-Za-z_]\w*)\.[A-Za-z_]\w*/g)) references.push({ source: current, target: match[1], line: index + 1 }); + if (/^\s*}/.test(line)) current = undefined; + } + }); + const nodes = [...resources].sort(([a], [b]) => codePointCompare(a, b)).map(([name, line]) => ({ id: id("resource", name), label: name, kind: "resource", provenance: { path: request.path, line } })); + const edges = references.filter((ref) => resources.has(ref.target)).sort((a, b) => codePointCompare(a.source, b.source) || codePointCompare(a.target, b.target) || a.line - b.line).map((ref, occurrence) => ({ id: id("edge", ref.source, ref.target, String(occurrence)), source: id("resource", ref.source), target: id("resource", ref.target), kind: "reference", provenance: { path: request.path, line: ref.line } })); + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; + } + if (!(["python", "javascript", "typescript", "go", "rust"] as SourceKind[]).includes(request.sourceKind) || request.input.type !== "text") throw new Error("Unsupported source input"); + const nodes: DiagramNode[] = [{ id: id("module", request.path), label: request.path, kind: "module", provenance: { path: request.path, line: 1 } }]; + const edges: DiagramEdge[] = []; + const diagnostics: ImportDiagnostic[] = []; + const javaScriptScan = request.sourceKind === "javascript" || request.sourceKind === "typescript" ? scanJavaScriptImports(request.input.text) : undefined; + const nonJavaScriptScan = request.sourceKind === "python" ? maskPythonTripleStrings(request.input.text) + : request.sourceKind === "go" ? maskComments(request.input.text, ["//"], false, "`") + : request.sourceKind === "rust" ? maskComments(request.input.text, ["//"], true, "", true) + : { text: request.input.text, unknownLines: [] }; + for (const line of javaScriptScan?.unknownLines ?? nonJavaScriptScan.unknownLines) diagnostics.push({ code: "unknown-construct", severity: "warning", message: `${request.sourceKind} construct is outside the bounded static-import subset`, path: request.path, line }); + nonJavaScriptScan.text.split(/\r?\n/).forEach((line, index) => { + const dynamicPython = request.sourceKind === "python" && /\b(?:__import__|importlib\.import_module)\s*\(\s*[^"']/.test(line); + if (dynamicPython) diagnostics.push({ code: "unknown-construct", severity: "warning", message: "Dynamic imports are not evaluated", path: request.path, line: index + 1 }); + }); + const seen = new Set(); + const foundImports = request.sourceKind === "rust" + ? nonJavaScriptScan.text.split(/\r?\n/).flatMap((line, index) => { + const mod = line.match(/^\s*mod\s+([A-Za-z_]\w*)\s*;/)?.[1]; + const use = line.match(/^\s*use\s+([^;]+);/)?.[1]; + if (/\w+!\s*\(/.test(line)) diagnostics.push({ code: "unknown-construct", severity: "warning", message: "Rust macros are not expanded", path: request.path, line: index + 1 }); + if (mod) return [{ name: mod, line: index + 1 }]; + if (use) { + const root = use.includes("::{") ? use.slice(0, use.indexOf("::{")) : use.split("::").slice(0, -1).join("::") || use; + return [{ name: root, line: index + 1 }]; + } + return []; + }) + : javaScriptScan?.imports ?? codeImports(request.sourceKind as "python" | "go", nonJavaScriptScan.text); + for (const found of foundImports) { + if (seen.has(found.name)) continue; + seen.add(found.name); + const targetId = id("library", found.name); + nodes.push({ id: targetId, label: found.name, kind: "library", provenance: { path: request.path, line: found.line } }); + edges.push({ id: id("edge", request.path, found.name, String(edges.length)), source: nodes[0].id, target: targetId, kind: "import", provenance: { path: request.path, line: found.line } }); + } + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; +} + +function isConfined(root: string, candidate: string): boolean { + const pathFromRoot = relative(root, candidate); + return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot)); +} + +async function openConfined(candidate: string, root: string): Promise<{ handle: FileHandle; canonical: string }> { + let handle: FileHandle; + try { + handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("Source symlink escape risk cannot be opened safely"); + throw error; + } + try { + const canonical = await realpath(`/proc/self/fd/${handle.fd}`); + if (!isConfined(root, canonical)) throw new Error("Opened source escapes outside declared root"); + return { handle, canonical }; + } catch (error) { + await handle.close(); + throw error; + } +} + +async function readBounded(handle: FileHandle, limit: number, directory: boolean): Promise { + const initial = await handle.stat(); + if (!initial.isFile()) throw new Error("Source entry must be a regular file"); + if (initial.size > limit) throw new Error(`${directory ? "Directory source input" : "Source input"} exceeds maxBytes ${limit}`); + const chunks: Buffer[] = []; + let consumed = 0; + while (consumed < limit) { + const chunk = Buffer.allocUnsafe(Math.min(65_536, limit - consumed)); + const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, null); + if (bytesRead === 0) break; + chunks.push(chunk.subarray(0, bytesRead)); + consumed += bytesRead; + } + const final = await handle.stat(); + if (initial.dev !== final.dev || initial.ino !== final.ino) throw new Error("Opened source identity changed during read"); + if (final.size > limit || consumed < initial.size) throw new Error(`${directory ? "Directory source input" : "Source input"} exceeds maxBytes ${limit}`); + return Buffer.concat(chunks, consumed); +} + +export async function importSource(request: SourceImportRequest): Promise { + validateBoundedInput(request); + if (request.input.type !== "file") return importInline(request); + if (isAbsolute(request.path)) throw new Error("Source path escapes outside declared root"); + const rootPath = await realpath(request.input.root); + const lexicalPath = resolve(rootPath, request.path); + if (!isConfined(rootPath, lexicalPath)) throw new Error("Source path escapes outside declared root"); + const limit = request.maxBytes ?? 1_048_576; + const objectKinds = new Set(["kubernetes", "docker-compose", "openapi", "ci"]); + const extensions: Record> = { + python: new Set([".py"]), javascript: new Set([".js", ".jsx", ".mjs", ".cjs"]), typescript: new Set([".ts", ".tsx", ".mts", ".cts"]), + go: new Set([".go"]), rust: new Set([".rs"]), terraform: new Set([".tf"]), sql: new Set([".sql"]), + kubernetes: new Set([".json", ".yaml", ".yml"]), "docker-compose": new Set([".json", ".yaml", ".yml"]), openapi: new Set([".json", ".yaml", ".yml"]), ci: new Set([".json", ".yaml", ".yml"]), + }; + const parse = async (buffer: Buffer, provenancePath: string): Promise => { + const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); + let input: SourceInput; + if (objectKinds.has(request.sourceKind)) { + const yaml = /\.ya?ml$/i.test(provenancePath); + try { + if (yaml) { + const documents = loadAll(text, undefined, { schema: JSON_SCHEMA, json: false }).filter((value) => value !== undefined); + if (documents.length === 0) throw new Error("empty"); + if (request.sourceKind !== "kubernetes" && documents.length !== 1) throw new Error("multiple documents"); + input = { type: "object", value: request.sourceKind === "kubernetes" ? documents : documents[0] }; + } else input = { type: "object", value: JSON.parse(text) as unknown }; + } catch { throw new Error(`Malformed ${yaml ? "YAML or disallowed tag" : "JSON"} source: ${provenancePath}`); } + } else input = { type: "text", text }; + return importInline({ ...request, path: provenancePath, input }); + }; + const source = await openConfined(lexicalPath, rootPath); + const info = await source.handle.stat(); + if (info.isFile()) { + try { return await parse(await readBounded(source.handle, limit, false), request.path); } + finally { await source.handle.close(); } + } + if (!info.isDirectory()) { await source.handle.close(); throw new Error("Source path must be a file or directory"); } + + const results: SourceImportResult[] = []; + let consumed = 0; + const walk = async (directory: FileHandle): Promise => { + for (const name of (await readdir(`/proc/self/fd/${directory.fd}`)).sort(codePointCompare)) { + const opened = await openConfined(resolve(`/proc/self/fd/${directory.fd}`, name), rootPath); + try { + const entryInfo = await opened.handle.stat(); + if (entryInfo.isDirectory()) await walk(opened.handle); + else if (entryInfo.isFile()) { + const dot = name.lastIndexOf("."); const extension = dot >= 0 ? name.slice(dot).toLowerCase() : ""; + if (extensions[request.sourceKind].has(extension)) { + const provenancePath = relative(rootPath, opened.canonical).split("\\").join("/"); + const buffer = await readBounded(opened.handle, limit - consumed, true); + consumed += buffer.byteLength; + results.push(await parse(buffer, provenancePath)); + } + } else throw new Error("Source directory contains an unsupported entry type"); + } finally { await opened.handle.close(); } + } + }; + try { await walk(source.handle); } + finally { await source.handle.close(); } + if (results.length === 0) throw new Error(`No supported ${request.sourceKind} source files found`); + const nodeById = new Map(); const edgeById = new Map(); const diagnostics: ImportDiagnostic[] = []; + for (const result of results) { + diagnostics.push(...result.diagnostics); + for (const node of result.diagram.pages[0].nodes) if (!nodeById.has(node.id)) nodeById.set(node.id, node); + for (const edge of result.diagram.pages[0].edges) { + if (edgeById.has(edge.id)) diagnostics.push({ code: "duplicate-id", severity: "warning", message: `Duplicate edge omitted: ${edge.id}`, path: String(edge.provenance?.path ?? request.path) }); + else edgeById.set(edge.id, edge); + } + } + return { diagram: diagramFor(request, [...nodeById.values()], [...edgeById.values()]), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; +} diff --git a/scripts/src/services/themes/theme-service.test.ts b/scripts/src/services/themes/theme-service.test.ts new file mode 100644 index 0000000..eadbf78 --- /dev/null +++ b/scripts/src/services/themes/theme-service.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js"; +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { BUILT_IN_THEMES, applyTheme, contrastRatio, validateTheme } from "./theme-service.js"; + +const IR: DiagramIRV2 = { + version: 2, + pages: [{ + id: "p", title: "Page", + nodes: [ + { id: "service", label: "Service", kind: "service", style: "shape=hexagon;fillColor=#123456;rounded=1;" }, + { id: "transparent", label: "Transparent", kind: "container", style: "swimlane;fillColor=none;horizontal=0;" }, + ], + edges: [{ id: "edge", source: "service", target: "transparent", style: "edgeStyle=orthogonalEdgeStyle;dashed=1;" }], + }], +}; + +test("built-in themes validate and meet role contrast requirements", () => { + assert.deepEqual(Object.keys(BUILT_IN_THEMES), ["default", "corporate", "dark", "colorblind-safe", "handdrawn"]); + for (const theme of Object.values(BUILT_IN_THEMES)) { + assert.doesNotThrow(() => validateTheme(theme)); + assert.ok(contrastRatio(theme.node.fontColor, theme.node.fillColor) >= 4.5, theme.name); + } +}); + +test("built-in theme registry and nested role styles reject mutation", () => { + const dark = BUILT_IN_THEMES.dark; + assert.ok(Object.isFrozen(dark)); + assert.ok(Object.isFrozen(dark.node)); + assert.ok(Object.isFrozen(dark.container)); + assert.ok(Object.isFrozen(dark.edge)); + assert.throws(() => { (dark.node as { fillColor: string }).fillColor = "#FFFFFF"; }, TypeError); + assert.equal(BUILT_IN_THEMES.dark.node.fillColor, "#243447"); +}); + +test("theme application is deterministic, immutable, role based, and preserves structural style", () => { + const snapshot = structuredClone(IR); + const first = applyTheme(IR, "corporate"); + assert.deepEqual(first, applyTheme(IR, "corporate")); + assert.deepEqual(IR, snapshot); + assert.match(first.pages[0].nodes[0].style!, /shape=hexagon/); + assert.match(first.pages[0].nodes[0].style!, /rounded=1/); + assert.match(first.pages[0].nodes[1].style!, /fillColor=none/); + assert.match(first.pages[0].nodes[1].style!, /swimlane/); + assert.match(first.pages[0].edges[0].style!, /edgeStyle=orthogonalEdgeStyle/); + assert.doesNotMatch(first.pages[0].nodes[0].style!, /rounded=0/); +}); + +test("theme result is deeply isolated from source objects", () => { + const source = structuredClone(IR); + source.assets = { icons: [{ name: "api" }] }; + source.pages[0].nodes[0].properties = { config: { retries: 3 } }; + source.pages[0].edges[0].extensions = { route: { owner: "network" } }; + const snapshot = structuredClone(source); + + const themed = applyTheme(source, "corporate"); + ((themed.assets!.icons as Array<{ name: string }>)[0]).name = "changed"; + ((themed.pages[0].nodes[0].properties!.config as { retries: number })).retries = 9; + ((themed.pages[0].edges[0].extensions!.route as { owner: string })).owner = "changed"; + + assert.deepEqual(source, snapshot); +}); + +test("theme application emits each visual style key exactly once", () => { + const source = structuredClone(IR); + source.pages[0].nodes[0].style = "shape=hexagon;fillColor=#000000;fillColor=#FFFFFF;strokeColor=#000000;strokeColor=#FFFFFF;fontColor=#000000;fontColor=#FFFFFF;sketch=0;sketch=0;"; + + const style = applyTheme(source, "handdrawn").pages[0].nodes[0].style!; + + for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) { + assert.equal(style.split(";").filter((token) => token.startsWith(`${key}=`)).length, 1, style); + } + assert.match(style, /fillColor=#FFFDF5;/); + assert.match(style, /strokeColor=#444444;/); + assert.match(style, /fontColor=#222222;/); + assert.match(style, /sketch=1;/); +}); + +test("theme application preserves page extensions and serializes its canvas background", () => { + const source = structuredClone(IR); + source.pages[0].extensions = { owner: "platform", $drawio: { attributes: { custom: "kept", "model:pageScale": "1.25" } } }; + + const themed = applyTheme(source, "dark"); + const xml = diagramIRToDrawio(themed); + + assert.deepEqual(themed.pages[0].extensions, { + owner: "platform", + $drawio: { attributes: { custom: "kept", "model:pageScale": "1.25", "model:background": "#111827" } }, + }); + assert.match(xml, /]* data-extensions="\{"owner":"platform"\}"[^>]* custom="kept"/); + assert.match(xml, /]* background="#111827"/); + assert.match(xml, /]* pageScale="1.25"/); + assert.doesNotMatch(xml, /data-extensions="[^"]*\$drawio/); +}); + +test("transparent nodes use a readable dark font on a light canvas", () => { + const custom = structuredClone(BUILT_IN_THEMES.dark); + custom.name = "custom-light-canvas"; + custom.backgroundColor = "#FFFFFF"; + custom.edge.fontColor = "#000000"; + + const style = applyTheme(IR, custom).pages[0].nodes[1].style!; + + assert.match(style, /fillColor=none;/); + assert.match(style, /fontColor=#000000;/); + assert.ok(contrastRatio("#000000", custom.backgroundColor) >= 4.5); +}); + +test("transparent nodes use a readable light font on a dark canvas", () => { + const custom = structuredClone(BUILT_IN_THEMES.default); + custom.name = "custom-dark-canvas"; + custom.backgroundColor = "#000000"; + custom.edge.fontColor = "#FFFFFF"; + + const source = structuredClone(IR); + source.pages[0].nodes[0].style = "shape=hexagon;fillColor=none;rounded=1;"; + const style = applyTheme(source, custom).pages[0].nodes[0].style!; + + assert.match(style, /fillColor=none;/); + assert.match(style, /fontColor=#FFFFFF;/); + assert.ok(contrastRatio("#FFFFFF", custom.backgroundColor) >= 4.5); +}); + +test("theme validation rejects invalid colors and insufficient contrast", () => { + const invalidColor = structuredClone(BUILT_IN_THEMES.default); + invalidColor.node.fillColor = "red"; + assert.throws(() => validateTheme(invalidColor), /color/i); + const lowContrast = structuredClone(BUILT_IN_THEMES.default); + lowContrast.node.fontColor = "#777777"; + lowContrast.node.fillColor = "#777777"; + assert.throws(() => validateTheme(lowContrast), /contrast/i); +}); diff --git a/scripts/src/services/themes/theme-service.ts b/scripts/src/services/themes/theme-service.ts new file mode 100644 index 0000000..901a8f8 --- /dev/null +++ b/scripts/src/services/themes/theme-service.ts @@ -0,0 +1,148 @@ +import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js"; + +export type BuiltInThemeName = "default" | "corporate" | "dark" | "colorblind-safe" | "handdrawn"; + +export interface ThemeRoleStyle { + fillColor: string; + strokeColor: string; + fontColor: string; +} + +export interface ThemeDefinition { + name: BuiltInThemeName | (string & {}); + backgroundColor: string; + node: ThemeRoleStyle; + container: ThemeRoleStyle; + edge: Pick; + sketch?: boolean; +} + +function deepFreeze(value: T): T { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + for (const nested of Object.values(value as Record)) deepFreeze(nested); + Object.freeze(value); + } + return value; +} + +export const BUILT_IN_THEMES: Readonly> = deepFreeze({ + default: theme("default", "#FFFFFF", ["#FFFFFF", "#4A5568", "#1A202C"], ["#F7FAFC", "#718096", "#1A202C"], ["#4A5568", "#1A202C"]), + corporate: theme("corporate", "#FFFFFF", ["#E8F0FE", "#1F5AA6", "#102A43"], ["#F5F8FC", "#526D82", "#102A43"], ["#1F5AA6", "#102A43"]), + dark: theme("dark", "#111827", ["#243447", "#94A3B8", "#FFFFFF"], ["#1F2937", "#64748B", "#FFFFFF"], ["#CBD5E1", "#FFFFFF"]), + "colorblind-safe": theme("colorblind-safe", "#FFFFFF", ["#FFF4CC", "#0072B2", "#1A1A1A"], ["#E8F3F8", "#009E73", "#1A1A1A"], ["#0072B2", "#1A1A1A"]), + handdrawn: { ...theme("handdrawn", "#FFFDF5", ["#FFFDF5", "#444444", "#222222"], ["#F6F1E5", "#555555", "#222222"], ["#444444", "#222222"]), sketch: true }, +}); + +function theme(name: BuiltInThemeName, backgroundColor: string, node: [string, string, string], container: [string, string, string], edge: [string, string]): ThemeDefinition { + return { + name, + backgroundColor, + node: { fillColor: node[0], strokeColor: node[1], fontColor: node[2] }, + container: { fillColor: container[0], strokeColor: container[1], fontColor: container[2] }, + edge: { strokeColor: edge[0], fontColor: edge[1] }, + }; +} + +const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/; + +export function contrastRatio(left: string, right: string): number { + const luminance = (color: string): number => { + if (!HEX_COLOR.test(color)) throw new Error(`Invalid color: ${color}`); + const values = [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255) + .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4); + return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722; + }; + const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a); + return (high + 0.05) / (low + 0.05); +} + +export function validateTheme(themeDefinition: ThemeDefinition): ThemeDefinition { + if (!themeDefinition || typeof themeDefinition.name !== "string" || themeDefinition.name.trim() === "") throw new Error("Theme requires a name"); + const colors: Array<[string, string]> = [ + ["backgroundColor", themeDefinition.backgroundColor], + ["node.fillColor", themeDefinition.node?.fillColor], ["node.strokeColor", themeDefinition.node?.strokeColor], ["node.fontColor", themeDefinition.node?.fontColor], + ["container.fillColor", themeDefinition.container?.fillColor], ["container.strokeColor", themeDefinition.container?.strokeColor], ["container.fontColor", themeDefinition.container?.fontColor], + ["edge.strokeColor", themeDefinition.edge?.strokeColor], ["edge.fontColor", themeDefinition.edge?.fontColor], + ]; + for (const [path, color] of colors) if (typeof color !== "string" || !HEX_COLOR.test(color)) throw new Error(`Theme ${path} must be a #RRGGBB color`); + for (const role of ["node", "container"] as const) { + if (contrastRatio(themeDefinition[role].fontColor, themeDefinition[role].fillColor) < 4.5) throw new Error(`Theme ${role} text has insufficient contrast`); + } + if (contrastRatio(themeDefinition.edge.fontColor, themeDefinition.backgroundColor) < 4.5) throw new Error("Theme edge text has insufficient contrast"); + return themeDefinition; +} + +interface StyleEntry { key: string; value?: string } + +function parseStyle(style = ""): StyleEntry[] { + return style.split(";").filter(Boolean).map((token) => { + const separator = token.indexOf("="); + return separator < 0 ? { key: token } : { key: token.slice(0, separator), value: token.slice(separator + 1) }; + }); +} + +function themedStyle(style: string | undefined, colors: Partial, sketch: boolean, backgroundColor?: string): string { + const entries = parseStyle(style); + const originalFillNone = entries.some(({ key, value }) => key === "fillColor" && value?.toLowerCase() === "none"); + const visual = new Map(); + if (colors.fillColor) visual.set("fillColor", originalFillNone ? "none" : colors.fillColor); + if (colors.strokeColor) visual.set("strokeColor", colors.strokeColor); + if (colors.fontColor) { + let fontColor = colors.fontColor; + if (originalFillNone && backgroundColor && contrastRatio(fontColor, backgroundColor) < 4.5) { + fontColor = contrastRatio("#000000", backgroundColor) >= 4.5 ? "#000000" : "#FFFFFF"; + } + visual.set("fontColor", fontColor); + } + visual.set("sketch", sketch ? "1" : "0"); + const visualKeys = new Set(["fillColor", "strokeColor", "fontColor", "sketch"]); + const handled = new Set(); + const output = entries.flatMap((entry) => { + if (!visualKeys.has(entry.key)) return [entry]; + if (handled.has(entry.key)) return []; + handled.add(entry.key); + const replacement = visual.get(entry.key); + if (replacement === undefined) return [entry]; + visual.delete(entry.key); + return [{ key: entry.key, value: replacement }]; + }); + for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) { + const value = visual.get(key); + if (value !== undefined) output.push({ key, value }); + } + return output.map(({ key, value }) => value === undefined ? key : `${key}=${value}`).join(";") + ";"; +} + +export function applyTheme(ir: DiagramIRV2, selected: BuiltInThemeName | ThemeDefinition): DiagramIRV2 { + const selectedTheme = typeof selected === "string" ? BUILT_IN_THEMES[selected] : selected; + if (!selectedTheme) throw new Error(`Unknown built-in theme: ${selected}`); + validateTheme(selectedTheme); + const isolated = structuredClone(ir); + const applyNode = (node: DiagramNode): DiagramNode => ({ + ...node, + style: themedStyle(node.style, node.kind === "container" ? selectedTheme.container : selectedTheme.node, selectedTheme.sketch === true, selectedTheme.backgroundColor), + }); + const applyEdge = (edge: DiagramEdge): DiagramEdge => ({ ...edge, style: themedStyle(edge.style, selectedTheme.edge, selectedTheme.sketch === true) }); + return { + ...isolated, + theme: selectedTheme.name, + pages: isolated.pages.map((page) => { + const extensions = page.extensions ?? {}; + const drawio = extensions.$drawio && typeof extensions.$drawio === "object" && !Array.isArray(extensions.$drawio) + ? extensions.$drawio as Record + : {}; + const attributes = drawio.attributes && typeof drawio.attributes === "object" && !Array.isArray(drawio.attributes) + ? drawio.attributes as Record + : {}; + return { + ...page, + extensions: { + ...extensions, + $drawio: { ...drawio, attributes: { ...attributes, "model:background": selectedTheme.backgroundColor } }, + }, + nodes: page.nodes.map(applyNode), + edges: page.edges.map(applyEdge), + }; + }), + }; +} diff --git a/scripts/src/services/transforms/heatmap.test.ts b/scripts/src/services/transforms/heatmap.test.ts new file mode 100644 index 0000000..eddfe79 --- /dev/null +++ b/scripts/src/services/transforms/heatmap.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { applyMetricsHeatmap } from "./heatmap.js"; +import { contrastRatio } from "../themes/theme-service.js"; + +const IR: DiagramIRV2 = { + version: 2, + pages: [{ id: "p", title: "Page", nodes: [ + { id: "low", label: "Low", style: "shape=hexagon;strokeColor=#000000;", geometry: { x: 0, y: 0, width: 100, height: 60 } }, + { id: "high", label: "High", style: "shape=ellipse;", geometry: { x: 200, y: 0, width: 100, height: 60 } }, + ], edges: [{ id: "flow", source: "low", target: "high", waypoints: [{ x: 150, y: 30 }] }] }], +}; + +test("metrics heatmap is deterministic with legend metadata and preserves topology", () => { + const request = { metric: "latency_ms", min: 0, max: 100, values: { "p/low": 0, "p/high": 100 } }; + const snapshot = structuredClone(IR); + const first = applyMetricsHeatmap(IR, request); + assert.deepEqual(first, applyMetricsHeatmap(IR, request)); + assert.deepEqual(IR, snapshot); + assert.deepEqual(first.legend, { + metric: "latency_ms", min: 0, max: 100, + stops: [ + { value: 0, color: "#440154", fontColor: "#FFFFFF" }, + { value: 25, color: "#3B528B", fontColor: "#FFFFFF" }, + { value: 50, color: "#21918C", fontColor: "#1A1A1A" }, + { value: 75, color: "#5EC962", fontColor: "#1A1A1A" }, + { value: 100, color: "#FDE725", fontColor: "#1A1A1A" }, + ], + }); + assert.match(first.diagram.pages[0].nodes[0].style!, /shape=hexagon/); + assert.match(first.diagram.pages[0].nodes[0].style!, /fillColor=#440154/); + assert.match(first.diagram.pages[0].nodes[1].style!, /fillColor=#FDE725/); + assert.deepEqual(first.diagram.pages[0].edges, IR.pages[0].edges); + assert.deepEqual(first.diagram.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry })), IR.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry }))); +}); + +test("metrics heatmap sets readable text for every palette stop", () => { + const result = applyMetricsHeatmap(IR, { + metric: "latency_ms", min: 0, max: 100, + values: { "p/low": 0, "p/high": 100 }, + }); + + for (const stop of result.legend.stops) { + assert.ok(contrastRatio(stop.fontColor, stop.color) >= 4.5, `${stop.fontColor} on ${stop.color}`); + } + assert.match(result.diagram.pages[0].nodes[0].style!, /fontColor=#FFFFFF/); + assert.match(result.diagram.pages[0].nodes[1].style!, /fontColor=#1A1A1A/); +}); + +test("metrics heatmap switches colors at the legend stop boundaries", () => { + const colorsAt = (low: number, high: number): string[] => applyMetricsHeatmap(IR, { + metric: "latency_ms", min: 0, max: 100, values: { "p/low": low, "p/high": high }, + }).diagram.pages[0].nodes.map((node) => /fillColor=(#[0-9A-F]{6})/i.exec(node.style!)?.[1] ?? ""); + + assert.deepEqual(colorsAt(24.999, 25), ["#440154", "#3B528B"]); + assert.deepEqual(colorsAt(49.999, 50), ["#3B528B", "#21918C"]); + assert.deepEqual(colorsAt(74.999, 75), ["#21918C", "#5EC962"]); + assert.deepEqual(colorsAt(99.999, 100), ["#5EC962", "#FDE725"]); +}); + +test("metrics heatmap rejects palettes without a readable text color", () => { + assert.throws(() => applyMetricsHeatmap(IR, { + metric: "x", min: 0, max: 1, values: {}, colors: ["#777777", "#777777"], + }), /no readable font color/i); +}); + +test("metrics heatmap rejects invalid bounds, colors, keys, and out-of-range values", () => { + assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 1, max: 1, values: {} }), /min.*max/i); + assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { "p/low": 2 } }), /bounded/i); + assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { missing: 0 } }), /unknown/i); + assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: {}, colors: ["red"] }), /color/i); +}); diff --git a/scripts/src/services/transforms/heatmap.ts b/scripts/src/services/transforms/heatmap.ts new file mode 100644 index 0000000..173090c --- /dev/null +++ b/scripts/src/services/transforms/heatmap.ts @@ -0,0 +1,85 @@ +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; + +export interface HeatmapRequest { + metric: string; + min: number; + max: number; + values: Readonly>; + colors?: readonly string[]; +} +export interface HeatmapLegendStop { value: number; color: string; fontColor: string } +export interface HeatmapLegend { metric: string; min: number; max: number; stops: HeatmapLegendStop[] } +export interface HeatmapResult { diagram: DiagramIRV2; legend: HeatmapLegend } + +export const DEFAULT_HEATMAP_COLORS = Object.freeze(["#440154", "#3B528B", "#21918C", "#5EC962", "#FDE725"]); +const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/; + +function setHeatmapColors(style: string | undefined, color: string, fontColor: string): string { + const entries = (style ?? "").split(";").filter(Boolean); + const replacements = new Map([["fillColor", color], ["fontColor", fontColor]]); + const targetKeys = new Set(replacements.keys()); + const output = entries.map((entry) => { + const key = entry.split("=", 1)[0]; + if (!targetKeys.has(key)) return entry; + const replacement = replacements.get(key); + if (replacement === undefined) return undefined; + replacements.delete(key); + return `${key}=${replacement}`; + }).filter((entry): entry is string => entry !== undefined); + for (const [key, value] of replacements) output.push(`${key}=${value}`); + return `${output.join(";")};`; +} + +function contrastRatio(left: string, right: string): number { + const luminance = (color: string): number => [1, 3, 5] + .map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255) + .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4) + .reduce((total, value, index) => total + value * [0.2126, 0.7152, 0.0722][index], 0); + const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a); + return (high + 0.05) / (low + 0.05); +} + +const READABLE_FONT_COLORS = ["#FFFFFF", "#1A1A1A"] as const; + +function readableFontColor(background: string): string { + const color = READABLE_FONT_COLORS.find((candidate) => contrastRatio(candidate, background) >= 4.5); + if (!color) throw new Error(`Heatmap palette color ${background} has no readable font color`); + return color; +} + +function exactNumber(value: number): number { + return Number(value.toFixed(12)); +} + +export function applyMetricsHeatmap(ir: DiagramIRV2, request: HeatmapRequest): HeatmapResult { + if (typeof request.metric !== "string" || request.metric.trim() === "") throw new Error("Heatmap metric must be a non-empty string"); + if (!Number.isFinite(request.min) || !Number.isFinite(request.max) || request.min >= request.max) throw new Error("Heatmap min must be less than max"); + if (!request.values || typeof request.values !== "object" || Array.isArray(request.values)) throw new Error("Heatmap values must be an object"); + const colors = [...(request.colors ?? DEFAULT_HEATMAP_COLORS)]; + if (colors.length < 2 || colors.some((color) => typeof color !== "string" || !HEX_COLOR.test(color))) throw new Error("Heatmap colors must contain at least two #RRGGBB colors"); + const nodeKeys = new Set(ir.pages.flatMap((page) => page.nodes.map((node) => `${page.id}/${node.id}`))); + for (const [key, value] of Object.entries(request.values)) { + if (!nodeKeys.has(key)) throw new Error(`Heatmap value has unknown node: ${key}`); + if (typeof value !== "number" || !Number.isFinite(value) || value < request.min || value > request.max) { + throw new Error(`Heatmap value for ${key} must be numeric and bounded by min and max`); + } + } + const stops = colors.map((color, index) => ({ + value: exactNumber(request.min + (request.max - request.min) * index / (colors.length - 1)), + color, + fontColor: readableFontColor(color), + })); + const legend: HeatmapLegend = { metric: request.metric, min: request.min, max: request.max, stops }; + const diagram = structuredClone(ir); + for (const page of diagram.pages) for (const node of page.nodes) { + const value = request.values[`${page.id}/${node.id}`]; + if (value === undefined) continue; + const nextStopIndex = stops.findIndex((stop) => value < stop.value); + const colorIndex = nextStopIndex === -1 ? stops.length - 1 : Math.max(0, nextStopIndex - 1); + node.style = setHeatmapColors(node.style, colors[colorIndex], stops[colorIndex].fontColor); + } + diagram.extensions = { ...(diagram.extensions ?? {}), heatmap: legend }; + return { diagram, legend }; +} + +export const transformMetricsHeatmap = applyMetricsHeatmap; diff --git a/scripts/src/services/transforms/relabel.test.ts b/scripts/src/services/transforms/relabel.test.ts new file mode 100644 index 0000000..9f9fd68 --- /dev/null +++ b/scripts/src/services/transforms/relabel.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { relabelDiagram } from "./relabel.js"; + +const IR: DiagramIRV2 = { + version: 2, + title: "Original", + pages: [{ id: "p", title: "Page", layout: { type: "manual" }, nodes: [ + { id: "a", label: "A", style: "shape=hexagon;", geometry: { x: 10, y: 20, width: 100, height: 60 } }, + { id: "b", label: "B", style: "shape=ellipse;", geometry: { x: 200, y: 20, width: 100, height: 60 } }, + ], edges: [{ id: "flow", source: "a", target: "b", label: "old", style: "dashed=1;", waypoints: [{ x: 150, y: 50 }] }] }], +}; + +test("complete relabel changes only labels while preserving IDs, topology, layout, and style", () => { + const snapshot = structuredClone(IR); + const result = relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new" } }); + assert.deepEqual(result.pages[0].nodes.map(({ id, label }) => ({ id, label })), [{ id: "a", label: "Alpha" }, { id: "b", label: "Beta" }]); + assert.equal(result.pages[0].edges[0].label, "new"); + const restored = structuredClone(result); + restored.pages[0].nodes[0].label = "A"; + restored.pages[0].nodes[1].label = "B"; + restored.pages[0].edges[0].label = "old"; + assert.deepEqual(restored, IR); + assert.deepEqual(IR, snapshot); +}); + +test("relabel requires a complete exact explicit map", () => { + assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha" } }), /missing.*p\/b.*p\/flow/i); + assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new", extra: "no" } }), /unknown.*extra/i); + assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "", "p/b": "Beta", "p/flow": "new" } }), /non-empty/i); +}); diff --git a/scripts/src/services/transforms/relabel.ts b/scripts/src/services/transforms/relabel.ts new file mode 100644 index 0000000..8de4f09 --- /dev/null +++ b/scripts/src/services/transforms/relabel.ts @@ -0,0 +1,35 @@ +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; + +export interface CompleteRelabelRequest { + mode: "complete"; + labels: Readonly>; +} + +export function relabelDiagram(ir: DiagramIRV2, request: CompleteRelabelRequest): DiagramIRV2 { + if (!request || request.mode !== "complete" || !request.labels || typeof request.labels !== "object" || Array.isArray(request.labels)) { + throw new Error("Relabel requires an explicit complete map mode"); + } + const required: string[] = []; + for (const page of ir.pages) { + for (const node of page.nodes) required.push(`${page.id}/${node.id}`); + for (const edge of page.edges) if (edge.label !== undefined) required.push(`${page.id}/${edge.id}`); + } + required.sort((left, right) => left.localeCompare(right, "en-US")); + const provided = Object.keys(request.labels).sort((left, right) => left.localeCompare(right, "en-US")); + const missing = required.filter((key) => !Object.hasOwn(request.labels, key)); + if (missing.length > 0) throw new Error(`Relabel map is missing: ${missing.join(", ")}`); + const unknown = provided.filter((key) => !required.includes(key)); + if (unknown.length > 0) throw new Error(`Relabel map has unknown keys: ${unknown.join(", ")}`); + for (const key of required) { + const label = request.labels[key]; + if (typeof label !== "string" || label.trim() === "") throw new Error(`Relabel value for ${key} must be a non-empty string`); + } + const output = structuredClone(ir); + for (const page of output.pages) { + for (const node of page.nodes) node.label = request.labels[`${page.id}/${node.id}`]; + for (const edge of page.edges) if (edge.label !== undefined) edge.label = request.labels[`${page.id}/${edge.id}`]; + } + return output; +} + +export const relabelDiagramIR = relabelDiagram; diff --git a/scripts/src/services/transforms/reverse.test.ts b/scripts/src/services/transforms/reverse.test.ts new file mode 100644 index 0000000..5b41925 --- /dev/null +++ b/scripts/src/services/transforms/reverse.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { diagramIRToMermaid, diagramIRToStructuredMarkdown } from "./reverse.js"; + +const IR: DiagramIRV2 = { + version: 2, + title: "Platform", + pages: [{ + id: "system", title: "System", + nodes: [ + { id: "a", label: "Client" }, + { id: "b", label: "API \"danger\"]\n%%{init: {}}%% [click](javascript:alert(1))" }, + ], + edges: [{ id: "call", source: "a", target: "b", label: "calls | inject" }], + }], +}; + +test("reverse Mermaid flowchart is deterministic and escapes untrusted labels", () => { + const output = diagramIRToMermaid(IR); + assert.equal(output, diagramIRToMermaid(IR)); + assert.match(output, /^flowchart LR/m); + assert.match(output, /n0\["Client"\]/); + assert.match(output, /n0 -->\|"calls | inject"\| n1/); + assert.doesNotMatch(output, /