diff --git a/.changeset/conversion-fidelity.md b/.changeset/conversion-fidelity.md new file mode 100644 index 0000000..c6432d8 --- /dev/null +++ b/.changeset/conversion-fidelity.md @@ -0,0 +1,23 @@ +--- +"@statelyai/graph": minor +--- + +Expose separate Graph, native semantic and source-document preservation assessments in format support entries. Correct unrestricted round-trip claims and document known conversion limits. Existing feature keys remain available; the additive preservation field is optional for third-party entries. + +Repair existing converter semantics: scoped XML attributes/defaults and edge modes, native GEXF weight/mutual edges, DOT declaration/cluster/port behavior, GML standard identity options, structured metadata/edit precedence, collision-free ELK endpoints and explicit hyperedge rejection, JGF version support, and adjacency/TGF boundaries. + +Retain supported D2 key/reference/source abstractions and diagram annotations. Fix Mermaid styles/properties/edge identity, ordered sequence notes/lifecycle with stable note references, official participant configuration, ER entities/constraints, block shapes/columns and class/tree metadata. New metadata fields remain JSON-serializable; legacy input remains readable. Source annotations may be reformatted, and unsupported event references/topology fail explicitly. + + +Add `@statelyai/graph/document` and explicit native `*DocumentConverter` registrations. Plain JSON-serializable envelopes retain exact original text/native JSON values after persistence, independently of partial or unavailable semantic projections. Supported edits patch source with reparse proof; unsupported/conflicting edits fail explicitly. Custom codecs, including VisualGraph codecs, can register without eager optional peer imports. Source validation rejects non-JSON values rather than silently dropping them. Explicit lossy regeneration reparses its output and reports unrepresentable Graph field paths or an unavailable projection, alongside possible source loss. Expand the optional ELK peer range through 0.12 after runtime/type package verification. + + +Add a separate Node 24 native-validation suite with exact dev-only parser/compiler/schema dependencies and converter coverage gates. Independently validate external and emitted profiles, with native database/compiler semantic assertions. Correct native Mermaid class multiplicity placement, nested state transition scope, and literal backslash encoding/import counts discovered by those oracles. Core runtime support remains Node 20+; validators are absent from published runtime imports. + +Add dependency-free `csv`, `graphology`, `graphlib` and `networkx` subpaths with explicit document registrations. CSV/TSV use separate node/edge tables, configurable typed columns and default JSON cells to distinguish missing/null/empty values. Bounded document cell edits preserve unrelated source columns and quoting. Native JSON adapters preserve selected attribute metadata and reject unsupported uniform-direction mappings. Validate emitted CSV cells with pinned csv-parse and NetworkX profiles with isolated Python oracles; these checks establish scoped interoperability, not unrestricted semantic losslessness. + +Add scoped Mermaid Architecture, Requirement, Sankey, GitGraph, C4 and UseCase converters with serializable document registrations and native Mermaid12.1 database proof. Architecture sides map ports; GitGraph preserves ordered DAG actions and canonical main-branch configuration. Bounded label patches preserve comments; unsupported syntax/edits retain source or reject explicitly. Scope Sankey to the native parser's printable ASCII/trimmed-name profile; omit Requirement empty text tokens that native syntax rejects. Repair injective empty Mermaid IDs and empty-parent hierarchy references. + +Add adjacency matrix, Matrix Market, Graphviz JSON, draw.io, Pajek and scoped PlantUML subpaths, explicit mappings/aggregation/direction policies, source document registrations and independent native oracles. draw.io compression uses optional fflate; native validation tools remain development-only, including checksum-pinned PlantUML/JRE and isolated Python dependencies. + +Reject native display/wire strings that independent oracles replace: Mermaid NUL/lone-surrogate labels and D2 lone-surrogate labels/IDs. Preserve supported astral strings, D2 compiler-level NUL labels, safe encoded Mermaid IDs, JSON-escaped opaque data and exact unprojectable source strings. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..822a551 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Preserve official vendored schema bytes; their upstream whitespace is hash checked. +tests/native/fixtures/graphml/1.0/*.xsd -text -whitespace +tests/native/fixtures/gexf/1.2draft/*.xsd -text +tests/native/fixtures/gexf/1.3/*.xsd -text +tests/native/fixtures/drawio/mxfile.xsd -text +tests/native/fixtures/graphviz/schema/graphviz_json_schema.json -text -whitespace +tests/native/fixtures/graphviz/schema/LICENSE.txt -text -whitespace + +# Decoded draw.io payload snapshots intentionally retain source whitespace and EOF. +tests/native/fixtures/drawio/page-first.mxgraph.xml -text -whitespace +tests/native/fixtures/drawio/page-second.mxgraph.xml -text -whitespace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 152c2aa..480ee32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,12 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: Typecheck Repository + run: pnpm typecheck:repo + + - name: Check API Conventions + run: pnpm check:conventions + - name: Check Generated Artifacts run: pnpm check:generated @@ -71,3 +77,41 @@ jobs: - name: Validate Published Package Surface run: pnpm validate:package + + + native-validation: + name: Native Format Validation + runs-on: ubuntu-latest + env: + GRAPH_NATIVE_PYTHON: ${{ github.workspace }}/.venv-native/bin/python + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - name: Install Dependencies + run: pnpm install --frozen-lockfile + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: requirements/native.txt + - name: Install Native Python Oracles + run: | + python -m venv .venv-native + .venv-native/bin/python -m pip install -r requirements/native.txt + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - name: Install Pinned PlantUML Oracle + run: pnpm setup:native:plantuml + - name: Validate Native Formats and Converter Coverage + run: pnpm test:coverage diff --git a/.gitignore b/.gitignore index c1119ec..ea2e087 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,10 @@ ASSESSMENT.md .thoughts docs/superpowers + +coverage/ + +.venv-native/ +__pycache__/ + +.tools-native/ diff --git a/README.md b/README.md index 165f1b1..4d87b83 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,97 @@ if (path) { } ``` +## Format preservation + + + +The table describes bare Graph converters. Format mappings, Graph round-tripping, +native semantics and source preservation are separate contracts. No converter currently guarantees 100% losslessness for +arbitrary Graphs or complete native documents. Selected-field tests characterize +supported mappings; they do not establish conformance to an entire format. + +| Formats | Graph → format → Graph | Native → Graph → native | Source details | +| --- | --- | --- | --- | +| Adjacency list, Cytoscape, D3, DOT, ELK, GEXF, GML, GraphML, JGF, TGF, xyflow | Partial | Partial | None | +| CSV/TSV, Graphology export JSON, Graphlib JSON, NetworkX node-link JSON, adjacency matrix, Matrix Market, Graphviz JSON, draw.io, Pajek, scoped PlantUML | Partial | Partial | None | +| Edge list | Partial | Full for endpoint tuples | None | +| D2 | Partial | Partial | Partial comments | +| Mermaid architecture, block, C4, class, ER, flowchart, GitGraph, ishikawa, mindmap, requirement, Sankey, sequence, state, UseCase | Partial | Partial | Partial annotations | + +Graph preservation includes JSON-serializable fields, optional-field presence and +entity order. Native preservation includes format-specific topology, attributes +and semantics. Source preservation additionally covers comments, declaration +order, spelling and whitespace. Adapter metadata can carry selected Graph fields +without making them native features. Use a GraphDocument when those details matter. + +Additional adapters: [adjacency matrix](src/formats/adjacency-matrix/README.md), +[Matrix Market](src/formats/matrix-market/README.md), +[Graphviz JSON](src/formats/graphviz-json/README.md), [draw.io](src/formats/drawio/README.md), +[Pajek](src/formats/pajek/README.md), [PlantUML](src/formats/plantuml/README.md). + +```ts +import { getFormatSupportEntry } from '@statelyai/graph/format-support'; + +const support = getFormatSupportEntry('graphml'); +console.log(support?.preservation); // { graph: 'partial', native: 'partial', document: 'none' } +``` + +The existing `features` API remains available; `features.roundTrip` describes the +legacy Graph round-trip assessment. `preservation` is optional for third-party +entries and present on every built-in entry. + + + +Native imports now retain supported key/default/namespace semantics, edge modes, +weights, hierarchy, ports and structured metadata. DOT declarations and clusters, +ELK endpoint identity, JGF versions, D2 source abstractions and Mermaid ordered sequence events, +properties and entity declarations have dedicated regression coverage. +Canonical and visible native edits take precedence over unchanged source hints +where the adapter can resolve the mapping. + +Source-only annotations remain a separate limitation: diagram comments, +frontmatter and directives may be reformatted; XML/DOT comments and complete +declaration order are not guaranteed. Unsupported Graph topology such as +hyperedges is rejected instead of silently truncated. Independent native validators cover selected emitted profiles. Broader structural source editing remains bounded by each document adapter. + +## Native documents + + + +```ts +import { graphMLDocumentConverter } from '@statelyai/graph/graphml'; + +const document = graphMLDocumentConverter.from(xml); +const persisted = JSON.parse(JSON.stringify(document)); +const exactSource = graphMLDocumentConverter.to(persisted); +``` + +CSV/TSV use explicit node and edge tables with a serializable schema; default optional cells encode JSON, including visible labels. Graphology, Graphlib and NetworkX subpaths convert their native JSON profiles without runtime library dependencies. Mapping limits: [CSV/TSV](src/formats/csv/README.md), [Graphology](src/formats/graphology/README.md), [Graphlib](src/formats/graphlib/README.md), [NetworkX](src/formats/networkx/README.md). + +Every native converter subpath offers a `*DocumentConverter` registration. The +`@statelyai/graph/document` factory also accepts explicit custom codecs, including +VisualGraph codecs. Documents retain exact original text/native JSON values on +no-op export, including comments, extensions and unsupported constructs, after +JSON persistence. Projection can be partial or unavailable and carries explicit +diagnostics. This guarantees source retention, not full semantic interpretation. + +Supported Graph edits patch native source and must pass reparse proof. Unsupported +or conflicting edits throw instead of replaying stale source. Native source edits +remain authoritative when Graph is unchanged. Regeneration is an explicit, +diagnostic-bearing operation that reports differing Graph fields after reparse, +as well as possible source loss. See [document API](src/formats/document/README.md) +for the validated JSON domain and editing boundary. + +## Native validation + + + +`pnpm test:native` runs pinned native parsers, compilers, schemas and runtime +oracles on Node 24. `pnpm test:coverage` enforces measured converter coverage +floors. These tools are dev-only; ordinary core tests still cover Node 20/22/24. +Selected native assertions supplement conversion regressions without promising +complete language coverage. See [native validation](docs/native-validation.md). + ## Optional peer dependencies The core (`@statelyai/graph`) and the pure-JSON formats have no runtime @@ -65,6 +156,7 @@ for the peer — install it and the import works. | Subpath import | Peer dependency to install | | --- | --- | | `@statelyai/graph/dot` | `dotparser` | +| `@statelyai/graph/drawio` | `fflate`, `fast-xml-parser` | | `@statelyai/graph/graphml`, `@statelyai/graph/gexf` | `fast-xml-parser` | | `@statelyai/graph/layout/cytoscape` | `cytoscape` | | `@statelyai/graph/xstate` | `xstate` | diff --git a/docs/adr/003-serializable-native-documents.md b/docs/adr/003-serializable-native-documents.md new file mode 100644 index 0000000..73e250e --- /dev/null +++ b/docs/adr/003-serializable-native-documents.md @@ -0,0 +1,17 @@ +# Serializable native documents + +Status: accepted, additive API. + +Graph models editable graph semantics. It cannot represent every comment, XML extension, unsupported hyperedge, multiple native graph, or syntax spelling. Existing format converters continue returning Graph and keep their existing defaults. + +`@statelyai/graph/document` adds a plain `GraphDocument` envelope. It stores native source, a possibly partial Graph projection, independent native/Graph baselines, diagnostic records and optional serializable adapter state. Explicit factory registrations avoid importing optional peers through the core module. VisualGraph codecs retain their stronger projected type. + +Source belongs to the JSON value/string domain. Validation rejects nonfinite numbers, undefined source values, functions, symbols, BigInt, class instances, accessors, sparse arrays and cycles before cloning. Object alias identity and prototypes are outside this contract. Optional undefined Graph object fields compare using their JSON view. No-op output exactly preserves source strings or native JSON values, including unknown fields, key order and value presence, after JSON persistence. This guarantees source retention, not complete native semantic interpretation. + +Native source edits are authoritative when Graph remains unchanged. Graph edits compare against the baseline projection, then merge with a fresh native projection by stable entity ID. Different mapped fields merge; conflicting changes to the same field return diagnostics. Object-valued Graph fields currently conflict conservatively as whole fields. Entity order changes count as structural changes. Unsupported edits never replay stale source. + +`.to(document)` remains strict. A verified format-specific patch must reproduce the merged requested projection when reparsed. Without a patch, structural support, a unique lexical span, or a successful proof, export throws an ordinary Error carrying diagnostics. Explicit `getGraphDocumentUpdate(document, converter, { mode: 'regenerate' })` may regenerate source and always returns a loss warning covering source and Graph details. Regenerated source is reparsed; differing Graph field paths or an unavailable projection receive additional warning diagnostics. Projection failures retain source with `graph: null`; strict import throws. + +Comparison hooks may normalize only verified adapter provenance. Editable user data and foreign reserved-key payloads remain observable. A comparator that removes private state needs a separate `getComparisonState` extractor to permit edits in normalization-altered fields; otherwise those field edits are conservatively rejected. Stored adapter state and baseline projection provenance must match the baseline native source. Import hooks may expose one editable label where a native format duplicates it in configuration, while retaining the complete native configuration in source. + +Initial text editing supports bounded, verified label spans, preserving unedited source text. JSON adapters update supported stable entity fields while retaining native extensions. This is an extensible editing boundary, not a universal syntax tree or native validation API. Native parsers and independent semantic oracles remain separate work. diff --git a/docs/native-validation.md b/docs/native-validation.md new file mode 100644 index 0000000..bdc0e04 --- /dev/null +++ b/docs/native-validation.md @@ -0,0 +1,142 @@ +# Native interoperability validation + + + +`pnpm test:native` runs the independent native suite in `tests/native/`. CI uses +Node 24 separately from core Node 20/22/24 checks. Use Node 24.15+ locally for the +current DOM validator. Standard `pnpm test` excludes native tests; repository +TypeScript checks include their source. Validators remain dev-only dependencies; +core and published format modules do not import them. + +`pnpm test:coverage` runs standard and native tests together on Node 24 and +checks converter coverage against committed thresholds. Core Node 20 tests remain +separate and exclude native modules. The initial native-foundation corpus measured +96.30% lines, 92.55% statements, 95.29% functions and 85.90% branches. The latest +reviewed expanded corpus measured 97.10% lines, 92.26% statements, 95.23% functions +and 86.44% branches across 4,222 tests on Node 24.15.0. These are distinct snapshots. + + + +Global floors remain 95% lines, 91% statements, 93% functions and 84% branches. +[The committed configuration](../vitest.config.ts) also sets line/branch floors +for selected D2, Mermaid, XML, ELK and primary CSV/JSON adapters, plus adjacency +matrix/core, Matrix Market, Graphviz JSON, draw.io, Pajek, PlantUML and all six +expanded Mermaid profiles with their shared parser helpers. Coverage output is +ignored. Native semantic assertions complement coverage; neither proves full +language coverage. + + + +| Oracle | Version | Scope | +| --- | --- | --- | +| Mermaid | 12.1.0 | Native syntax parsers and diagram databases | +| @terrastruct/d2 | 0.1.33 | Portable WASM compiler and compiled graph/diagram | +| Ajv | 8.20.0 | Pinned JSON schemas | +| ajv-draft-04 | 1.0.0 | Official draft-04 compatibility | +| xmllint-wasm | 5.3.0 | libxml2 XSD validation | +| jsdom | 30.1.2 | Browser DOM for Mermaid parsing/entity decoding | +| csv-parse | 7.0.3 | Exact external/emitted CSV and TSV cells | +| @maxgraph/core | 0.25.0 | Legacy mxGraph XML model import compatibility | +| NetworkX (Python) | 3.7 | Native node-link JSON and GML readers/writers | +| SciPy / NumPy (Python) | 1.18.1 / 2.5.4 | Matrix Market numeric/math interoperability | +| igraph (Python) | 1.0.0 | Pajek reader vertices/sections/attributes | +| PlantUML | 1.2026.8 | Compiler-produced SVG and class XMI, scoped component/class dialect | + +Python oracles use an isolated task environment, with exact versions in +`requirements/native.txt`. CI installs it before combined coverage; missing or +wrong versions fail explicitly. Local setup uses Python 3.12+: + +```sh +python3 -m venv .venv-native +.venv-native/bin/python -m pip install -r requirements/native.txt +pnpm test:native +``` + +`GRAPH_NATIVE_PYTHON` can select another task-owned environment. No Python package +is needed by the library at runtime. CSV/TSV native checks assert exact cell +contents and the separate Graph projection; CSV parsing alone does not establish +graph-domain validity. + +Draw.io fixtures exercise maxgraph 0.25.0's native legacy mxGraph model import, +including UserObject values, style records, grouping, routing and page inflation. +This proves import compatibility for asserted fields, rather than rendering in +the draw.io application. The unchanged draw.io generated-file XSD rejects the +compressed-page fixture; the suite records that negative result separately from +successful native inflation/import. Pinned Python igraph fixtures inspect Pajek +vertices, sections and attributes through the native reader. These oracles assert model import and selected native fields; they do not establish +complete draw.io rendering or unrestricted Pajek grammar. Graphviz fixtures +inspect actual Graphviz JSON output; Graphviz has no native JSON input reader, so +fresh structural JSON export has no such reader proof. PlantUML fixtures inspect +actual compiler SVG and class XMI rather than accepting syntax alone. + +Installed Graphology, Graphlib, Cytoscape, D3 and ELK provide independent runtime +oracles. Their fixture tests record precise installed versions. XML/JSON schema +fixtures include their own official source and provenance. Invalid legacy output +profiles are tested explicitly; passing a negative validation control does not +promote that profile to standards compliance. + + + +Mermaid fixtures are independently authored from official syntax documentation, +with URLs and oracle versions in `mermaid-profiles.json`. All fourteen scoped +diagram dialects validate both native input and emitted output. Database checks +assert selected graph labels, shapes, edge strokes, participant/message/note +order, namespaces/members/multiplicities, ER attributes/cardinalities, state +hierarchy/transitions, scoped block columns and tree structure. Exact Unicode, +quotes and backslash counts use the pinned native entity decoder plus browser +HTML decoding. This integration deliberately checks the native decoder's bundle +signature when locating it; version upgrades require review. It is test-only. +The corpus includes 251-node flow and 50-level tree profiles, adjacent quoted +block labels ending in literal backslashes, malformed controls +and edited document output. Native flow comments occupy separate lines; inline +`%%` after a declaration is rejected by the pinned parser. + +D2 fixtures record official documentation and compiler version in +`d2-profiles.json`. Native compilation checks escaped labels, literal dotted +identity, isolated nodes, SQL column types/constraints and connection column +indices, mixed arrows/full arrowheads, scoped ancestor endpoints and variable/ +class substitution. Additional profiles contain a 60-node chain and 20-level +scope. The D2 wrapper exposes no public worker shutdown API, so compilation runs +in a subprocess with a 25-second deadline and explicit exit after output flush. +No private worker termination API is used. + +Native acceptance and selected semantic assertions do not prove unrestricted +Graph preservation or interpretation of all native syntax. Exact source-document +retention is a separate [contract](../src/formats/document/README.md). + +Sources: [Mermaid usage](https://mermaid.js.org/config/usage), +[D2 JavaScript wrapper](https://github.com/d2lang/d2/tree/master/d2js), and the +per-fixture official documentation links. + + + + +Six further Mermaid profiles cover Architecture, Requirement, Sankey, GitGraph, +C4 and UseCase. Native database snapshots are captured before reparse so shared +native database reset cannot create a false equivalence. Their assertions include +semantic modifiers, literal text, graph hierarchy, canonical direction, native +configuration and negative grammar controls. Mermaid12.1 Sankey has an ASCII-only +lexer and trims even quoted outer whitespace; Unicode names reject projection +and remain exact in GraphDocument source. Requirement empty text uses omission; +the native parser rejects an explicit `text: ""` field. + +PlantUML is a checksum-pinned development JAR at `.tools-native/plantuml.jar`. +`scripts/native/plantuml-tools.json` records primary release URLs, hashes and +licenses. CI uses Temurin Java21; local macOS ARM64 setup can use the separately +checksum-pinned task JRE. No system Java installation or hosted renderer is needed: + +```sh +pnpm setup:native:plantuml # existing JAVA_HOME / CI Java21 +node scripts/native/setup-plantuml.mjs --jre # task-local macOS ARM64 JRE +``` + +The JAR/JRE, GPL development tooling and generated compiler outputs stay in ignored +`.tools-native/`; none enter library runtime imports or the published package. + + +String-domain controls distinguish Unicode scalars from opaque JSON strings. +Mermaid's native/DOM entity path replaces NUL and surrogate references; display +label emission/projection/patches reject them, while astral characters and safe ID +aliases remain supported. D2's compiler retains NUL label fields but replaces lone +surrogates; its adapters reject only that proven surrogate loss. These tests +inspect native compiled/display fields rather than asserting file-byte encoding. diff --git a/package.json b/package.json index 528fa46..bd20b04 100644 --- a/package.json +++ b/package.json @@ -28,18 +28,29 @@ "./algorithms": "./dist/algorithms.mjs", "./format-support": "./dist/format-support.mjs", "./adjacency-list": "./dist/formats/adjacency-list/index.mjs", + "./adjacency-matrix": "./dist/formats/adjacency-matrix/index.mjs", "./converter": "./dist/formats/converter/index.mjs", + "./csv": "./dist/formats/csv/index.mjs", "./cytoscape": "./dist/formats/cytoscape/index.mjs", "./d2": "./dist/formats/d2/index.mjs", "./d3": "./dist/formats/d3/index.mjs", + "./document": "./dist/formats/document/index.mjs", "./dot": "./dist/formats/dot/index.mjs", + "./drawio": "./dist/formats/drawio/index.mjs", "./edge-list": "./dist/formats/edge-list/index.mjs", "./elk": "./dist/formats/elk/index.mjs", "./gexf": "./dist/formats/gexf/index.mjs", "./gml": "./dist/formats/gml/index.mjs", + "./graphlib": "./dist/formats/graphlib/index.mjs", "./graphml": "./dist/formats/graphml/index.mjs", + "./graphology": "./dist/formats/graphology/index.mjs", + "./graphviz-json": "./dist/formats/graphviz-json/index.mjs", "./jgf": "./dist/formats/jgf/index.mjs", + "./matrix-market": "./dist/formats/matrix-market/index.mjs", "./mermaid": "./dist/formats/mermaid/index.mjs", + "./networkx": "./dist/formats/networkx/index.mjs", + "./pajek": "./dist/formats/pajek/index.mjs", + "./plantuml": "./dist/formats/plantuml/index.mjs", "./tgf": "./dist/formats/tgf/index.mjs", "./xyflow": "./dist/formats/xyflow/index.mjs", "./kernel": "./dist/kernel.mjs", @@ -83,7 +94,10 @@ "prepublishOnly": "pnpm run build", "bench:compare": "tsx bench/compare/run.ts", "bench:compare:quick": "tsx bench/compare/run.ts --quick", - "bench:report": "tsx bench/compare/report.ts" + "bench:report": "tsx bench/compare/report.ts", + "test:native": "vitest run --config vitest.native.config.ts", + "test:coverage": "vitest run --config vitest.coverage.config.ts --coverage", + "setup:native:plantuml": "node scripts/native/setup-plantuml.mjs" }, "devDependencies": { "@changesets/changelog-github": "^0.5.2", @@ -91,13 +105,19 @@ "@dagrejs/dagre": "^3.0.0", "@dagrejs/graphlib": "^4.0.1", "@hpcc-js/wasm-graphviz": "^1.22.0", + "@maxgraph/core": "0.25.0", + "@terrastruct/d2": "0.1.33", "@types/d3-array": "^3.2.2", "@types/d3-force": "^3.0.10", "@types/d3-hierarchy": "^3.1.7", + "@types/jsdom": "30.0.0", "@types/node": "^25.0.3", "@vitest/coverage-v8": "^4.0.18", "@xyflow/system": "^0.0.75", + "ajv": "8.20.0", + "ajv-draft-04": "1.0.0", "bumpp": "^10.3.2", + "csv-parse": "7.0.3", "cytoscape": "^3.34.0", "d3-array": "^3.2.4", "d3-force": "^3.0.0", @@ -105,12 +125,15 @@ "dotparser": "^1.1.1", "elkjs": "^0.11.1", "fast-xml-parser": "^5.3.4", + "fflate": "0.8.3", "graphology": "^0.26.0", "graphology-components": "^1.5.4", "graphology-layout-forceatlas2": "^0.10.1", "graphology-metrics": "^2.4.0", "graphology-shortest-path": "^2.1.0", "graphology-types": "^0.24.8", + "jsdom": "30.1.2", + "mermaid": "12.1.0", "ngraph.graph": "^20.1.2", "ngraph.path": "^1.6.1", "publint": "^0.3.15", @@ -119,6 +142,7 @@ "typescript": "^5.9.3", "vitest": "^4.0.16", "webcola": "^3.4.0", + "xmllint-wasm": "5.3.0", "xstate": "^5.28.0", "zod": "^4.3.6" }, @@ -129,8 +153,9 @@ "d3-force": "^3.0.0", "d3-hierarchy": "^3.0.0", "dotparser": "^1.0.0", - "elkjs": "^0.9.0 || ^0.10.0 || ^0.11.0", + "elkjs": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0", "fast-xml-parser": "^5.0.0", + "fflate": "0.8.3", "graphology": "^0.26.0", "graphology-layout-forceatlas2": "^0.10.0", "webcola": "^3.4.0", @@ -176,6 +201,9 @@ }, "zod": { "optional": true + }, + "fflate": { + "optional": true } } } diff --git a/plans/001-fidelity-contract-and-regressions.md b/plans/001-fidelity-contract-and-regressions.md new file mode 100644 index 0000000..f48b646 --- /dev/null +++ b/plans/001-fidelity-contract-and-regressions.md @@ -0,0 +1,30 @@ +# 001: Establish truthful claims and regression signals + +Planned at `de5ebf8`, 2026-10-12. Priority P1; effort M; risk LOW; dependency none. + +## Why + +Current format tests pass while ordinary native inputs lose topology, labels, weights, metadata and comments. `roundTrip: full` mixes Graph field preservation with native/document preservation. Fixtures sort arrays and ignore edge.mode. Preserve public compatibility while making the guarantee explicit. + +## Current state + +`src/formats/support.ts` has one roundTrip feature and marks D2 plus eight structured formats full. `tests/formats/support-matrix.test.ts` excludes D2 from full fixture conformance; projected edge keys omit mode. `tests/formats/fixture-roundtrip.ts` sorts arrays. DOT stability helper compares two emissions after the first potentially lossy import. README derives its format inventory/support matrix from source using symbolic comments. + +## Scope + +Own `src/formats/support.ts`, `src/format-support.ts`, `tests/formats/support-matrix.test.ts`, new `tests/formats/fidelity-regressions.test.ts`, root README, converter READMEs, `.changeset/conversion-fidelity.md`, optionally `vitest.config.ts` to exclude nested worktrees. No converter implementation edits, package dependency edits, core model edits or user checkout changes. Other executors will own individual converters after this plan. + +## Steps + +1. Install exact lockfile dependencies: `pnpm install --frozen-lockfile --ignore-scripts` (exit 0). Read repo AGENTS, README skill and verify current source against excerpts. +2. Keep legacy feature shape; add explicit Graph/native semantic/document preservation capability fields or an adjacent additive contract, with clear JSDoc. No full unrestricted preservation claims. Retain selected fixture tests as narrower characterization tests, without implying conformance. Update READMEs/symbolic comments and Changeset. +3. Add meaningful integration regressions exercising actual converters: GraphML arbitrary key IDs/defaults/nested directedness; GEXF native weight/mutual/default direction; DOT repeated declarations/strict/rank contents/clusters; ELK hyperedge rejection or faithful representation/ID collisions; default null labels; D3 numeric endpoints; xyflow reserved metadata and native edits; adjacency delimiter/prototype IDs; TGF CRLF/whitespace; D2 inherited modes/comments; Mermaid spanning-note stability/styles/isolated ER/block quoted labels. Existing topology/model limits need explicit rejection rather than silent truncation, until a document API exists. Group tests by ownership so later workers can target them. +4. Run `pnpm exec vitest run tests/formats/support-matrix.test.ts` expecting PASS, then fidelity regressions expecting the audited failures. Record exact failing cases; do not weaken assertions or leave skipped/todo tests. The temporary red suite is the handoff to plan 002. + +## Done criteria + +Truthful claims/docs; baseline tests passing; new failures reproducibly demonstrate audit defects; `pnpm typecheck` and `pnpm typecheck:repo` exit 0; no out-of-scope changes. Report tool-backed results. Do not commit failing tests or push. Reviewer maintains plan index. + +## Conventions / stop conditions + +All public functions prefixed, graph first, plain JSON data, source contracts in `tests/contracts.test.ts` if root public exports change; preserve existing exports. Stop for core Graph redesign or contradictory API decisions. No secrets. Match existing Vitest patterns. Do not revert others' edits. diff --git a/plans/002-existing-converter-semantics.md b/plans/002-existing-converter-semantics.md new file mode 100644 index 0000000..f35066d --- /dev/null +++ b/plans/002-existing-converter-semantics.md @@ -0,0 +1,33 @@ +# 002: Repair existing converter semantics + +Planned at `de5ebf8`, 2026-10-12. Priority P1; effort L; risk MED; dependency 001 regression signals. + +## Why / current state + +Own-dialect green tests hide valid native input corruption. This plan repairs semantic conversion first; comments/unknown source state get a separate document API later. No false guarantees, stale snapshot precedence, silent hyperedge truncation or test normalization hiding lost fields. + +## Scope / ownership + +XML/DOT executor owns `src/formats/{graphml,gexf,gml,dot}/**` and matching dedicated tests plus its named regression describe sections. Structured executor owns `{cytoscape,d3,elk,xyflow,jgf,adjacency-list,edge-list,tgf}/**` and matching tests. Syntax executor owns `{d2,mermaid}/**` and matching tests. Shared support/docs/Changeset final reconciliation assigned separately. No concurrent commits. Not alone: accommodate others and never revert unrelated edits. + +## Repair requirements + +- GraphML resolve scoped key ID/name/type/defaults; retain unknown data in namespaced metadata; namespaces and boolean 0/1; enclosing nested direction; preserve Graph bidirectional via metadata. Standard native ports/data; explicit policy for multiple graphs/hyperedges. +- GEXF native weight and mutual bidirectional, standard undirected default; scope declaration IDs by node/edge class; retain metadata/custom attributes/dynamics conservatively; native visual data and arbitrary Graph colors/optional geometry; schema-compatible extension policy. +- DOT merge repeated declarations and strict edge attributes; process rank-group contents; emit native clusters; preserve independent border/fill/style and inherited attribute semantics, HTML markers, port/compass state. Graph extensions must remain namespaced and JSON-safe. Report grammar additions that require a parser replacement. +- GML compliant entities and numeric native ID mapping while accepting existing dialect; unknown ordered values, malformed input rejection; preserve Graph IDs/presence/order via explicit metadata. +- All structured adapters preserve default null labels, independent geometry, empty strings and exact JSON field values where their private metadata permits. Preserve foreign user data safely. +- ELK collision-free document-wide IDs, primitive edges, complete labels/sections/options. Hyperedges must never truncate: reject at Graph boundary unless the pending document API supplies a faithful source carrier and explicit projection. +- xyflow version/discriminator for metadata; foreign reserved-key payload preserved; renderer-visible user edits take precedence where unambiguous. +- JGF explicit v1/v2 schema versions; standards-compatible default, backward-readable existing dialect; native relation/directedness and metadata. +- Adjacency null-prototype accumulator and tuple-safe dedup; per-edge directedness; explicit parallel-edge policy. TGF normalize CRLF and whitespace; reject unsupported embedded newlines. D3 numeric endpoint resolution. +- D2 inherited mode and overrides; preserve key segments/quoted identity, ancestor/connection references, chain attrs, structured common attrs, nested vars/classes/import kind/scope, arrowheads. Keep unknown constructs as preserved metadata/diagnostics, not fake nodes. +- Mermaid canonical directedness for applicable diagrams; preserve expanded flow shapes/properties/styles/IDs/declaration override semantics; sequence ordered lifecycle/notes and no exponential duplication; official participant configuration; ER isolated entities/multiple attribute keys; block quoted tokenization/shapes/scoped columns; class namespaces/notes/interfaces; tree identity/shape/classes and symmetric escaping. Comments/frontmatter retained where feasible but full document guarantee awaits plan 003. + +## Verification + +Install locked dependencies if absent. Write failing tests before each fix; run owned dedicated tests and owned sections of `fidelity-regressions.test.ts`; then `pnpm typecheck`, `pnpm typecheck:repo`, `pnpm check:conventions`, `git diff --check` all exit 0. Reviewer runs combined full `pnpm verify` after reconciling. Review relevant README and add source comments. No weakened/skipped tests, no blanket preservation claims. + +## Stop / maintenance + +Ask reviewer if a repair requires changing core Graph topology/API or ownership. Do not touch source schemas or unrelated algorithms. Preserve backwards import compatibility; any unavoidable serialization version change must be explicit and documented. Record remaining native features honestly; no claiming all syntax parsed just because raw content retained. diff --git a/plans/003-document-preservation.md b/plans/003-document-preservation.md new file mode 100644 index 0000000..0d930e2 --- /dev/null +++ b/plans/003-document-preservation.md @@ -0,0 +1,27 @@ +# 003: Add serializable native document preservation + +Planned at `de5ebf8`, 2026-10-12. Priority P1; effort L; risk HIGH. Depends on 002. User authorizes common-sense architecture decisions; after giving the optional preference question ample time, reviewer adopts the recommended additive GraphDocument boundary, communicated in commentary. User can still steer this choice. + +## Objective + +Preserve original native source/comments/unknown fields/extensions/multiple graphs/hyperedges without changing the simple Graph model. Native semantics and opaque preservation are separate capabilities. Edited Graphs must never silently replay stale source. + +## Adopted boundary and proposed detailed contract + +Add a subpath document module, plain JSON `GraphDocument` envelope with version, format, graph projection(s), original native value/source and baseline, diagnostics/projection losses, and adapter extension state. Generic prefixed parse/create/to/get APIs accept explicit codecs rather than loading every optional peer at root. Unchanged documents reproduce exact source; edits use format-specific updates with unknown state retention, otherwise explicit unsupported-edit diagnostic and strict rejection. JSON serialization/deserialization must not lose preservation state. Never claim unsupported hyperedge semantics become binary Graph edges losslessly. + +Prefer `@statelyai/graph/document` exporting `createGraphDocumentConverter` and plain types. Existing native subpaths expose optional `*DocumentConverter` objects (same `.from(source)` / `.to(document)` pattern) registered explicitly; no global registry and no eager optional-peer import from root. Factory accepts a format ID, existing codec, and optional native patch/project hooks. Document stores version/format/source, Graph projection and JSON baseline, diagnostics, and preservation state. A source that cannot project to binary Graph must be retained with an explicit unavailable/partial projection diagnostic; never fabricated binary semantic equivalence. Edited source/native fields are authoritative over metadata snapshots. + +Required invariants: `.to(.from(source))` returns exact original text/native JSON when unchanged, also after JSON serialization of the document. Graph edits are never ignored. Supported label/data/style/geometry changes patch native source while retaining comments/unknown fields; unsupported structural edits throw a diagnostic-bearing ordinary Error by default. An explicit lossy regeneration option may exist only with a returned diagnostic result, never silently via `.to`. Generated Graph-to-native documents need native carriers/sidecars for Graph-only fields; bare minimal formats remain partial. Document provenance/version and native value consistency must be validated, with no functions/classes/symbols/cycles in stored content. Capture snapshots independently so later mutation cannot alter baselines. + +Detailed API defaults: `GraphDocument` stores a constant kind/version, format ID, cloned source, `graph: Graph | null`, JSON baseline and diagnostic list. A projection failure may preserve the source with graph null and `projection-unavailable`; strict import explicitly throws. Default strict `.to(document)` refuses changed Graph when no safe patch exists. Provide `getGraphDocumentUpdate` returning a discriminated plain result with source/diagnostics rather than an implicit lossy string. Imported source edits and graph edits affecting the same field require conflict detection; independent unknown-source changes should survive. No source replay after a Graph edit and no graph snapshot restore after a native field edit. Semantic projections remain explicitly partial even when exact source preservation succeeds. + +Ownership for core executor: new `src/formats/document/**` (package subpath ./document), document tests/docs/ADR, package.json/lock/tsdown/smoke updates and test contract exclusions if root exports added. Keep dependency installs serial. Native hooks belong to their converter executors and must be reviewed/registered explicitly; do not edit their implementation files concurrently. Implement core first and ask reviewer to coordinate hooks. JSON formats can patch known entity records by stable ID while retaining unknown fields. Text/XML hooks must target verified lexical spans, preserve comments/namespaces/declarations, and reject ambiguity/unimplemented structure changes. Regex replacing all matching labels indiscriminately is not acceptable. + +## Steps / scope + +Write exact API/compatibility ADR under docs/adr, tests covering JSON persistence, no-op source equality, editing supported fields with preserved comments/extensions, unsupported structural edits and strict mode, metadata provenance and native precedence. Executor owns new document module/tests/docs and additive package/build exports; coordinate converter ownership. Integrate every existing codec through explicit registrations. Retain current APIs/default behavior except confirmed bug fixes. Namespaces must avoid arbitrary user-data collisions. + +## Verification / done + +`pnpm verify` exit 0; package smoke imports work; document no-op equality and post-edit reparse semantics tested, preserve unknown native content, diagnostics precise. Public exports represented/excluded in tests/contracts. README/Changeset updated. No complete semantic claims without independent fixtures/oracles. If user chooses a different model boundary, pause dependent implementation and report the reconciliation needed. diff --git a/plans/004-native-validation-and-adapters.md b/plans/004-native-validation-and-adapters.md new file mode 100644 index 0000000..dbf21a0 --- /dev/null +++ b/plans/004-native-validation-and-adapters.md @@ -0,0 +1,50 @@ +# 004: Validate native interoperability and add missing adapters + +Planned at `de5ebf8`, 2026-10-12. Priority P2; effort L; risk MED. Depends on 003. + +## Objective + +Native-reader/schema-backed test corpus plus adapters ordered by existing domain fit. Graphology, Graphlib, NetworkX node-link JSON, CSV/TSV first. Graphviz JSON, draw.io, Pajek, Matrix Market, scoped PlantUML follow. Mermaid Architecture, Requirement, Sankey, GitGraph, C4, Use Case follow their current official grammar. Domain-specific RDF/GraphSON/CX2/DGML require explicit mappings before implementation. + +## Scope / requirements + +Executor owns each assigned new src/formats directory and its tests/README, coordinates package exports, tsdown entries, support inventory, contract exclusions and Changesets. No new runtime dependency unless needed for authoritative parser integration; optional peers stay optional, lazy document registry does not load them from root. + +Use installed Graphology/Graphlib as independent oracles. Pin current official JGF JSON schemas and Mermaid/D2 parser or compiler fixtures with provenance/version; validate external native documents and emitted outputs, never converter-against-itself alone. Include null/presence/order/mixed-mode/parallel-edge/isolated-node/port/custom metadata, reserved names, arbitrary Unicode/escaping, large/deep iterative graphs, hostile malformed inputs, document no-op/edited preservation and diagnostics. + +Native validation choices: dev-only Ajv for pinned JGF schemas; dev-only `xmllint-wasm` (libxml2) with vendored official XSD dependencies and provenance for XML. Mermaid current v12 requires Node >=22.12 whereas existing core CI covers Node20/22/24: preserve core runtime support and run a separate native-validation script/job on Node24, with exact native parser versions. Do not make optional validators runtime dependencies or silently skip unsupported inputs. Prefer portable WASM D2 compiler validation in a subprocess with bounded lifetime, because its worker can keep the event loop alive. Dependency installation/lockfile updates belong to one designated integrator, not concurrent workers. + +CSV/TSV uses explicit node/edge tables and schema/configuration for IDs, endpoints, data, types, direction and ports. Parse quoting/newlines correctly; retain unknown columns and metadata in document mode. Matrix family needs clear parallel-edge aggregation and node ID sidecar. draw.io needs page/group/style/routing/native carrier. PlantUML requires honest scoped dialect support; never flatten executable process/statechart semantics. + +## Verification / completion + +Milestones: native foundation independently passed 3807 tests. Primary Graphology, +Graphlib, NetworkX and CSV/TSV independently passed `pnpm verify` and 3955 combined +Node24 tests (96.53% lines / 86.45% branches). Review caught and repaired new-edge +identity loss after foreign simple/multigraph imports, metadata suffix overflow, +GML empty labels and native CR/LF/TAB normalization. Remaining follow-on adapters +and six Mermaid dialects are executor-owned; no domain-specific schema claims. + +Follow-on review: fresh Graphviz JSON, draw.io and Pajek exports preserve complete +Graph equality for structural IDs 0/1, hierarchy, ports, null/empty labels, zero +weights and arbitrary JSON data. GraphML strict label edits reject XML-invalid +NUL characters. Count-coefficient edits now reject instead of becoming weights; +reviewer independently passed 110 focused tests and seven actual SciPy tests on +Node 24. Asymmetric stored-zero undirected coordinates need a matching-presence +guard, now implemented. Empty-ID Mermaid Architecture groups retain child +membership. Chunked XML patch assembly reduced a 10k draw.io bulk-label benchmark +from 15.58s to 3.91s. Actual Graphviz 15 fdp output proves cluster endpoints; +dot instead creates ordinary cluster-named endpoint nodes. XML worker final gate: +173 focused core tests, 26 actual Node 24 native tests, both type checks and +conventions pass. DOT native wire strings now reject NUL/lone surrogates while +JSON-escaped user data remains intact. Supplied source strings are preserved +exactly; arbitrary original file byte encoding is outside the API. + +`pnpm verify` passes plus native validation suite, selected format coverage thresholds checked in CI. README inventories match actual exports. Document exact supported dialect/version and losses. Each adapter independent native oracle tests; no blanket 100% claims. If native schema/domain representation is unclear, produce concrete API options for user rather than guess. Task branch based on integration main; commits/push allowed after verification; never direct-push main or touch shared checkout. + +Completed: reviewer sequential `pnpm verify`, actual Node20.20.2 core3994 and +Node24.15.0 combined4222 tests passed. Converter coverage97.10% lines/86.44% +branches; all global and selected new-module floors passed. Ten new adapters, +six Mermaid profiles, document registrations, scoped READMEs, native provenance, +CI and packed runtime/types are verified. Domain-specific mappings remain explicit +follow-ups. See [final verification](005-final-verification.md). diff --git a/plans/005-final-verification.md b/plans/005-final-verification.md new file mode 100644 index 0000000..cdb4d3a --- /dev/null +++ b/plans/005-final-verification.md @@ -0,0 +1,83 @@ +# Conversion fidelity verification + +Reviewed 2026-10-12, integration base `de5ebf8`, branch `feature/conversion-fidelity`. + +## Final gates + +- `pnpm verify`: passed; 3,994 core tests, source/repository types, generated checks, conventions, build, publint, packed runtime/types. +- Actual Node 20.20.2: 3,994 core tests / 103 files passed. +- Actual Node 24.15.0: 4,222 combined core/native tests / 121 files passed; all committed coverage floors passed. +- Converter coverage: 97.10% lines, 86.44% branches, 95.23% functions, 92.26% statements. +- Shared checkout preserved; only its pre-existing untracked `docs/meta.json` remains. + +One concurrent full-gate run exceeded the default five-second limit on a 10,000-edge matrix test. The exact isolated test completed in 1.42s. Its test-specific timeout now matches other large-workload tests at 30s; assertions, global limits and native subprocess limits remain unchanged. The sequential full gate and final Node 20 run passed. + +## Interpretation + +These percentages measure executed converter code, **not native grammar coverage**. No bare Graph converter claims unrestricted 100% Graph/native losslessness. GraphDocument retains supplied source strings/native JSON values exactly on unchanged export after JSON persistence, including unsupported constructs and comments. Projection can be partial/null. Supported edits preserve surrounding source and require reparse proof; unsupported/conflicting edits reject. Explicit regeneration reports losses. Original file byte encoding lies outside string/JSON APIs. + +## Per-converter code coverage + +Measured from the final combined suite. D2 parser/emitter shown separately; export-only barrels omitted. + +| Converter | Lines % | Branches % | +| --- | ---: | ---: | +| Adjacency list | 97.67 | 89.18 | +| Adjacency matrix | 97.95 | 92.04 | +| CSV/TSV | 99.40 | 89.10 | +| Cytoscape | 98.14 | 94.94 | +| D2 parser | 91.88 | 75.73 | +| D2 emitter | 92.00 | 78.32 | +| D3 | 97.22 | 87.17 | +| Document API | 98.74 | 87.60 | +| DOT | 97.53 | 90.35 | +| draw.io | 100.00 | 82.29 | +| Edge list | 87.50 | 86.66 | +| ELK | 99.53 | 93.18 | +| GEXF | 99.20 | 92.75 | +| GML | 98.19 | 92.28 | +| Graphlib JSON | 98.75 | 94.64 | +| GraphML | 99.28 | 94.21 | +| Graphology JSON | 98.73 | 86.15 | +| Graphviz JSON | 100.00 | 88.61 | +| JGF | 99.00 | 95.43 | +| Matrix Market | 96.89 | 83.13 | +| Mermaid Architecture | 100.00 | 88.52 | +| Mermaid Block | 95.52 | 80.95 | +| Mermaid C4 | 98.64 | 83.66 | +| Mermaid Class | 92.37 | 80.20 | +| Mermaid ER | 98.90 | 82.89 | +| Mermaid Flowchart | 92.87 | 80.51 | +| Mermaid GitGraph | 100.00 | 90.37 | +| Mermaid Ishikawa | 100.00 | 85.71 | +| Mermaid Mindmap | 100.00 | 89.55 | +| Mermaid Requirement | 100.00 | 89.21 | +| Mermaid Sankey | 100.00 | 88.09 | +| Mermaid Sequence | 95.32 | 83.08 | +| Mermaid State | 98.45 | 88.55 | +| Mermaid UseCase | 100.00 | 85.71 | +| NetworkX node-link | 98.87 | 96.59 | +| Pajek | 98.68 | 83.81 | +| PlantUML | 100.00 | 83.74 | +| TGF | 97.82 | 88.88 | +| xyflow | 97.77 | 96.26 | + +## Native evidence and limits + +Pinned native parsers/runtime libraries, official schemas and independently authored fixtures supplement converter tests. See [native validation](../docs/native-validation.md), [support inventory](../src/formats/support.ts) and each format README for exact scope. + +- Mermaid 12.1.0: fourteen scoped dialects, database semantics and decoded display text. Sankey is ASCII/trimmed-name scoped; C4 reverse directions normalized and duplicate ordered native relation pairs reject. +- D2 0.1.33: actual compiler graph/diagram semantics; opaque imports/substitutions do not become fabricated Graph semantics. +- Graphviz 15.0.0: actual DOT and JSON output; `fdp` cluster endpoints independently verified. JSON is an output profile, with no native JSON input reader. +- GraphML/GEXF/JGF: byte-pinned official schemas with unchanged upstream limitations; standard XML/JGF profiles are explicit options. Schema acceptance alone does not prove full semantics. +- CSV, Graphology, Graphlib, NetworkX, Cytoscape, D3 and ELK: independent reader/runtime assertions for documented mappings. +- draw.io: actual maxgraph model import and uncompressed generation XSD; no draw.io application rendering claim. Compressed models have independent inflation/import proof; that published XSD rejects compressed outer pages. +- Pajek: igraph plus the narrower NetworkX subset; duplicate labels, comments and fixed-count records expose reader differences. +- Matrices: actual SciPy/NumPy math, stored zeros, symmetry, duplicates and a 20,000-node sparse matrix. Ambiguous aggregate/count edits reject. +- PlantUML 1.2026.8 / Java 21: actual SVG identities/displays/direction and class XMI. Default XMI omits relationships; `xmi:star` verifies Generalization. Headless JVM fixes macOS AWT shutdown stalls without increasing native deadlines. + +The 10,000-node draw.io bulk-label probe improved from 15.58s to 3.91s after chunked source-span assembly. Timings characterize this machine; they are not universal latency guarantees. + +## Mapping-gated follow-ups + +RDF/Turtle/N-Triples/JSON-LD, GraphSON, CX2 and DGML still need explicit semantic profiles. RDF literal properties versus literal nodes remains an architectural choice. graph6/digraph6/sparse6 remains lower priority. None is advertised as implemented. diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 0000000..42dcf94 --- /dev/null +++ b/plans/README.md @@ -0,0 +1,16 @@ +# Conversion fidelity implementation + +Planned at `de5ebf8`, 2026-10-12. User authorized implementation, common-sense API choices, and questions for consequential architecture decisions. + +| Plan | Work | Depends on | Status | +| --- | --- | --- | --- | +| 001 | Honest claims and failing preservation regressions | None | DONE: 717 baseline tests pass; 39 regressions reproduce failures; types/check pass | +| 002 | Repair existing converter semantics | 001 | DONE: reviewer pnpm verify passed, 3517 tests plus packed runtime/types smoke; backward-compatible JGF default | +| 003 | Serializable document preservation and diagnostics | 002 | DONE: all21 document registrations; exact source/JSON persistence; strict diagnostics; reviewer full gate passed | +| 004 | Native validation and missing adapters | 003 | DONE: ten new adapters and six Mermaid kinds; reviewer full gate, 3994 Node20 core / 4222 Node24 combined tests; 97.10% lines / 86.44% branches | + +Source edits belong to executors. All work uses `/Users/davidkpiano/.codex/worktrees/graph/conversion-fidelity`, branch `feature/conversion-fidelity`, based on fetched `origin/main`. Shared checkout remains untouched. Review every diff and run `pnpm verify` before publication. Changesets and affected READMEs required. + +New format priorities: Graphology, Graphlib, NetworkX node-link, CSV/TSV first; Graphviz JSON, draw.io, Pajek, Matrix Market, PlantUML next. RDF, GraphSON, CX2, DGML are domain-dependent; define semantic profiles before claiming support. Mermaid graph-like additions: Architecture, Requirement, Sankey, GitGraph, C4, Use Case. Never equate source preservation with complete semantic interpretation. + +Final [verification report](005-final-verification.md) records per-converter coverage, native evidence, remaining scope limits and mapping-gated follow-ups. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39becb3..9ce56ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,12 @@ importers: '@hpcc-js/wasm-graphviz': specifier: ^1.22.0 version: 1.22.0 + '@maxgraph/core': + specifier: 0.25.0 + version: 0.25.0 + '@terrastruct/d2': + specifier: 0.1.33 + version: 0.1.33 '@types/d3-array': specifier: ^3.2.2 version: 3.2.2 @@ -32,6 +38,9 @@ importers: '@types/d3-hierarchy': specifier: ^3.1.7 version: 3.1.7 + '@types/jsdom': + specifier: 30.0.0 + version: 30.0.0 '@types/node': specifier: ^25.0.3 version: 25.9.3 @@ -41,9 +50,18 @@ importers: '@xyflow/system': specifier: ^0.0.75 version: 0.0.75 + ajv: + specifier: 8.20.0 + version: 8.20.0 + ajv-draft-04: + specifier: 1.0.0 + version: 1.0.0(ajv@8.20.0) bumpp: specifier: ^10.3.2 version: 10.4.1(magicast@0.5.3) + csv-parse: + specifier: 7.0.3 + version: 7.0.3 cytoscape: specifier: ^3.34.0 version: 3.34.0 @@ -65,6 +83,9 @@ importers: fast-xml-parser: specifier: ^5.3.4 version: 5.8.0 + fflate: + specifier: 0.8.3 + version: 0.8.3 graphology: specifier: ^0.26.0 version: 0.26.0(graphology-types@0.24.8) @@ -83,6 +104,12 @@ importers: graphology-types: specifier: ^0.24.8 version: 0.24.8 + jsdom: + specifier: 30.1.2 + version: 30.1.2 + mermaid: + specifier: 12.1.0 + version: 12.1.0 ngraph.graph: specifier: ^20.1.2 version: 20.1.2 @@ -103,10 +130,13 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.16 - version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@30.1.2)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) webcola: specifier: ^3.4.0 version: 3.4.0 + xmllint-wasm: + specifier: 5.3.0 + version: 5.3.0(@types/node@25.9.3) xstate: specifier: ^5.28.0 version: 5.32.0 @@ -116,6 +146,17 @@ importers: packages: + '@antfu/install-pkg@2.1.0': + resolution: {integrity: sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==, tarball: https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.1.0.tgz} + + '@asamuzakjp/css-color@7.1.5': + resolution: {integrity: sha512-jCpAvj8apa7SKHZYogk0rbXB3Kb0hLFrpkWF+Ap+vBA5rirItnpv9NKyAyEuDuFizvl0qwnj6JWR70RGJ0rOlw==, tarball: https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.5.tgz} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@asamuzakjp/dom-selector@9.2.4': + resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==, tarball: https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@babel/generator@7.29.7': resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==, tarball: https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz} engines: {node: '>=6.9.0'} @@ -145,6 +186,13 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==, tarball: https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz} engines: {node: '>=18'} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==, tarball: https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz} + + '@bramus/specificity@2.4.3': + resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==, tarball: https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz} + hasBin: true + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==, tarball: https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.1.1.tgz} @@ -206,6 +254,57 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==, tarball: https://registry.npmjs.org/@changesets/write/-/write-0.4.0.tgz} + '@chevrotain/cst-dts-gen@13.2.0': + resolution: {integrity: sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg==, tarball: https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-13.2.0.tgz} + + '@chevrotain/gast@13.2.0': + resolution: {integrity: sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg==, tarball: https://registry.npmjs.org/@chevrotain/gast/-/gast-13.2.0.tgz} + + '@chevrotain/regexp-to-ast@13.2.0': + resolution: {integrity: sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw==, tarball: https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-13.2.0.tgz} + + '@chevrotain/types@13.2.0': + resolution: {integrity: sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==, tarball: https://registry.npmjs.org/@chevrotain/types/-/types-13.2.0.tgz} + + '@chevrotain/utils@13.2.0': + resolution: {integrity: sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q==, tarball: https://registry.npmjs.org/@chevrotain/utils/-/utils-13.2.0.tgz} + + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==, tarball: https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==, tarball: https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.6': + resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==, tarball: https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==, tarball: https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15': + resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==, tarball: https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==, tarball: https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz} + engines: {node: '>=20.19.0'} + '@dagrejs/dagre@3.0.0': resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==, tarball: https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz} @@ -377,9 +476,24 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==, tarball: https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@hpcc-js/wasm-graphviz@1.22.0': resolution: {integrity: sha512-/l6m+ev4TzYwiSm2pklMALiKejuheJwFkOHKrTAvFei+US+MVv6Vgk06xMc4Mjn57WiqzheMVGnmNu1iqkcBdA==, tarball: https://registry.npmjs.org/@hpcc-js/wasm-graphviz/-/wasm-graphviz-1.22.0.tgz} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==, tarball: https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz} + + '@iconify/utils@3.1.7': + resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==, tarball: https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==, tarball: https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz} engines: {node: '>=18'} @@ -408,6 +522,13 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==, tarball: https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz} + '@maxgraph/core@0.25.0': + resolution: {integrity: sha512-RzIewujF2xt2Z1hJhFeEKil203P8Zss08SfFpuZqQD01oSNcZWlaxdicc8pelNHwndGdHAvm5Cuh/nkaG7OH8Q==, tarball: https://registry.npmjs.org/@maxgraph/core/-/core-0.25.0.tgz} + + '@mermaid-js/parser@2.0.1': + resolution: {integrity: sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA==, tarball: https://registry.npmjs.org/@mermaid-js/parser/-/parser-2.0.1.tgz} + engines: {node: '>=22.12.0'} + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==, tarball: https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz} peerDependencies: @@ -734,6 +855,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, tarball: https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz} + '@terrastruct/d2@0.1.33': + resolution: {integrity: sha512-eK5hyfGIJFolC7sUsiKvWdY9xGFctTe3d+PSijo09IYDso8psztC+A4SammizXtlwYZpnnW0AtDjfBYauceSeA==, tarball: https://registry.npmjs.org/@terrastruct/d2/-/d2-0.1.33.tgz} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==, tarball: https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz} @@ -746,42 +870,123 @@ packages: '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==, tarball: https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz} + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==, tarball: https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==, tarball: https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==, tarball: https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==, tarball: https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz} + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==, tarball: https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==, tarball: https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==, tarball: https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz} + '@types/d3-drag@3.0.7': resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==, tarball: https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz} + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==, tarball: https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==, tarball: https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==, tarball: https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz} + '@types/d3-force@3.0.10': resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==, tarball: https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz} + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==, tarball: https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==, tarball: https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz} + '@types/d3-hierarchy@3.1.7': resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==, tarball: https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz} '@types/d3-interpolate@3.0.4': resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==, tarball: https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz} + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==, tarball: https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==, tarball: https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==, tarball: https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==, tarball: https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==, tarball: https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==, tarball: https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz} + '@types/d3-selection@3.0.11': resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==, tarball: https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz} + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==, tarball: https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==, tarball: https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==, tarball: https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==, tarball: https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz} + '@types/d3-transition@3.0.9': resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==, tarball: https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz} '@types/d3-zoom@3.0.8': resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==, tarball: https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz} + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==, tarball: https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, tarball: https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz} '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==, tarball: https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz} + + '@types/jsdom@30.0.0': + resolution: {integrity: sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==, tarball: https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==, tarball: https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz} '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==, tarball: https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==, tarball: https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==, tarball: https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz} + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==, tarball: https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz} + '@vitest/coverage-v8@4.1.8': resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==, tarball: https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz} peerDependencies: @@ -826,6 +1031,17 @@ packages: '@yomguithereal/helpers@1.1.1': resolution: {integrity: sha512-UYvAq/XCA7xoh1juWDYsq3W0WywOB+pz8cgVnE1b45ZfdMhBvHDrgmSFG3jXeZSr2tMTYLGHFHON+ekG05Jebg==, tarball: https://registry.npmjs.org/@yomguithereal/helpers/-/helpers-1.1.1.tgz} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==, tarball: https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==, tarball: https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==, tarball: https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz} engines: {node: '>=6'} @@ -869,6 +1085,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==, tarball: https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz} engines: {node: '>=4'} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==, tarball: https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==, tarball: https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz} @@ -900,32 +1119,90 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==, tarball: https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz} + chevrotain@13.2.0: + resolution: {integrity: sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA==, tarball: https://registry.npmjs.org/chevrotain/-/chevrotain-13.2.0.tgz} + engines: {node: '>=22.0.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==, tarball: https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz} engines: {node: '>= 20.19.0'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==, tarball: https://registry.npmjs.org/commander/-/commander-7.2.0.tgz} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==, tarball: https://registry.npmjs.org/commander/-/commander-8.3.0.tgz} + engines: {node: '>= 12'} + confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==, tarball: https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, tarball: https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==, tarball: https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==, tarball: https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, tarball: https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==, tarball: https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + csv-parse@7.0.3: + resolution: {integrity: sha512-YFd3QM/yo17vH91L1IOZuvl09zM0zEEtdfTcOCKWcMdM++MNaQSyp09slXyFCLaPXvHstQFx/xC8myiFHSMUvw==, tarball: https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.3.tgz} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==, tarball: https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==, tarball: https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz} + peerDependencies: + cytoscape: ^3.2.0 + cytoscape@3.34.0: resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==, tarball: https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz} engines: {node: '>=0.10'} + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==, tarball: https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz} + d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==, tarball: https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz} engines: {node: '>=12'} + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==, tarball: https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==, tarball: https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==, tarball: https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz} + engines: {node: '>=12'} + d3-color@3.1.0: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==, tarball: https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz} engines: {node: '>=12'} + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==, tarball: https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==, tarball: https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz} + engines: {node: '>=12'} + d3-dispatch@1.0.6: resolution: {integrity: sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==, tarball: https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz} @@ -940,14 +1217,31 @@ packages: resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==, tarball: https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz} engines: {node: '>=12'} + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==, tarball: https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz} + engines: {node: '>=12'} + hasBin: true + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==, tarball: https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz} engines: {node: '>=12'} + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==, tarball: https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz} + engines: {node: '>=12'} + d3-force@3.0.0: resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==, tarball: https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz} engines: {node: '>=12'} + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==, tarball: https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==, tarball: https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz} + engines: {node: '>=12'} + d3-hierarchy@3.1.2: resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==, tarball: https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz} engines: {node: '>=12'} @@ -959,10 +1253,33 @@ packages: d3-path@1.0.9: resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==, tarball: https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz} + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==, tarball: https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==, tarball: https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz} + engines: {node: '>=12'} + d3-quadtree@3.0.1: resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==, tarball: https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz} engines: {node: '>=12'} + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==, tarball: https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==, tarball: https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==, tarball: https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==, tarball: https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz} + engines: {node: '>=12'} + d3-selection@1.4.2: resolution: {integrity: sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==, tarball: https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz} @@ -973,6 +1290,18 @@ packages: d3-shape@1.3.7: resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==, tarball: https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz} + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==, tarball: https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==, tarball: https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==, tarball: https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz} + engines: {node: '>=12'} + d3-timer@1.0.10: resolution: {integrity: sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==, tarball: https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz} @@ -990,12 +1319,32 @@ packages: resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==, tarball: https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz} engines: {node: '>=12'} + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==, tarball: https://registry.npmjs.org/d3/-/d3-7.9.0.tgz} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==, tarball: https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz} + + data-urls@8.0.0: + resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==, tarball: https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz} + engines: {node: ^22.14.0 || >=24.0.0} + dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==, tarball: https://registry.npmjs.org/dataloader/-/dataloader-1.4.0.tgz} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==, tarball: https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==, tarball: https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==, tarball: https://registry.npmjs.org/defu/-/defu-6.1.7.tgz} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==, tarball: https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==, tarball: https://registry.npmjs.org/destr/-/destr-2.0.5.tgz} @@ -1011,6 +1360,9 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==, tarball: https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz} engines: {node: '>=8'} + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==, tarball: https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz} engines: {node: '>=12'} @@ -1034,6 +1386,9 @@ packages: elkjs@0.11.1: resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==, tarball: https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz} + elkjs@0.9.3: + resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==, tarball: https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==, tarball: https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz} engines: {node: '>=14'} @@ -1042,9 +1397,16 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==, tarball: https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz} engines: {node: '>=8.6'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==, tarball: https://registry.npmjs.org/entities/-/entities-8.1.0.tgz} + engines: {node: '>=20.19.0'} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==, tarball: https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==, tarball: https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz} + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz} engines: {node: '>=18'} @@ -1076,10 +1438,16 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==, tarball: https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, tarball: https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==, tarball: https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz} engines: {node: '>=8.6.0'} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==, tarball: https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz} + fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==, tarball: https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz} @@ -1099,6 +1467,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==, tarball: https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==, tarball: https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz} engines: {node: '>=8'} @@ -1176,6 +1547,9 @@ packages: peerDependencies: graphology-types: '>=0.24.0' + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==, tarball: https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, tarball: https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz} engines: {node: '>=8'} @@ -1183,6 +1557,10 @@ packages: hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==, tarball: https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz} + html-encoding-sniffer@7.0.0: + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==, tarball: https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz} + engines: {node: ^22.13.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==, tarball: https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz} @@ -1190,6 +1568,10 @@ packages: resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==, tarball: https://registry.npmjs.org/human-id/-/human-id-4.2.0.tgz} hasBin: true + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==, tarball: https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==, tarball: https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz} engines: {node: '>=0.10.0'} @@ -1198,10 +1580,16 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, tarball: https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz} engines: {node: '>= 4'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==, tarball: https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz} + import-without-cache@0.2.5: resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==, tarball: https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.2.5.tgz} engines: {node: '>=20.19.0'} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==, tarball: https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz} + internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==, tarball: https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz} engines: {node: '>=12'} @@ -1218,6 +1606,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==, tarball: https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==, tarball: https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==, tarball: https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz} engines: {node: '>=4'} @@ -1256,17 +1647,42 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==, tarball: https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz} hasBin: true + jsdom@30.1.2: + resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==, tarball: https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==, tarball: https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz} engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==, tarball: https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz} + jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==, tarball: https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz} jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==, tarball: https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==, tarball: https://registry.npmjs.org/katex/-/katex-0.16.47.tgz} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==, tarball: https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==, tarball: https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==, tarball: https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==, tarball: https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz} engines: {node: '>= 12.0.0'} @@ -1345,9 +1761,16 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==, tarball: https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz} engines: {node: '>=8'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==, tarball: https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==, tarball: https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, tarball: https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz} @@ -1358,10 +1781,22 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==, tarball: https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz} engines: {node: '>=10'} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==, tarball: https://registry.npmjs.org/marked/-/marked-16.4.2.tgz} + engines: {node: '>= 20'} + hasBin: true + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==, tarball: https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, tarball: https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz} engines: {node: '>= 8'} + mermaid@12.1.0: + resolution: {integrity: sha512-wlVCp+8eTupfCeeFvoZNNiTuHrvag0P2jz/ILgb/f/6jkVokUefOcujefi8qUe/j2asHiSePncVsz/xzzA80LQ==, tarball: https://registry.npmjs.org/mermaid/-/mermaid-12.1.0.tgz} + engines: {node: '>=22.12.0'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, tarball: https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz} engines: {node: '>=8.6'} @@ -1435,9 +1870,18 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==, tarball: https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz} + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==, tarball: https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz} + pandemonium@2.4.1: resolution: {integrity: sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==, tarball: https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==, tarball: https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==, tarball: https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz} engines: {node: '>=8'} @@ -1478,6 +1922,12 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==, tarball: https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==, tarball: https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==, tarball: https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==, tarball: https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz} engines: {node: ^10 || ^12 || >=14} @@ -1492,6 +1942,10 @@ packages: engines: {node: '>=18'} hasBin: true + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, tarball: https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz} + engines: {node: '>=6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==, tarball: https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz} @@ -1512,6 +1966,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz} engines: {node: '>= 20.19.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==, tarball: https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==, tarball: https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz} engines: {node: '>=8'} @@ -1523,6 +1981,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==, tarball: https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==, tarball: https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz} + rolldown-plugin-dts@0.20.0: resolution: {integrity: sha512-cLAY1kN2ilTYMfZcFlGWbXnu6Nb+8uwUBsi+Mjbh4uIx7IN8uMOmJ7RxrrRgPsO4H7eSz3E+JwGoL1gyugiyUA==, tarball: https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.20.0.tgz} engines: {node: '>=20.19.0'} @@ -1557,9 +2018,15 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==, tarball: https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, tarball: https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==, tarball: https://registry.npmjs.org/rw/-/rw-1.3.3.tgz} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==, tarball: https://registry.npmjs.org/sade/-/sade-1.8.1.tgz} engines: {node: '>=6'} @@ -1567,6 +2034,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, tarball: https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==, tarball: https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz} + engines: {node: '>=v12.22.7'} + semver@7.8.4: resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==, tarball: https://registry.npmjs.org/semver/-/semver-7.8.4.tgz} engines: {node: '>=10'} @@ -1618,6 +2089,9 @@ packages: strnum@2.4.0: resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==, tarball: https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==, tarball: https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==, tarball: https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz} engines: {node: '>=8'} @@ -1633,6 +2107,10 @@ packages: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==, tarball: https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz} engines: {node: '>=18'} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==, tarball: https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz} engines: {node: '>=12.0.0'} @@ -1641,17 +2119,36 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==, tarball: https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz} engines: {node: '>=14.0.0'} + tldts-core@7.4.18: + resolution: {integrity: sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==, tarball: https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz} + + tldts@7.4.18: + resolution: {integrity: sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==, tarball: https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==, tarball: https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz} engines: {node: '>=8.0'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==, tarball: https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==, tarball: https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz} + tr46@7.0.0: + resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==, tarball: https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz} + engines: {node: ^22.14.0 || >=24.0.0} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==, tarball: https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz} hasBin: true + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==, tarball: https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz} + engines: {node: '>=6.10'} + tsdown@0.18.4: resolution: {integrity: sha512-J/tRS6hsZTkvqmt4+xdELUCkQYDuUCXgBv0fw3ImV09WPGbEKfsPD65E+WUjSu3E7Z6tji9XZ1iWs8rbGqB/ZA==, tarball: https://registry.npmjs.org/tsdown/-/tsdown-0.18.4.tgz} engines: {node: '>=20.19.0'} @@ -1696,6 +2193,13 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz} + undici-types@8.11.2: + resolution: {integrity: sha512-iMVNmWZ0leK/goS6eXMizSzmm9CDWtyphwbaCms3DNLqRxDL+mMoNVcZMTyyVgXP0N+Z8neAMzDoUOUJL8veKg==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-8.11.2.tgz} + + undici@8.11.2: + resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==, tarball: https://registry.npmjs.org/undici/-/undici-8.11.2.tgz} + engines: {node: '>=22.19.0'} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==, tarball: https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz} engines: {node: '>= 4.0.0'} @@ -1710,6 +2214,10 @@ packages: synckit: optional: true + uuid@14.0.3: + resolution: {integrity: sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==, tarball: https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz} + hasBin: true + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==, tarball: https://registry.npmjs.org/vite/-/vite-8.0.16.tgz} engines: {node: ^20.19.0 || >=22.12.0} @@ -1794,12 +2302,28 @@ packages: jsdom: optional: true + w3c-xmlserializer@6.0.0: + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==, tarball: https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + webcola@3.4.0: resolution: {integrity: sha512-4BiLXjXw3SJHo3Xd+rF+7fyClT6n7I+AR6TkBqyQ4kTsePSAMDLRCXY1f3B/kXJeP9tYn4G1TblxTO+jAt0gaw==, tarball: https://registry.npmjs.org/webcola/-/webcola-3.4.0.tgz} webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==, tarball: https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==, tarball: https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==, tarball: https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz} + engines: {node: '>=20'} + + whatwg-url@17.2.0: + resolution: {integrity: sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==, tarball: https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz} + engines: {node: ^22.14.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==, tarball: https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz} @@ -1813,10 +2337,23 @@ packages: engines: {node: '>=8'} hasBin: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==, tarball: https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz} + engines: {node: '>=18'} + xml-naming@0.1.0: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==, tarball: https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz} engines: {node: '>=16.0.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==, tarball: https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz} + + xmllint-wasm@5.3.0: + resolution: {integrity: sha512-sEgRXdf2t13eYCFCURmCE6h2hwleEOIS9VL/jOh9JJJnez2YXIooi2Ghm05/JZEPXyPawN8sMASPVZlmh8Sd6A==, tarball: https://registry.npmjs.org/xmllint-wasm/-/xmllint-wasm-5.3.0.tgz} + engines: {node: '>=16'} + peerDependencies: + '@types/node': '>=16' + xstate@5.32.0: resolution: {integrity: sha512-zsk73aWGmxn9z34P0kbiod5JwTvdYRW3+IDxITq8sd9+VWwMyW7BUzpplnYy9mIEXa6V8IMDv7Hy4m0mhT5+2Q==, tarball: https://registry.npmjs.org/xstate/-/xstate-5.32.0.tgz} @@ -1830,6 +2367,26 @@ packages: snapshots: + '@antfu/install-pkg@2.1.0': + dependencies: + package-manager-detector: 1.9.0 + tinyexec: 1.3.1 + + '@asamuzakjp/css-color@7.1.5': + dependencies: + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 + + '@asamuzakjp/dom-selector@9.2.4': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + '@babel/generator@7.29.7': dependencies: '@babel/parser': 7.29.7 @@ -1855,6 +2412,12 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@braintree/sanitize-url@7.1.2': {} + + '@bramus/specificity@2.4.3': + dependencies: + css-tree: 3.2.1 + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -2013,6 +2576,45 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 + '@chevrotain/cst-dts-gen@13.2.0': + dependencies: + '@chevrotain/gast': 13.2.0 + '@chevrotain/types': 13.2.0 + + '@chevrotain/gast@13.2.0': + dependencies: + '@chevrotain/types': 13.2.0 + + '@chevrotain/regexp-to-ast@13.2.0': {} + + '@chevrotain/types@13.2.0': {} + + '@chevrotain/utils@13.2.0': {} + + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + '@dagrejs/dagre@3.0.0': dependencies: '@dagrejs/graphlib': 4.0.1 @@ -2113,8 +2715,18 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@exodus/bytes@1.16.0': {} + '@hpcc-js/wasm-graphviz@1.22.0': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.7': + dependencies: + '@antfu/install-pkg': 2.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@inquirer/external-editor@1.0.3(@types/node@25.9.3)': dependencies: chardet: 2.1.1 @@ -2152,6 +2764,12 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@maxgraph/core@0.25.0': {} + + '@mermaid-js/parser@2.0.1': + dependencies: + '@chevrotain/types': 13.2.0 + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2342,6 +2960,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@terrastruct/d2@0.1.33': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -2359,22 +2979,79 @@ snapshots: '@types/d3-array@3.2.2': {} + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + '@types/d3-color@3.1.3': {} + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + '@types/d3-drag@3.0.7': dependencies: '@types/d3-selection': 3.0.11 + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + '@types/d3-force@3.0.10': {} + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + '@types/d3-hierarchy@3.1.7': {} '@types/d3-interpolate@3.0.4': dependencies: '@types/d3-color': 3.1.3 + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + '@types/d3-selection@3.0.11': {} + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/d3-transition@3.0.9': dependencies: '@types/d3-selection': 3.0.11 @@ -2384,16 +3061,68 @@ snapshots: '@types/d3-interpolate': 3.0.4 '@types/d3-selection': 3.0.11 + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + + '@types/jsdom@30.0.0': + dependencies: + '@types/node': 25.9.3 + '@types/tough-cookie': 4.0.5 + parse5: 8.0.1 + undici-types: 8.11.2 + '@types/node@12.20.55': {} '@types/node@25.9.3': dependencies: undici-types: 7.24.6 + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': + optional: true + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -2406,7 +3135,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@30.1.2)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/expect@4.1.8': dependencies: @@ -2463,6 +3192,17 @@ snapshots: '@yomguithereal/helpers@1.1.1': {} + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -2498,6 +3238,10 @@ snapshots: dependencies: is-windows: 1.0.2 + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + birpc@4.0.0: {} braces@3.0.3: @@ -2543,28 +3287,91 @@ snapshots: chardet@2.1.1: {} + chevrotain@13.2.0: + dependencies: + '@chevrotain/cst-dts-gen': 13.2.0 + '@chevrotain/gast': 13.2.0 + '@chevrotain/regexp-to-ast': 13.2.0 + '@chevrotain/types': 13.2.0 + '@chevrotain/utils': 13.2.0 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 + commander@7.2.0: {} + + commander@8.3.0: {} + confbox@0.2.4: {} convert-source-map@2.0.0: {} + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + csv-parse@7.0.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + cytoscape@3.34.0: {} + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + d3-array@3.2.4: dependencies: internmap: 2.0.3 + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + d3-color@3.1.0: {} + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + d3-dispatch@1.0.6: {} d3-dispatch@3.0.1: {} @@ -2579,14 +3386,30 @@ snapshots: d3-dispatch: 3.0.1 d3-selection: 3.0.0 + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + d3-ease@3.0.1: {} + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + d3-force@3.0.0: dependencies: d3-dispatch: 3.0.1 d3-quadtree: 3.0.1 d3-timer: 3.0.1 + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + d3-hierarchy@3.1.2: {} d3-interpolate@3.0.1: @@ -2595,8 +3418,32 @@ snapshots: d3-path@1.0.9: {} + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + d3-quadtree@3.0.1: {} + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-selection@1.4.2: {} d3-selection@3.0.0: {} @@ -2605,6 +3452,18 @@ snapshots: dependencies: d3-path: 1.0.9 + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + d3-timer@1.0.10: {} d3-timer@3.0.1: {} @@ -2626,10 +3485,63 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + data-urls@8.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 17.2.0 + transitivePeerDependencies: + - '@noble/hashes' + dataloader@1.4.0: {} + dayjs@1.11.23: {} + + decimal.js@10.6.0: {} + defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + destr@2.0.5: {} detect-indent@6.1.0: {} @@ -2640,6 +3552,10 @@ snapshots: dependencies: path-type: 4.0.0 + dompurify@3.4.16: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@17.4.2: {} dotenv@8.6.0: {} @@ -2650,6 +3566,8 @@ snapshots: elkjs@0.11.1: {} + elkjs@0.9.3: {} + empathic@2.0.1: {} enquirer@2.4.1: @@ -2657,8 +3575,12 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@8.1.0: {} + es-module-lexer@2.1.0: {} + es-toolkit@1.52.0: {} + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -2704,6 +3626,8 @@ snapshots: extendable-error@0.1.7: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2712,6 +3636,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-uri@3.1.8: {} + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -2733,6 +3659,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -2823,22 +3751,38 @@ snapshots: events: 3.3.0 graphology-types: 0.24.8 + hachure-fill@0.5.2: {} + has-flag@4.0.0: {} hookable@6.1.1: {} + html-encoding-sniffer@7.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} human-id@4.2.0: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 ignore@5.3.2: {} + import-meta-resolve@4.2.0: {} + import-without-cache@0.2.5: {} + internmap@1.0.1: {} + internmap@2.0.3: {} is-extglob@2.1.1: {} @@ -2849,6 +3793,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -2883,14 +3829,51 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@30.1.2: + dependencies: + '@asamuzakjp/css-color': 7.1.5 + '@asamuzakjp/dom-selector': 9.2.4 + '@bramus/specificity': 2.4.3 + '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) + '@exodus/bytes': 1.16.0 + css-tree: 3.2.1 + data-urls: 8.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 7.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + parse5: 8.0.1 + saxes: 6.0.0 + tough-cookie: 6.0.2 + undici: 8.11.2 + w3c-xmlserializer: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.2.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + jsonc-parser@3.3.1: {} jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 + katex@0.16.47: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -2944,8 +3927,12 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash-es@4.18.1: {} + lodash.startcase@4.4.0: {} + lru-cache@11.5.3: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2960,8 +3947,38 @@ snapshots: dependencies: semver: 7.8.4 + marked@16.4.2: {} + + mdn-data@2.27.1: {} + merge2@1.4.1: {} + mermaid@12.1.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.7 + '@mermaid-js/parser': 2.0.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + chevrotain: 13.2.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.23 + dompurify: 3.4.16 + elkjs: 0.9.3 + es-toolkit: 1.52.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.3 + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -3017,10 +4034,18 @@ snapshots: package-manager-detector@1.6.0: {} + package-manager-detector@1.9.0: {} + pandemonium@2.4.1: dependencies: mnemonist: 0.39.8 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-expression-matcher@1.5.0: {} @@ -3047,6 +4072,13 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss@8.5.15: dependencies: nanoid: 3.3.15 @@ -3062,6 +4094,8 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 + punycode@2.3.1: {} + quansync@0.2.11: {} quansync@1.0.0: {} @@ -3082,12 +4116,16 @@ snapshots: readdirp@5.0.0: {} + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} reusify@1.1.0: {} + robust-predicates@3.0.3: {} + rolldown-plugin-dts@0.20.0(rolldown@1.0.0-beta.57(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@5.9.3): dependencies: '@babel/generator': 7.29.7 @@ -3168,16 +4206,29 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.3 '@rolldown/binding-win32-x64-msvc': 1.0.3 + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + sade@1.8.1: dependencies: mri: 1.2.0 safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + semver@7.8.4: {} shebang-command@2.0.0: @@ -3215,6 +4266,8 @@ snapshots: dependencies: anynum: 1.0.0 + stylis@4.4.0: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3225,6 +4278,8 @@ snapshots: tinyexec@1.2.4: {} + tinyexec@1.3.1: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -3232,14 +4287,30 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.18: {} + + tldts@7.4.18: + dependencies: + tldts-core: 7.4.18 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.18 + tr46@0.0.3: {} + tr46@7.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} + ts-dedent@2.3.0: {} + tsdown@0.18.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(publint@0.3.21)(typescript@5.9.3): dependencies: ansis: 4.3.1 @@ -3288,12 +4359,18 @@ snapshots: undici-types@7.24.6: {} + undici-types@8.11.2: {} + + undici@8.11.2: {} + universalify@0.1.2: {} unrun@0.2.39: dependencies: rolldown: 1.0.0-rc.17 + uuid@14.0.3: {} + vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -3309,7 +4386,7 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@30.1.2)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -3334,9 +4411,14 @@ snapshots: optionalDependencies: '@types/node': 25.9.3 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + jsdom: 30.1.2 transitivePeerDependencies: - msw + w3c-xmlserializer@6.0.0: + dependencies: + xml-name-validator: 5.0.0 + webcola@3.4.0: dependencies: d3-dispatch: 1.0.6 @@ -3346,6 +4428,18 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@17.2.0: + dependencies: + '@exodus/bytes': 1.16.0 + tr46: 7.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -3360,8 +4454,16 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + xml-name-validator@5.0.0: {} + xml-naming@0.1.0: {} + xmlchars@2.2.0: {} + + xmllint-wasm@5.3.0(@types/node@25.9.3): + dependencies: + '@types/node': 25.9.3 + xstate@5.32.0: {} yaml@2.9.0: {} diff --git a/requirements/native.txt b/requirements/native.txt new file mode 100644 index 0000000..9ff2770 --- /dev/null +++ b/requirements/native.txt @@ -0,0 +1,6 @@ +# Native test oracles only; never library runtime dependencies. +# Versions verified against primary PyPI releases on 2026-10-12. +networkx==3.7 +scipy==1.18.1 +numpy==2.5.4 +igraph==1.0.0 diff --git a/scripts/native/d2-compile.mjs b/scripts/native/d2-compile.mjs new file mode 100644 index 0000000..8d8ed22 --- /dev/null +++ b/scripts/native/d2-compile.mjs @@ -0,0 +1,21 @@ +// Bounded subprocess: D2's public wrapper has no worker shutdown API. +import { D2 } from '@terrastruct/d2'; +let input = ''; +process.stdin.setEncoding('utf8'); +for await (const chunk of process.stdin) input += chunk; +try { + const sources = JSON.parse(input); + const compiler = new D2(); + const results = []; + for (const source of sources) { + try { + const result = await compiler.compile(source); + results.push({ ok: true, diagram: result.diagram, graphEdges: (result.graph.edges ?? []).map((edge) => ({ srcTableColumnIndex: edge?.srcTableColumnIndex, dstTableColumnIndex: edge?.dstTableColumnIndex })) }); + } catch (error) { + results.push({ ok: false, error: error instanceof Error ? error.message : String(error) }); + } + } + process.stdout.write(JSON.stringify(results), () => process.exit(0)); +} catch (error) { + process.stderr.write(String(error), () => process.exit(1)); +} diff --git a/scripts/native/igraph_oracle.py b/scripts/native/igraph_oracle.py new file mode 100644 index 0000000..9604bb4 --- /dev/null +++ b/scripts/native/igraph_oracle.py @@ -0,0 +1,61 @@ +"""Pinned igraph C-backed file reader oracle; one JSON request, private temporary file.""" + +import json +import platform +import sys +import tempfile +import warnings +from pathlib import Path + +import igraph + +EXPECTED_VERSION = "1.0.0" + + +def get_summary(graph): + return { + "directed": graph.is_directed(), + "graph": {name: graph[name] for name in graph.attributes()}, + "nodes": [{"id": vertex.index, "attributes": vertex.attributes()} for vertex in graph.vs], + "edges": [ + {"source": edge.source, "target": edge.target, "attributes": edge.attributes()} + for edge in graph.es + ], + } + + +def get_result(request): + if igraph.__version__ != EXPECTED_VERSION: + raise RuntimeError(f"Oracle requires igraph {EXPECTED_VERSION}, found {igraph.__version__}") + result = {"oracle": "igraph", "version": igraph.__version__, "python": platform.python_version()} + if request.get("operation") == "version": + return result + if request.get("operation") != "pajek" or not isinstance(request.get("source"), str): + raise ValueError("Only the native Pajek text reader operation is supported") + # The C reader is file based. Keep its UTF-8 input private and remove it on exit. + with tempfile.TemporaryDirectory(prefix="graph-native-igraph-") as directory: + filename = Path(directory) / "fixture.net" + filename.write_text(request["source"], encoding="utf-8") + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + graph = igraph.Graph.Read_Pajek(str(filename)) + result.update({"summary": get_summary(graph), "warnings": [str(warning.message) for warning in captured]}) + return result + + +def to_output(): + try: + request = json.load(sys.stdin) + if not isinstance(request, dict): + raise ValueError("Oracle request must be an object") + json.dump(get_result(request), sys.stdout, ensure_ascii=False, allow_nan=False) + sys.stdout.write("\n") + except Exception as error: + json.dump({"oracle": "igraph", "version": igraph.__version__, "error": type(error).__name__, "message": str(error)}, sys.stderr, ensure_ascii=False, allow_nan=False) + sys.stderr.write("\n") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(to_output()) diff --git a/scripts/native/networkx_oracle.py b/scripts/native/networkx_oracle.py new file mode 100644 index 0000000..08943fa --- /dev/null +++ b/scripts/native/networkx_oracle.py @@ -0,0 +1,75 @@ +"""Pinned native reader/writer oracle. One JSON request on stdin; no graph emulation.""" + +import io +import json +import platform +import sys + +import networkx as nx + +EXPECTED_VERSION = "3.7" + + +def get_summary(graph): + nodes = [{"id": node, "attributes": dict(attrs)} for node, attrs in graph.nodes(data=True)] + if graph.is_multigraph(): + edges = [ + {"source": source, "target": target, "key": key, "attributes": dict(attrs)} + for source, target, key, attrs in graph.edges(keys=True, data=True) + ] + else: + edges = [ + {"source": source, "target": target, "attributes": dict(attrs)} + for source, target, attrs in graph.edges(data=True) + ] + return { + "directed": graph.is_directed(), + "multigraph": graph.is_multigraph(), + "graph": dict(graph.graph), + "nodes": nodes, + "edges": edges, + } + + +def get_result(request): + if nx.__version__ != EXPECTED_VERSION: + raise RuntimeError(f"Oracle requires NetworkX {EXPECTED_VERSION}, found {nx.__version__}") + result = {"oracle": "networkx", "version": nx.__version__, "python": platform.python_version()} + operation = request.get("operation") + if operation == "version": + return result + options = request.get("options", {}) + if operation == "node-link": + # Pass only official 3.7 node-link keywords; errors remain native errors. + names = {key: options[key] for key in ("source", "target", "name", "key", "edges", "nodes") if key in options} + graph = nx.node_link_graph(request["data"], **names) + native = nx.node_link_data(graph, **names) + elif operation == "gml": + graph = nx.parse_gml(request["source"], label=options.get("label", None)) + native = nx.node_link_data(graph) + elif operation == "pajek": + graph = nx.read_pajek(io.BytesIO(request["source"].encode("utf-8")), encoding="UTF-8") + native = nx.node_link_data(graph) + else: + raise ValueError(f"Unsupported oracle operation: {operation}") + result.update({"summary": get_summary(graph), "roundTrip": native}) + return result + + +def to_output(): + try: + request = json.load(sys.stdin) + if not isinstance(request, dict): + raise ValueError("Oracle request must be an object") + result = get_result(request) + json.dump(result, sys.stdout, ensure_ascii=False, allow_nan=False) + sys.stdout.write("\n") + except Exception as error: + json.dump({"oracle": "networkx", "version": nx.__version__, "error": type(error).__name__, "message": str(error)}, sys.stderr, ensure_ascii=False, allow_nan=False) + sys.stderr.write("\n") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(to_output()) diff --git a/scripts/native/plantuml-tools.json b/scripts/native/plantuml-tools.json new file mode 100644 index 0000000..cee74f5 --- /dev/null +++ b/scripts/native/plantuml-tools.json @@ -0,0 +1,18 @@ +{ + "retrieved": "2026-10-12", + "plantuml": { + "version": "1.2026.8", + "url": "https://github.com/plantuml/plantuml/releases/download/v1.2026.8/plantuml-1.2026.8.jar", + "sha256": "5e1ecfa8ecd32c90b03bbf3b1eb6f020943f98ab0fcf4032be31a0002ee2c462", + "license": "GPL-3.0; development oracle only, never published", + "provenance": "https://github.com/plantuml/plantuml/releases/tag/v1.2026.8" + }, + "localJRE": { + "version": "Temurin 21.0.12.1+1-LTS", + "platform": "darwin-arm64", + "url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jre_aarch64_mac_hotspot_21.0.12.1_1.tar.gz", + "sha256": "dec50fc6f9fcd4fe3ae8cabf5a5fa68f6afc48841f7698e468e9aa5d54beed84", + "license": "GPL-2.0 with Classpath Exception; development oracle only", + "provenance": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=aarch64&image_type=jre&os=mac&vendor=eclipse" + } +} diff --git a/scripts/native/scipy_oracle.py b/scripts/native/scipy_oracle.py new file mode 100644 index 0000000..2627b47 --- /dev/null +++ b/scripts/native/scipy_oracle.py @@ -0,0 +1,30 @@ +"""Pinned, independent Matrix Market reader/writer. JSON stdin/stdout only.""" +import io +import json +import sys +import scipy +import numpy as np +from scipy.io import mmread, mmwrite +from scipy.sparse import issparse, coo_array + +request = json.load(sys.stdin) +if scipy.__version__ != '1.18.1' or np.__version__ != '2.5.4': + raise RuntimeError('SciPy/NumPy native oracle version mismatch') +source = request['source'] +value = mmread(io.BytesIO(source.encode('utf-8'))) +rows, cols = value.shape +if issparse(value): + coordinates = value.tocoo() + entries = [{'row': int(r), 'col': int(c), 'value': float(v)} for r, c, v in zip(coordinates.row, coordinates.col, coordinates.data)] + stored_zeros = int(np.count_nonzero(coordinates.data == 0)) +else: + entries = [{'row': int(r), 'col': int(c), 'value': float(value[r, c])} for r in range(rows) for c in range(cols) if value[r, c] != 0] + stored_zeros = None +result = {'oracle': 'scipy', 'scipy': scipy.__version__, 'numpy': np.__version__, 'shape': [rows, cols], 'sparse': bool(issparse(value)), 'entries': entries, 'storedZeros': stored_zeros} +if rows * cols <= request.get('maxDenseCells', 10000): + result['dense'] = (value.toarray() if issparse(value) else value).tolist() +output = io.BytesIO() +options = request.get('options', {}) +mmwrite(output, value, **options) +result['roundTrip'] = output.getvalue().decode('utf-8') +json.dump(result, sys.stdout, allow_nan=False) diff --git a/scripts/native/setup-plantuml.mjs b/scripts/native/setup-plantuml.mjs new file mode 100644 index 0000000..a806751 --- /dev/null +++ b/scripts/native/setup-plantuml.mjs @@ -0,0 +1,30 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, readdirSync, symlinkSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join, resolve } from 'node:path'; + +const root = resolve('.tools-native'); +const manifest = JSON.parse(readFileSync(new URL('./plantuml-tools.json', import.meta.url), 'utf8')); +mkdirSync(root, { recursive: true }); +function getArtifact(spec, name) { + const target = join(root, name); + if (!existsSync(target)) execFileSync('curl', ['--fail', '--location', '--silent', '--show-error', '--max-time', '180', spec.url, '--output', target], { timeout: 185000 }); + const actual = createHash('sha256').update(readFileSync(target)).digest('hex'); + if (actual !== spec.sha256) throw new Error(`Native oracle checksum mismatch: ${name}; delete the task-local file before retrying`); + return target; +} +getArtifact(manifest.plantuml, 'plantuml.jar'); +if (process.argv.includes('--jre')) { + if (`${process.platform}-${process.arch}` !== manifest.localJRE.platform) throw new Error('Pinned local JRE is darwin-arm64; use setup-java21/JAVA_HOME on CI'); + const archive = getArtifact(manifest.localJRE, 'jre.tar.gz'); + const entries = execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' }).trim().split('\n'); + if (entries.some((entry) => entry.startsWith('/') || entry.split('/').includes('..'))) throw new Error('Unsafe native JRE archive path'); + const extraction = join(root, 'jre'); + if (!existsSync(extraction)) { mkdirSync(extraction); execFileSync('tar', ['-xzf', archive, '-C', extraction]); } + const folders = readdirSync(extraction); + if (folders.length !== 1) throw new Error('Unexpected pinned JRE archive layout'); + const home = join(extraction, folders[0], 'Contents', 'Home'); + if (!existsSync(join(root, 'java'))) symlinkSync(home, join(root, 'java'), 'dir'); + execFileSync(join(root, 'java', 'bin', 'java'), ['-version'], { stdio: 'inherit' }); +} +console.log('Pinned PlantUML oracle ready at .tools-native/plantuml.jar'); diff --git a/scripts/smoke-package.ts b/scripts/smoke-package.ts index ef7a553..63031e4 100644 --- a/scripts/smoke-package.ts +++ b/scripts/smoke-package.ts @@ -166,6 +166,147 @@ async function main(): Promise { `edgeList[0]?.[0];`, ], }, + './document': { + phase: 'core', + runtime: [ + `const converter = $MOD.createGraphDocumentConverter({ format: 'smoke', codec: { from: () => m0.createGraph({ nodes: [{ id: 'a', label: 'A' }] }), to: () => 'regenerated' } });`, + `const source = '# retained comment\\nopaque source\\n';`, + `const document = converter.from(source);`, + `assert.equal(converter.to(JSON.parse(JSON.stringify(document))), source);`, + `document.graph.nodes[0].label = 'Edited';`, + `assert.equal($MOD.getGraphDocumentUpdate(document, converter).ok, false);`, + `assert.throws(() => converter.to(document), /unsupported-edit/);`, + ], + types: [ + `const converter = $MOD.createGraphDocumentConverter({ format: 'visual-smoke', codec: { from: (_source: string) => m0.createVisualGraph({ nodes: [{ id: 'a', width: 3 }] }), to: (_graph: m0.VisualGraph) => 'visual' } });`, + `const document: $MOD.GraphDocument = converter.from('visual');`, + `const width: number = document.graph!.nodes[0].width;`, + `const result = $MOD.getGraphDocumentUpdate(document, converter);`, + `if (result.ok) result.source.toUpperCase();`, + `width.toFixed();`, + ], + }, + './adjacency-matrix': { + phase: 'core', + runtime: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 1 }] });`, + `assert.equal($MOD.fromAdjacencyMatrix($MOD.toAdjacencyMatrix(graph)).edges.length, 1);`, + ], + types: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 1 }] });`, + `const native = $MOD.toAdjacencyMatrix(graph);`, + `native.matrix[0]?.[0];`, + ], + }, + './matrix-market': { + phase: 'core', + runtime: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 1 }] });`, + `assert.equal($MOD.fromMatrixMarket($MOD.toMatrixMarket(graph)).edges.length, 1);`, + ], + types: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 1 }] });`, + `const native = $MOD.toMatrixMarket(graph);`, + `native.toUpperCase();`, + ], + }, + './graphviz-json': { + phase: 'core', + runtime: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 1 }] });`, + `assert.equal($MOD.fromGraphvizJSON($MOD.toGraphvizJSON(graph, { regenerate: true })).edges.length, 1);`, + ], + types: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 1 }] });`, + `const native = $MOD.toGraphvizJSON(graph, { regenerate: true });`, + `native.objects?.[0]?.name;`, + ], + }, + './drawio': { + phase: 'optional', + runtime: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 1 }] });`, + `assert.equal($MOD.fromDrawio($MOD.toDrawio(graph)).edges.length, 1);`, + ], + types: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 1 }] });`, + `const native = $MOD.toDrawio(graph);`, + `native.toUpperCase();`, + ], + }, + './pajek': { + phase: 'core', + runtime: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 1 }] });`, + `assert.equal($MOD.fromPajek($MOD.toPajek(graph)).edges.length, 1);`, + ], + types: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 1 }] });`, + `const native = $MOD.toPajek(graph);`, + `native.toUpperCase();`, + ], + }, + './plantuml': { + phase: 'core', + runtime: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 1 }] });`, + `assert.equal($MOD.fromPlantUML($MOD.toPlantUML(graph)).edges.length, 1);`, + ], + types: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 1 }] });`, + `const native = $MOD.toPlantUML(graph);`, + `native.toUpperCase();`, + ], + }, + './csv': { + phase: 'core', + runtime: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a', label: 'A' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }] });`, + `assert.equal($MOD.fromCSV($MOD.toCSV(graph)).edges.length, 1);`, + `assert.equal($MOD.fromTSV($MOD.toTSV(graph)).nodes.length, 2);`, + ], + types: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }] });`, + `const native = $MOD.toCSV(graph);`, + `native.nodes.toUpperCase();`, + ], + }, + './graphlib': { + phase: 'core', + runtime: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a', label: 'A' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }] });`, + `assert.equal($MOD.fromGraphlibJSON($MOD.toGraphlibJSON(graph)).edges.length, 1);`, + ], + types: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }] });`, + `const native = $MOD.toGraphlibJSON(graph);`, + `native.nodes[0]?.v;`, + ], + }, + './graphology': { + phase: 'core', + runtime: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a', label: 'A' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }] });`, + `assert.equal($MOD.fromGraphologyJSON($MOD.toGraphologyJSON(graph)).edges.length, 1);`, + ], + types: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }] });`, + `const native = $MOD.toGraphologyJSON(graph);`, + `native.nodes[0]?.key;`, + ], + }, + './networkx': { + phase: 'core', + runtime: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a', label: 'A' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }] });`, + `assert.equal($MOD.fromNetworkXNodeLink($MOD.toNetworkXNodeLink(graph)).edges.length, 1);`, + ], + types: [ + `const graph = m0.createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }] });`, + `const native = $MOD.toNetworkXNodeLink(graph);`, + `native.nodes[0]?.id;`, + ], + }, './cytoscape': { phase: 'core', runtime: [ @@ -503,6 +644,45 @@ async function main(): Promise { ], }, }; + const documentExports: Array<[string, string, string]> = [ + ['./adjacency-list', 'adjacencyListDocumentConverter', '({a:["b"]})'], + ['./adjacency-matrix', 'adjacencyMatrixDocumentConverter', '({matrix:[[0,1],[0,0]],nodeIds:["a","b"],mode:"directed"})'], + ['./matrix-market', 'matrixMarketDocumentConverter', '"%%MatrixMarket matrix coordinate real general\\n2 2 1\\n1 2 1\\n"'], + ['./graphviz-json', 'graphvizJSONDocumentConverter', '({name:"g",directed:true,strict:false,_subgraph_cnt:0,objects:[{_gvid:0,name:"a"}],edges:[]})'], + ['./drawio', 'drawioDocumentConverter', '\'\''], + ['./pajek', 'pajekDocumentConverter', '\'*Vertices 1\\n1 "A"\\n*Arcs\\n\''], + ['./plantuml', 'plantumlDocumentConverter', '\'@startuml\\ncomponent "A" as a\\n@enduml\''], + ['./csv', 'csvDocumentConverter', '({nodes:"id\\r\\na\\r\\n",edges:"id,sourceId,targetId\\r\\n"})'], + ['./csv', 'tsvDocumentConverter', '({nodes:"id\\na\\n",edges:"id\\tsourceId\\ttargetId\\n"})'], + ['./graphlib', 'graphlibDocumentConverter', '({options:{directed:true,multigraph:false,compound:false},nodes:[{v:"a"}],edges:[]})'], + ['./graphology', 'graphologyDocumentConverter', '({options:{type:"directed",multi:false,allowSelfLoops:true},attributes:{},nodes:[{key:"a"}],edges:[]})'], + ['./networkx', 'networkxDocumentConverter', '({directed:true,multigraph:false,graph:{},nodes:[{id:"a"}],edges:[]})'], + ['./cytoscape', 'cytoscapeDocumentConverter', '({elements:{nodes:[{data:{id:"a"}}],edges:[]}})'], + ['./d2', 'd2DocumentConverter', '"# comment\\na: Before\\n"'], + ['./d3', 'd3DocumentConverter', '({nodes:[{id:"a"}],links:[]})'], + ['./dot', 'dotDocumentConverter', '"// comment\\ndigraph { a [label=\\\"Before\\\"]; }"'], + ['./edge-list', 'edgeListDocumentConverter', '([["a","b"]])'], + ['./elk', 'elkDocumentConverter', '({id:"root",children:[{id:"a"}],edges:[]})'], + ['./gexf', 'gexfDocumentConverter', '\'\''], + ['./gml', 'gmlDocumentConverter', '\'graph [ node [ id 0 label "Before" ] ]\''], + ['./graphml', 'graphMLDocumentConverter', '\'\''], + ['./jgf', 'jgfDocumentConverter', '({graph:{id:"g",nodes:[{id:"a"}],edges:[]}})'], + ['./tgf', 'tgfDocumentConverter', '"a Before\\r\\n#\\r\\n"'], + ['./xyflow', 'xyflowDocumentConverter', '({nodes:[{id:"a",position:{x:0,y:0},data:{label:"Before"}}],edges:[]})'], + ...[ + ['Flowchart', 'flowchart TD\\nA[Before]'], ['Sequence', 'sequenceDiagram\\nA->>B: Before'], + ['State', 'stateDiagram-v2\\nA --> B'], ['Class', 'classDiagram\\nclass A'], + ['ER', 'erDiagram\\nA'], ['Block', 'block-beta\\nA[Before]'], + ['Architecture', 'architecture-beta\\nservice a(server)[Before]'], + ['Requirement', 'requirementDiagram\\nrequirement a {\\ntext: Before\\n}'], ['Sankey', 'sankey-beta\\nA,B,1'], + ['GitGraph', 'gitGraph\\ncommit id: "a"'], ['C4', 'C4Context\\nPerson(a, "A")'], ['UseCase', 'usecase-beta\\nactor A("A")'], + ['Mindmap', 'mindmap\\n root((Before))'], ['Ishikawa', 'ishikawa-beta\\n Before'], + ].map(([name, source]) => ['./mermaid', `mermaid${name}DocumentConverter`, JSON.stringify(source).replace(/\\\\n/g, '\\n')] as [string, string, string]), + ]; + for (const [key, symbol, source] of documentExports) { + exportChecks[key].runtime.push(`{ const nativeSource = ${source}; const nativeDocument = $MOD.${symbol}.from(nativeSource); assert.notEqual(nativeDocument.graph, null, ${JSON.stringify(`${key}:${symbol}`)}); assert.deepEqual($MOD.${symbol}.to(JSON.parse(JSON.stringify(nativeDocument))), nativeSource); }`); + exportChecks[key].types.push(`{ const nativeDocument = $MOD.${symbol}.from(${source}); const nativeSource = $MOD.${symbol}.to(nativeDocument); void nativeSource; }`); + } const missingChecks = publishedExportKeys.filter((key) => !(key in exportChecks)); const staleChecks = Object.keys(exportChecks).filter( (key) => !publishedExportKeys.includes(key), @@ -617,6 +797,7 @@ async function main(): Promise { '--ignore-workspace', 'dotparser', 'fast-xml-parser', + 'fflate', 'zod', // Layout adapter optional peers 'elkjs', diff --git a/src/format-support.ts b/src/format-support.ts index 88f1919..0a8ddea 100644 --- a/src/format-support.ts +++ b/src/format-support.ts @@ -3,6 +3,7 @@ export { getFormatSupportEntry, } from './formats/support'; export type { + FormatPreservationCapabilities, FormatSupportEntry, FormatSupportFeatures, FormatSupportLevel, diff --git a/src/formats/adjacency-list/README.md b/src/formats/adjacency-list/README.md index 283929a..0e70987 100644 --- a/src/formats/adjacency-list/README.md +++ b/src/formats/adjacency-list/README.md @@ -2,6 +2,16 @@ Converter for the adjacency list format — a plain `Record` mapping each node ID to its neighbors. +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **partial**. +Source document preservation: **none**. These are separate guarantees; +selected-field round-trip tests do not prove unrestricted losslessness. + +The minimal adjacency representation omits isolated edge IDs, labels, arbitrary metadata and geometry. Export respects each edge mode. Import collapses repeated neighbors by default; `{ parallel: 'preserve' }` retains directed duplicates and reciprocal undirected multiplicity. Dictionary keys such as `__proto__` are handled safely without changing the plain-object return shape. + ## Resources - [Adjacency list (Wikipedia)](https://en.wikipedia.org/wiki/Adjacency_list) @@ -11,7 +21,7 @@ Converter for the adjacency list format — a plain `Record` m ```ts -import { toAdjacencyList, fromAdjacencyList, adjacencyListConverter } from '@statelyai/graph/adjacency-list'; +import { toAdjacencyList, fromAdjacencyList } from '@statelyai/graph/adjacency-list'; import { createGraph } from '@statelyai/graph'; // Export @@ -60,6 +70,21 @@ Returns a record mapping each node ID to its outgoing neighbor IDs. Pre-built `GraphFormatConverter>`: ```ts +import { adjacencyListConverter } from '@statelyai/graph/converter'; + const adj = adjacencyListConverter.to(graph); const graph = adjacencyListConverter.from(adj); ``` + + +## Native documents + + + +`adjacencyListDocumentConverter.from(source)` creates a plain JSON document with the +original source and a separate Graph projection. `.to(document)` preserves exact +source when unchanged, including after JSON serialization. Supported edits patch +verified native fields; unsupported edits throw with diagnostics. `.getUpdate(document)` +returns a diagnostic-bearing result. Native semantic projection remains partial. + +Original neighbor order and duplicate entries stay intact. Graph edits reject because the minimal format cannot encode Graph labels, data, geometry or stable edge IDs. diff --git a/src/formats/adjacency-list/index.ts b/src/formats/adjacency-list/index.ts index d4621f6..f7d70da 100644 --- a/src/formats/adjacency-list/index.ts +++ b/src/formats/adjacency-list/index.ts @@ -1,4 +1,7 @@ +import { createGraphDocumentConverter } from '../document'; +import { getStructuredDocumentProjectionGraph, getStructuredDocumentPatch } from '../preservation'; import type { Graph } from '../../types'; +import { getEdgeMode } from '../../mode'; /** * Converts a graph to an adjacency list representation. @@ -17,7 +20,7 @@ import type { Graph } from '../../types'; * ``` */ export function toAdjacencyList(graph: Graph): Record { - const adj: Record = {}; + const adj: Record = Object.create(null); for (const node of graph.nodes) { adj[node.id] = []; @@ -25,12 +28,13 @@ export function toAdjacencyList(graph: Graph): Record { for (const edge of graph.edges) { adj[edge.sourceId]?.push(edge.targetId); - if (graph.mode !== 'directed') { + if (getEdgeMode(graph, edge) !== 'directed' && edge.sourceId !== edge.targetId) { adj[edge.targetId]?.push(edge.sourceId); } } - return adj; + // Preserve the public plain-object shape; spread defines __proto__ safely. + return { ...adj }; } /** @@ -52,16 +56,22 @@ export function toAdjacencyList(graph: Graph): Record { */ export function fromAdjacencyList( adj: Record, - options?: { directed?: boolean; id?: string }, + options?: { directed?: boolean; id?: string; parallel?: 'collapse' | 'preserve' }, ): Graph { + if (!adj || typeof adj !== 'object' || Array.isArray(adj)) throw new Error('Adjacency list: expected an object'); const directed = options?.directed ?? true; + const preserveParallel = options?.parallel === 'preserve'; const seen = new Set(); + const multiplicities = new Map(); // Include targets that never appear as keys so edges always reference // existing nodes. const nodeIds = new Set(Object.keys(adj)); + if ([...nodeIds].some((id) => !id)) throw new Error('Adjacency list: node ids must be non-empty strings'); for (const targets of Object.values(adj)) { + if (!Array.isArray(targets)) throw new Error('Adjacency list: neighbor lists must be arrays'); for (const targetId of targets) { + if (typeof targetId !== 'string' || !targetId) throw new Error('Adjacency list: node ids must be non-empty strings'); nodeIds.add(targetId); } } @@ -80,11 +90,23 @@ export function fromAdjacencyList( for (const [sourceId, targets] of Object.entries(adj)) { for (const targetId of targets) { - const key = directed - ? `${sourceId}->${targetId}` - : [sourceId, targetId].sort().join('<->'); + const pair = directed ? [sourceId, targetId] : [sourceId, targetId].sort(); + const key = JSON.stringify(pair); + let include = !seen.has(key); + if (preserveParallel) { + if (directed) include = true; + else { + // Reciprocal neighbor lists describe the same undirected edges. + // Keep their maximum multiplicity rather than doubling it. + const counts = multiplicities.get(key) ?? [0, 0, 0]; + const side = sourceId === pair[0] ? 0 : 1; + include = ++counts[side] > counts[2]; + if (include) counts[2]++; + multiplicities.set(key, counts); + } + } - if (!seen.has(key)) { + if (include) { seen.add(key); edges.push({ type: 'edge', @@ -107,3 +129,12 @@ export function fromAdjacencyList( data: undefined as any, }; } + + +/** Exact native source preservation, with strict verified field edits. */ +export const adjacencyListDocumentConverter = createGraphDocumentConverter({ + format: 'adjacency-list', + codec: { from: (source: Record) => fromAdjacencyList(source), to: toAdjacencyList }, + project: (source: Record) => ({ graph: getStructuredDocumentProjectionGraph(fromAdjacencyList(source), source, 'adjacency-list') }), + patch: (context) => getStructuredDocumentPatch(context, 'adjacency-list'), +}); diff --git a/src/formats/adjacency-matrix/README.md b/src/formats/adjacency-matrix/README.md new file mode 100644 index 0000000..bdf2e4a --- /dev/null +++ b/src/formats/adjacency-matrix/README.md @@ -0,0 +1,25 @@ +# Adjacency matrix + +Dependency-free square matrix JSON with explicit ordered `nodeIds` and `mode`. Rows are sources; columns are targets. Coefficients are finite numbers. Ordinary zero means absence; `presence[row][column] = true` represents a zero-weight edge. + + +```ts +import { toAdjacencyMatrix, fromAdjacencyMatrix, adjacencyMatrixDocumentConverter } from '@statelyai/graph/adjacency-matrix'; +const source = toAdjacencyMatrix(graph); +const restored = fromAdjacencyMatrix(source); +const document = adjacencyMatrixDocumentConverter.from(source); +document.graph!.edges[0].weight = 0; +const updated = adjacencyMatrixDocumentConverter.to(document); +``` + +`adjacencyMatrixConverter` exposes `.to(graph)` and `.from(source)`. Export emits a presence mask when needed. Undirected input requires symmetric coefficients and symmetric presence; bidirectional and mixed directed/undirected edges reject. Empty string and prototype-looking IDs remain valid unique IDs. Dense conversion costs O(N²); `maxCells` defaults to 1,000,000 and rejects before allocation. + +Parallel edges reject by default. Explicit `{ aggregation: 'sum' | 'min' | 'max' | 'count' }` emits aggregated coefficients. The sidecar retains original edge identities and weights, but independent native readers see the aggregated matrix. Changed multi-edge aggregates reject because individual weights are ambiguous. Count coefficients encode cardinality: changing, adding or removing any count cell rejects, even when it originally represented one edge. + + +Versioned `__statelyai` sidecars retain JSON-domain Graph data, labels, ports, identity, ordering and optional-field presence. An occupied foreign name receives a separate marker name. Live matrix coefficients, node order and presence take precedence over recorded baselines. Metadata is an explicit convention, not collision-free provenance. Import `{ metadata: 'native' }` treats markers as ordinary foreign fields; export `{ nativeCarrier: false }` ignores captured import provenance. + +Documents retain complete source JSON, unknown properties and array order through no-op and JSON persistence. Strict weight edits update actual coefficients/presence and preserve unknown fields; source changes are merged and conflicts diagnosed. Structural/data/style edits and aggregated coefficient patches reject. `.getUpdate(document)` returns diagnostics; `.to(document)` throws on unsupported edits. Full Graph preservation relies on the sidecar; ordinary matrices carry only topology, ordered IDs, direction and numeric weights. + + +Core tests cover both directions, zero/negative weights, isolates, arbitrary JSON data, native edits, ambiguity, malformed masks and allocation limits. Independent SciPy 1.18.1 / NumPy 2.5.4 checks validate the related Matrix Market serialization. This JSON profile is a library-defined interchange shape, rather than a universal matrix schema. diff --git a/src/formats/adjacency-matrix/core.ts b/src/formats/adjacency-matrix/core.ts new file mode 100644 index 0000000..937ffc0 --- /dev/null +++ b/src/formats/adjacency-matrix/core.ts @@ -0,0 +1,106 @@ +import type { Graph } from '../../types'; +import { getClonedJSON, isJSONEqual } from '../preservation'; +import { getUniformMode, getValidatedGraph, isNativeMetadataRecord } from '../structured-json'; + +export type MatrixAggregation = 'reject' | 'sum' | 'min' | 'max' | 'count'; +export interface MatrixCell { sourceId: string; targetId: string; value: number } +export interface MatrixSidecar { + kind: '@statelyai/graph/matrix'; version: 1; format: string; + nodeIds: string[]; mode: 'directed' | 'undirected'; graph: Graph; + baseline: { nodeIds: string[]; mode: 'directed' | 'undirected'; cells: MatrixCell[]; aggregation: MatrixAggregation }; + zeroPresence?: Array<[string, string]>; +} +export interface MatrixView { nodeIds: string[]; mode: 'directed' | 'undirected'; cells: MatrixCell[] } + +export function getMatrixKey(sourceId: string, targetId: string, mode: MatrixView['mode']): string { + return JSON.stringify(mode === 'undirected' ? [sourceId, targetId].sort() : [sourceId, targetId]); +} +export function getMatrixLimit(size: number, maxCells = 1_000_000): void { + if (!Number.isSafeInteger(maxCells) || maxCells < 0 || !Number.isSafeInteger(size) || size < 0 || size > Math.sqrt(maxCells)) throw new Error('Matrix: maxCells exceeded or invalid limit'); +} +export function getValidatedNodeIds(nodeIds: string[]): void { + if (!Array.isArray(nodeIds) || nodeIds.some((id) => typeof id !== 'string') || new Set(nodeIds).size !== nodeIds.length) throw new Error('Matrix: nodeIds must be unique strings'); +} +export function getMatrixAggregation(values: number[], aggregation: MatrixAggregation): number { + if (values.length > 1 && aggregation === 'reject') throw new Error('Matrix: parallel edges/duplicate entries require explicit aggregation'); + const value = aggregation === 'count' ? values.length : aggregation === 'min' ? values.reduce((minimum, weight) => Math.min(minimum, weight), Infinity) : aggregation === 'max' ? values.reduce((maximum, weight) => Math.max(maximum, weight), -Infinity) : values.reduce((sum, weight) => sum + weight, 0); + if (!Number.isFinite(value)) throw new Error('Matrix: aggregated coefficients must remain finite'); + return value; +} +export function getGraphMatrixView(graph: Graph, aggregation: MatrixAggregation = 'reject'): MatrixView { + if (!['reject', 'sum', 'min', 'max', 'count'].includes(aggregation)) throw new Error('Matrix: unsupported aggregation'); + getValidatedGraph(graph, 'Matrix'); + const mode = getUniformMode(graph, 'Matrix'); + if (!['directed', 'undirected'].includes(mode)) throw new Error('Matrix: invalid graph mode'); + const groups = new Map(); + for (const edge of graph.edges) { + if (edge.weight !== undefined && (typeof edge.weight !== 'number' || !Number.isFinite(edge.weight))) throw new Error('Matrix: weights must be finite numbers'); + const key = getMatrixKey(edge.sourceId, edge.targetId, mode); + const group = groups.get(key) ?? { sourceId: edge.sourceId, targetId: edge.targetId, weights: [] }; + group.weights.push(edge.weight ?? 1); groups.set(key, group); + } + return { nodeIds: graph.nodes.map((node) => node.id), mode, cells: [...groups.values()].map((group) => ({ sourceId: group.sourceId, targetId: group.targetId, value: getMatrixAggregation(group.weights, aggregation) })) }; +} +export function createMatrixSidecar(graph: Graph, format: string, aggregation: MatrixAggregation = 'reject'): MatrixSidecar { + const view = getGraphMatrixView(graph, aggregation); + return { kind: '@statelyai/graph/matrix', version: 1, format, nodeIds: view.nodeIds, mode: view.mode, graph: getClonedJSON(graph), baseline: { ...getClonedJSON(view), aggregation } }; +} +export function getMatrixSidecar(value: any, format: string): MatrixSidecar | undefined { + if (value?.kind !== '@statelyai/graph/matrix' || value.version !== 1 || value.format !== format || !Array.isArray(value.graph?.nodes) || !Array.isArray(value.graph?.edges) || !Array.isArray(value.baseline?.cells) || !Array.isArray(value.nodeIds) || !['directed', 'undirected'].includes(value.mode) || !isNativeMetadataRecord(value.graph, 'graph') || !value.graph.nodes.every((node: any) => isNativeMetadataRecord(node, 'node')) || !value.graph.edges.every((edge: any) => isNativeMetadataRecord(edge, 'edge'))) return undefined; + try { getValidatedGraph(value.graph, 'Matrix sidecar'); getValidatedNodeIds(value.nodeIds); getValidatedNodeIds(value.baseline.nodeIds); } + catch { return undefined; } + if (!['directed', 'undirected'].includes(value.baseline.mode) || !['reject', 'sum', 'min', 'max', 'count'].includes(value.baseline.aggregation) || value.baseline.cells.some((cell: any) => !cell || typeof cell.sourceId !== 'string' || typeof cell.targetId !== 'string' || typeof cell.value !== 'number' || !Number.isFinite(cell.value))) return undefined; + const nodeIds = new Set(value.nodeIds); + if (value.zeroPresence !== undefined && (!Array.isArray(value.zeroPresence) || value.zeroPresence.some((pair: any) => !Array.isArray(pair) || pair.length !== 2 || pair.some((id: any) => typeof id !== 'string' || !nodeIds.has(id))))) return undefined; + return value; +} +export function getMatrixSourceSidecar(source: Record, format: string): { key: string; sidecar: MatrixSidecar } | undefined { + const keys = Object.keys(source).filter((key) => key === '__statelyai' || (/^__statelyai__\d+$/.test(key) && Number.isSafeInteger(Number(key.slice('__statelyai__'.length))))); + keys.sort((a, b) => Number(b.slice('__statelyai__'.length) || 0) - Number(a.slice('__statelyai__'.length) || 0)); + for (const key of keys) { const sidecar = getMatrixSidecar(source[key], format); if (sidecar) return { key, sidecar }; } + return undefined; +} +/** Live coefficients and row IDs determine topology; snapshots supply untouched fields only. */ +export function getMatrixGraph(view: MatrixView, sidecar?: MatrixSidecar, data: any = null): Graph { + getValidatedNodeIds(view.nodeIds); + const ids = new Set(view.nodeIds); + const cells = new Map(view.cells.map((cell) => [getMatrixKey(cell.sourceId, cell.targetId, view.mode), cell])); + if (cells.size !== view.cells.length || view.cells.some((cell) => !ids.has(cell.sourceId) || !ids.has(cell.targetId) || !Number.isFinite(cell.value))) throw new Error('Matrix: invalid or duplicate cells'); + const original = sidecar?.graph; + const rootMode = sidecar && view.mode !== sidecar.baseline.mode ? view.mode : original?.mode ?? view.mode; + const oldNodes = new Map(original?.nodes.map((node) => [node.id, node])); + const groups = new Map(); + for (const edge of original?.edges ?? []) { + const key = getMatrixKey(edge.sourceId, edge.targetId, view.mode); + const group = groups.get(key) ?? []; group.push(edge); groups.set(key, group); + } + const baseline = new Map(sidecar?.baseline.cells.map((cell) => [getMatrixKey(cell.sourceId, cell.targetId, view.mode), cell.value])); + if (sidecar?.baseline.aggregation === 'count' && (view.mode !== sidecar.baseline.mode || cells.size !== baseline.size || [...cells].some(([key, cell]) => cell.value !== baseline.get(key)))) throw new Error('Matrix: edited count aggregate is ambiguous; cardinality does not determine individual edge weights/IDs'); + for (const [key, group] of groups) if (group.length > 1 && (!cells.has(key) || cells.get(key)!.value !== baseline.get(key) || view.mode !== sidecar!.baseline.mode)) throw new Error('Matrix: edited/removed aggregate is ambiguous; individual edge weights cannot be inferred'); + const edges: Graph['edges'] = []; + const used = new Set(); + for (const edge of original?.edges ?? []) { + const key = getMatrixKey(edge.sourceId, edge.targetId, view.mode); const cell = cells.get(key); + if (!cell || !ids.has(edge.sourceId) || !ids.has(edge.targetId)) continue; + if (groups.get(key)!.length > 1 && (cell.value !== baseline.get(key) || view.mode !== sidecar!.baseline.mode)) throw new Error('Matrix: edited aggregate is ambiguous; individual edge weights cannot be inferred'); + const restored = getClonedJSON(edge); + if (cell.value !== baseline.get(key)) restored.weight = cell.value; + if (view.mode !== sidecar!.baseline.mode) delete restored.mode; + edges.push(restored); used.add(key); + } + const edgeIds = new Set(edges.map((edge) => edge.id)); + for (const [key, cell] of cells) if (!used.has(key)) { + let id = `matrix:${key}`, suffix = 0; + while (edgeIds.has(id)) id = `matrix:${key}__${++suffix}`; + edgeIds.add(id); edges.push({ type: 'edge', id, sourceId: cell.sourceId, targetId: cell.targetId, label: null, data: null, weight: cell.value, ...(rootMode !== view.mode && { mode: view.mode }) }); + } + const { nodes: _nodes, edges: _edges, ...root } = original ?? { id: '', mode: view.mode, initialNodeId: null, data }; + const graph: Graph = { ...getClonedJSON(root), ...(sidecar && view.mode !== sidecar.baseline.mode && { mode: view.mode }), nodes: view.nodeIds.map((id) => getClonedJSON(oldNodes.get(id) ?? { type: 'node', id, label: null, data: null })), edges }; + getValidatedGraph(graph, 'Matrix'); return graph; +} + +export function getMatrixDocumentChanges(context: any): string | undefined { + for (const change of context.changes) if (change.entity !== 'edge' || change.field !== 'weight') return `Unsupported matrix edit ${change.path}; only existing edge weights have native coefficients`; + if (context.sourceGraph.edges.length !== context.graph.edges.length || !isJSONEqual(context.sourceGraph.nodes.map((node: any) => node.id), context.graph.nodes.map((node: any) => node.id))) return 'Matrix structural edits require explicit regeneration'; + return undefined; +} diff --git a/src/formats/adjacency-matrix/index.ts b/src/formats/adjacency-matrix/index.ts new file mode 100644 index 0000000..7da4180 --- /dev/null +++ b/src/formats/adjacency-matrix/index.ts @@ -0,0 +1,84 @@ +import type { Graph } from '../../types'; +import { createFormatConverter } from '../converter'; +import { createGraphDocumentConverter } from '../document'; +import { createNativeCarrier, getCarrierData, getNativeCarrier, getClonedJSON, getNativeDocumentGraph, isJSONEqual, type FormatExportOptions, type FormatImportOptions } from '../preservation'; +import { createMatrixSidecar, getMatrixSourceSidecar, getMatrixGraph, getGraphMatrixView, getMatrixLimit, getValidatedNodeIds, getMatrixDocumentChanges, type MatrixAggregation, type MatrixSidecar, type MatrixView } from './core'; + +export type { MatrixAggregation } from './core'; +export interface AdjacencyMatrixJSON { + nodeIds: string[]; + matrix: number[][]; + mode: 'directed' | 'undirected'; + presence?: boolean[][]; + __statelyai?: MatrixSidecar | any; + [field: string]: any; +} +export interface AdjacencyMatrixExportOptions extends FormatExportOptions { aggregation?: MatrixAggregation; maxCells?: number } +export interface AdjacencyMatrixImportOptions extends FormatImportOptions { maxCells?: number } + +function getMatrixView(source: AdjacencyMatrixJSON, maxCells?: number): MatrixView { + if (!source || !Array.isArray(source.matrix) || !['directed', 'undirected'].includes(source.mode)) throw new Error('Adjacency matrix: expected square matrix and mode'); + getValidatedNodeIds(source.nodeIds); const size = source.nodeIds.length; getMatrixLimit(size, maxCells); + if (source.matrix.length !== size || source.matrix.some((row) => !Array.isArray(row) || row.length !== size || row.some((value) => typeof value !== 'number' || !Number.isFinite(value)))) throw new Error('Adjacency matrix: dimensions/finite coefficients disagree with nodeIds'); + if (source.presence !== undefined && (!Array.isArray(source.presence) || source.presence.length !== size || source.presence.some((row) => !Array.isArray(row) || row.length !== size || row.some((value) => typeof value !== 'boolean')))) throw new Error('Adjacency matrix: invalid presence mask'); + const cells: MatrixView['cells'] = []; + for (let row = 0; row < size; row++) for (let col = 0; col < size; col++) { + const present = source.presence?.[row][col] ?? source.matrix[row][col] !== 0; + if (!present && source.matrix[row][col] !== 0) throw new Error('Adjacency matrix: presence mask hides a nonzero coefficient'); + if (source.mode === 'undirected' && (source.matrix[row][col] !== source.matrix[col][row] || present !== (source.presence?.[col][row] ?? source.matrix[col][row] !== 0))) throw new Error('Adjacency matrix: undirected coefficients/presence must be symmetric'); + if (present && (source.mode === 'directed' || row <= col)) cells.push({ sourceId: source.nodeIds[row], targetId: source.nodeIds[col], value: source.matrix[row][col] }); + } + return { nodeIds: source.nodeIds, mode: source.mode, cells }; +} + +/** Square finite coefficients; zero means absence unless an explicit presence mask marks it. */ +export function fromAdjacencyMatrix(source: AdjacencyMatrixJSON, options: AdjacencyMatrixImportOptions = {}): Graph { + const metadata = options.metadata === 'native' ? undefined : getMatrixSourceSidecar(source, 'adjacency-matrix'); + const extras = Object.fromEntries(Object.entries(source).filter(([key]) => !['nodeIds', 'matrix', 'mode', 'presence', metadata?.key].includes(key))); + const graph = getMatrixGraph(getMatrixView(source, options.maxCells), metadata?.sidecar, Object.keys(extras).length ? getClonedJSON(extras) : null); + graph.data = createNativeCarrier(graph.data, 'adjacency-matrix', source, graph); return graph; +} + +/** Dense allocation is bounded by maxCells (default 1,000,000); parallel aggregation is opt-in. */ +export function toAdjacencyMatrix(graph: Graph, options: AdjacencyMatrixExportOptions = {}): AdjacencyMatrixJSON { + getMatrixLimit(graph.nodes.length, options.maxCells); + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, 'adjacency-matrix'); + const current = carrier ? { ...graph, data: getCarrierData(graph.data, 'adjacency-matrix') } : graph; + const candidate = carrier ? getMatrixSourceSidecar(carrier.native, 'adjacency-matrix') : undefined; + const prior = candidate && !isJSONEqual(carrier!.baseline.data?.[candidate.key], carrier!.native[candidate.key]) ? candidate : undefined; + const aggregation = options.aggregation ?? prior?.sidecar.baseline.aggregation ?? 'reject'; + const view = getGraphMatrixView(current, aggregation), size = view.nodeIds.length; + const source: AdjacencyMatrixJSON = carrier ? getClonedJSON(carrier.native) : {} as AdjacencyMatrixJSON; + source.nodeIds = view.nodeIds; source.mode = view.mode; source.matrix = Array.from({ length: size }, () => Array(size).fill(0)); + const presence = Array.from({ length: size }, () => Array(size).fill(false)); const indices = new Map(view.nodeIds.map((id, index) => [id, index])); + for (const cell of view.cells) { + const row = indices.get(cell.sourceId)!, col = indices.get(cell.targetId)!; + source.matrix[row][col] = cell.value; presence[row][col] = true; + if (view.mode === 'undirected') { source.matrix[col][row] = cell.value; presence[col][row] = true; } + } + if (view.cells.some((cell) => cell.value === 0) || (carrier && Object.hasOwn(carrier.native, 'presence'))) source.presence = presence; else delete source.presence; + let key = prior?.key ?? '__statelyai'; + if (!prior && Object.hasOwn(source, key)) { let suffix = 1; while (Object.hasOwn(source, `__statelyai__${suffix}`)) suffix++; key = `__statelyai__${suffix}`; } + source[key] = createMatrixSidecar(current, 'adjacency-matrix', aggregation); return source; +} + +export const adjacencyMatrixConverter = createFormatConverter(toAdjacencyMatrix, fromAdjacencyMatrix); +export const adjacencyMatrixDocumentConverter = createGraphDocumentConverter({ + format: 'adjacency-matrix', codec: { from: fromAdjacencyMatrix, to: (graph) => toAdjacencyMatrix(graph, { nativeCarrier: false }) }, + project: (source: AdjacencyMatrixJSON) => ({ graph: getNativeDocumentGraph(fromAdjacencyMatrix(source), 'adjacency-matrix', source) }), + patch: (context) => { + const error = getMatrixDocumentChanges(context); + if (error) return { ok: false, diagnostics: [{ code: 'unsupported-native-edit', severity: 'error', message: error }] }; + const sidecar = getMatrixSourceSidecar(context.source, 'adjacency-matrix')?.sidecar; + if (sidecar?.baseline.aggregation !== undefined && sidecar.baseline.aggregation !== 'reject') return { ok: false, diagnostics: [{ code: 'ambiguous-aggregate-edit', severity: 'error', message: 'Aggregated coefficients cannot be distributed among original edges' }] }; + const source = getClonedJSON(context.source); const indices = new Map(source.nodeIds.map((id, index) => [id, index])); + const edges = new Map(context.graph.edges.map((edge) => [edge.id, edge])); + for (const change of context.changes) { + const edge = edges.get(change.id!)!; const row = indices.get(edge.sourceId)!, col = indices.get(edge.targetId)!; const weight = edge.weight ?? 1; + source.matrix[row][col] = weight; if (source.mode === 'undirected') source.matrix[col][row] = weight; + if (weight === 0 && !source.presence) { source.presence = context.source.matrix.map((row) => row.map((value) => value !== 0)); } + if (source.presence) { source.presence[row][col] = true; if (source.mode === 'undirected') source.presence[col][row] = true; } + } + return { ok: true, source }; + }, +}); diff --git a/src/formats/csv/README.md b/src/formats/csv/README.md new file mode 100644 index 0000000..227979c --- /dev/null +++ b/src/formats/csv/README.md @@ -0,0 +1,83 @@ +# CSV / TSV graph tables + +Separate node and edge tables preserve isolated nodes. This is an explicit Graph +schema, rather than a portable graph CSV standard. Conversion has no runtime dependencies. + + + +```ts +import { toCSV, fromCSV, toTSV, fromTSV, csvDocumentConverter } from '@statelyai/graph/csv'; +const source = toCSV(graph); // { nodes, edges, schema, graph } +const restored = fromCSV(source); +const tabSource = toTSV(graph); +const tabGraph = fromTSV(tabSource); +const document = csvDocumentConverter.from(source); +``` + +`csvConverter` and `tsvConverter` provide `.to(graph)` / `.from(source)`. +`csvDocumentConverter` and `tsvDocumentConverter` retain native table documents. +The serializable source has `nodes` and `edges` strings, optional `schema`, optional +Graph root properties in `graph`, and opaque source-only `metadata`. Metadata does +not become `Graph.data` implicitly. + +Each table requires a header. Default ID/endpoint columns contain plain strings; +optional columns use JSON cells, including visible labels. An empty JSON cell +means absent, `null` means null, and `""` means an empty string. CSV quoting applies +after JSON encoding: label `Hello` is a JSON string cell, displayed in CSV as +`"""Hello"""`. Arbitrary data supports JSON objects, arrays, scalars and null. +The default `__extra` JSON column carries otherwise unmapped Graph fields; `graph` +carries root fields. Undefined optional Graph fields are absent in the JSON view. + + + +For externally authored plain-text labels, declare a string column: + +```ts +const source: import('@statelyai/graph/csv').CSVGraphSource = { + nodes: 'id,title\r\na,Hello\r\nb,World\r\n', + edges: 'id,from,to\r\ne,a,b\r\n', + schema: { + nodes: [ + { name: 'id', field: 'id', type: 'string', required: true }, + { name: 'title', field: 'label', type: 'string' }, + ], + edges: [ + { name: 'id', field: 'id', type: 'string', required: true }, + { name: 'from', field: 'sourceId', type: 'string', required: true }, + { name: 'to', field: 'targetId', type: 'string', required: true }, + ], + }, +}; +``` + +Column types are `string`, `number`, `boolean`, `json` and `mode`; schema delimiter +is comma or tab. IDs/endpoints must have mapped columns. Names and mapped fields +must be unique. Resulting Graph fields are validated independently of cell types: +labels must be strings/null, visual numbers and weights finite, styles valid, +modes recognized, references present and port names unique. Duplicate IDs, +dangling endpoints, malformed quotes, duplicate headers and invalid typed cells +throw. Prototype-sensitive names remain own properties. + +Custom schemas encode their stated fields only. Plain string cells cannot retain +missing/null distinctions; use JSON cells for those distinctions. `{ nativeCarrier: +false }` omits root properties and `$extra` fields, including any unmapped Graph +information and inherited root direction. `{ metadata: 'native' }` disables import +of `$extra`; declared data cells retain their explicit types. These options are +partial profiles with explicit losses. + + + +Documents reproduce original tables, metadata, unknown columns, BOM, quoting and +line endings exactly on no-op, including JSON persistence. Supported edits replace +existing mapped cells or Graph root properties; untouched bytes remain intact. +Identity, endpoints, hierarchy and port structure edits reject. Missing mapped +cells and ambiguous/unrepresentable changes reject with diagnostics rather than +replaying stale tables. Independent source edits survive unless they conflict with +requested Graph edits. All patches must reparse to the requested Graph projection. + +Bare imports ignore unknown columns; retain the document envelope when those +columns matter. The parser supports doubled quotes, delimiters and embedded +CRLF/newlines within quoted cells. Default output uses CRLF; `lineEnding` can select +LF. [csv-parse](https://csv.js.org/parse/) 7.0.3 independently checks emitted cell +contents; it validates CSV syntax, while this adapter validates the graph schema. +Semantic and bare round-trip coverage remains partial. diff --git a/src/formats/csv/index.ts b/src/formats/csv/index.ts new file mode 100644 index 0000000..c5d5f8e --- /dev/null +++ b/src/formats/csv/index.ts @@ -0,0 +1,268 @@ +import type { Graph, GraphFormatConverter } from '../../types'; +import { createFormatConverter } from '../converter'; +import { createGraphDocumentConverter, type JSONValue, type GraphDocumentPatchContext } from '../document'; +import { getCSVTable, toCSVCell, type CSVTable } from './table'; + +export interface CSVColumnConfig { + name: string; + /** Graph field name; `$extra` carries otherwise unmapped JSON fields. */ + field: string; + type: 'string' | 'number' | 'boolean' | 'json' | 'mode'; + required?: boolean; +} +export interface CSVGraphSchema { + delimiter?: ',' | '\t'; + nodes?: CSVColumnConfig[]; + edges?: CSVColumnConfig[]; +} +export interface CSVGraphSource { + nodes: string; + edges: string; + schema?: CSVGraphSchema; + /** Explicit Graph root properties, excluding nodes/edges. */ + graph?: Record; + /** Opaque native source metadata, not implicitly assigned to Graph.data. */ + metadata?: JSONValue; +} +export interface CSVImportOptions { + delimiter?: ',' | '\t'; + schema?: CSVGraphSchema; + /** `native` disables the `$extra` adapter field; declared data cells remain typed. */ + metadata?: 'auto' | 'native'; +} +export interface CSVExportOptions { + delimiter?: ',' | '\t'; + schema?: CSVGraphSchema; + lineEnding?: '\n' | '\r\n'; + /** False omits unmapped Graph fields and root properties; this is explicitly lossy. */ + nativeCarrier?: boolean; +} + +const NODE_FIELDS = ['label', 'parentId', 'initialNodeId', 'data', 'ports', 'x', 'y', 'width', 'height', 'color', 'shape', 'style']; +const EDGE_FIELDS = ['mode', 'sourcePort', 'targetPort', 'label', 'data', 'weight', 'points', 'routing', 'x', 'y', 'width', 'height', 'color', 'style']; +const MODES = new Set(['directed', 'undirected', 'bidirectional']); +const TYPES = new Set(['string', 'number', 'boolean', 'json', 'mode']); + +/** Reject values outside the plain JSON domain before any serialization loss. */ +function getCSVJSON(value: any): string { + const ancestors = new Set(); + const stack: Array<{ value: any; exit?: boolean }> = [{ value }]; + while (stack.length) { + const item = stack.pop()!; + const current = item.value; + if (item.exit) { ancestors.delete(current); continue; } + if (current === null || ['string', 'boolean'].includes(typeof current) || typeof current === 'number' && Number.isFinite(current)) continue; + if (!current || typeof current !== 'object' || !Array.isArray(current) && ![Object.prototype, null].includes(Object.getPrototypeOf(current))) throw new Error('CSV: values must be plain finite JSON'); + if (ancestors.has(current)) throw new Error('CSV: cyclic JSON data'); + if (Object.getOwnPropertySymbols(current).length) throw new Error('CSV: symbol properties cannot be encoded'); + const descriptors = Object.getOwnPropertyDescriptors(current); + const keys = Object.keys(descriptors).filter((key) => !(Array.isArray(current) && key === 'length')); + if (Array.isArray(current) && keys.length !== current.length) throw new Error('CSV: sparse JSON arrays'); + ancestors.add(current); stack.push({ value: current, exit: true }); + for (const key of keys) { + const descriptor = descriptors[key]; + if (!descriptor.enumerable || !('value' in descriptor) || Array.isArray(current) && !/^(?:0|[1-9]\d*)$/.test(key)) throw new Error('CSV: accessors/non-JSON properties cannot be encoded'); + stack.push({ value: descriptor.value }); + } + } + return JSON.stringify(value); +} + +function getCSVColumns(kind: 'nodes' | 'edges', schema?: CSVGraphSchema): CSVColumnConfig[] { + const required = kind === 'nodes' ? ['id'] : ['id', 'sourceId', 'targetId']; + const columns = schema?.[kind] ?? [ + ...required.map((field): CSVColumnConfig => ({ name: field, field, type: 'string', required: true })), + ...(kind === 'nodes' ? NODE_FIELDS : EDGE_FIELDS).map((field): CSVColumnConfig => ({ name: field, field, type: 'json' })), + { name: '__extra', field: '$extra', type: 'json' as const }, + ]; + if (!Array.isArray(columns) || columns.some((column) => !column || typeof column.name !== 'string' || !column.name || typeof column.field !== 'string' || !column.field || !TYPES.has(column.type) || column.required !== undefined && typeof column.required !== 'boolean' || column.field === 'type' || column.field === 'nodes' || column.field === 'edges' || column.field === '$extra' && column.type !== 'json')) throw new Error('CSV: invalid column schema'); + if (new Set(columns.map((column) => column.name)).size !== columns.length || new Set(columns.map((column) => column.field)).size !== columns.length) throw new Error('CSV: duplicate schema column names/fields'); + if (required.some((field) => !columns.some((column) => column.field === field))) throw new Error(`CSV: schema requires ${required.join(', ')} fields`); + return columns.map((column) => ({ name: column.name, field: column.field, type: column.type, ...(column.required !== undefined && { required: column.required }) })); +} + +function getCSVDelimiter(source?: CSVGraphSource, options?: CSVImportOptions | CSVExportOptions): ',' | '\t' { + const delimiter = options?.delimiter ?? options?.schema?.delimiter ?? source?.schema?.delimiter ?? ','; + if (delimiter !== ',' && delimiter !== '\t') throw new Error('CSV: delimiter must be comma or tab'); + return delimiter; +} + +function getCSVValue(text: string, column: CSVColumnConfig): any { + if (text === '' && column.type !== 'string') { + if (column.required) throw new Error(`CSV: required cell ${column.name} is empty`); + return undefined; + } + switch (column.type) { + case 'string': return text; + case 'json': try { const value = JSON.parse(text); getCSVJSON(value); return value; } catch { throw new Error(`CSV: invalid JSON cell ${column.name}`); } + case 'number': { + if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(text) || !Number.isFinite(Number(text))) throw new Error(`CSV: invalid finite number cell ${column.name}`); + return Number(text); + } + case 'boolean': if (text === 'true') return true; if (text === 'false') return false; throw new Error(`CSV: invalid boolean cell ${column.name}`); + case 'mode': if (MODES.has(text)) return text; throw new Error(`CSV: unknown mode cell ${column.name}`); + } +} + +function toCSVValue(value: any, column: CSVColumnConfig): string { + if (value === undefined) { if (column.required) throw new Error(`CSV: missing required field ${column.field}`); return ''; } + if (column.type === 'json') return getCSVJSON(value); + if (column.type === 'string' && typeof value === 'string') return value; + if (column.type === 'number' && typeof value === 'number' && Number.isFinite(value)) return String(value); + if (column.type === 'boolean' && typeof value === 'boolean') return String(value); + if (column.type === 'mode' && MODES.has(value)) return value; + throw new Error(`CSV: ${column.field} cannot be encoded as ${column.type}`); +} + +function getCSVRecords(table: CSVTable, columns: CSVColumnConfig[], kind: 'nodes' | 'edges', native: boolean): any[] { + const indices = new Map(table.headers.map((header, index) => [header, index])); + const required = kind === 'nodes' ? ['id'] : ['id', 'sourceId', 'targetId']; + for (const column of columns) if ((column.required || required.includes(column.field)) && !indices.has(column.name)) throw new Error(`CSV: missing required header ${column.name}`); + return table.rows.map((cells) => { + const record: Record = { type: kind === 'nodes' ? 'node' : 'edge' }; + const extras: Array<[string, any]> = []; + for (const column of columns) { + const index = indices.get(column.name); + if (index === undefined || native && column.field === '$extra') continue; + const value = getCSVValue(cells[index].value, column); + if (column.field === '$extra') { + if (value === undefined) continue; + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('CSV: $extra must be a JSON object'); + extras.push(...Object.entries(value)); + } else if (value !== undefined) Object.defineProperty(record, column.field, { value, enumerable: true, writable: true, configurable: true }); + } + for (const [key, value] of extras) { + if (key === 'type' || columns.some((column) => column.field !== '$extra' && column.field === key)) throw new Error(`CSV: $extra collides with mapped field ${key}`); + Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true }); + } + return record; + }); +} + +function getCSVValidatedStyle(style: any): void { + if (style !== undefined && (!style || typeof style !== 'object' || Array.isArray(style) || Object.values(style).some((value) => !['string', 'number', 'boolean'].includes(typeof value) || typeof value === 'number' && !Number.isFinite(value)))) throw new Error('CSV: invalid style'); +} + +function getCSVValidatedGraph(graph: Graph): void { + const nodes = new Map(); + const edges = new Set(); + if (typeof graph.id !== 'string' || !MODES.has(graph.mode)) throw new Error('CSV: invalid graph id/mode'); + for (const node of graph.nodes) { + if (typeof node.id !== 'string' || !node.id || nodes.has(node.id)) throw new Error('CSV: duplicate/empty node IDs'); + if (node.type !== 'node') throw new Error('CSV: invalid node type'); + if (node.ports !== undefined && (!Array.isArray(node.ports) || node.ports.some((port) => !port || typeof port.name !== 'string' || !port.name || !['in', 'out', 'inout'].includes(port.direction)) || new Set(node.ports.map((port) => port.name)).size !== node.ports.length)) throw new Error('CSV: invalid or duplicate ports'); + nodes.set(node.id, node); + } + for (const edge of graph.edges) { + if (typeof edge.id !== 'string' || !edge.id || edges.has(edge.id)) throw new Error('CSV: duplicate/empty edge IDs'); + if (edge.type !== 'edge' || !nodes.has(edge.sourceId) || !nodes.has(edge.targetId)) throw new Error('CSV: dangling/invalid edge endpoints'); + if (edge.mode !== undefined && !MODES.has(edge.mode)) throw new Error('CSV: unknown edge mode'); + for (const [field, endpoint] of [['sourcePort', edge.sourceId], ['targetPort', edge.targetId]] as const) if (edge[field] !== undefined && !nodes.get(endpoint)?.ports?.some((port) => port.name === edge[field])) throw new Error(`CSV: missing ${field}`); + edges.add(edge.id); + } + for (const entity of [...graph.nodes, ...graph.edges]) { + if (entity.label !== undefined && entity.label !== null && typeof entity.label !== 'string') throw new Error('CSV: label must be string or null'); + for (const field of ['x', 'y', 'width', 'height', 'weight']) if ((entity as any)[field] !== undefined && (typeof (entity as any)[field] !== 'number' || !Number.isFinite((entity as any)[field]))) throw new Error(`CSV: ${field} must be finite`); + for (const field of ['color', 'shape', 'sourcePort', 'targetPort']) if ((entity as any)[field] !== undefined && typeof (entity as any)[field] !== 'string') throw new Error(`CSV: ${field} must be string`); + getCSVValidatedStyle(entity.style); + const points = (entity as any).points; + if (points !== undefined && (!Array.isArray(points) || points.some((point) => !point || typeof point.x !== 'number' || !Number.isFinite(point.x) || typeof point.y !== 'number' || !Number.isFinite(point.y)))) throw new Error('CSV: invalid route points'); + if ((entity as any).routing !== undefined && !['polyline', 'orthogonal', 'splines'].includes((entity as any).routing)) throw new Error('CSV: invalid routing'); + + } + getCSVValidatedStyle(graph.style); + if (graph.direction !== undefined && !['up', 'down', 'left', 'right'].includes(graph.direction)) throw new Error('CSV: invalid graph direction'); + if (graph.initialNodeId !== undefined && graph.initialNodeId !== null && !nodes.has(graph.initialNodeId)) throw new Error('CSV: missing graph initial node'); + for (const node of graph.nodes) { + if (node.parentId !== undefined && node.parentId !== null && (typeof node.parentId !== 'string' || !nodes.has(node.parentId))) throw new Error('CSV: missing parent node'); + if (node.initialNodeId !== undefined && node.initialNodeId !== null && !nodes.has(node.initialNodeId)) throw new Error('CSV: missing node initial reference'); + for (const port of node.ports ?? []) { + if (port.label !== undefined && typeof port.label !== 'string') throw new Error('CSV: invalid port label'); + for (const field of ['x', 'y', 'width', 'height']) if ((port as any)[field] !== undefined && (typeof (port as any)[field] !== 'number' || !Number.isFinite((port as any)[field]))) throw new Error('CSV: invalid port geometry'); + getCSVValidatedStyle(port.style); + } + + } +} + +export function fromCSV(source: CSVGraphSource, options: CSVImportOptions = {}): Graph { + if (!source || typeof source !== 'object') throw new Error('CSV: expected separate nodes/edges tables'); + const delimiter = getCSVDelimiter(source, options); + const schema = options.schema ?? source.schema; + const nodes = getCSVRecords(getCSVTable(source.nodes, delimiter), getCSVColumns('nodes', schema), 'nodes', options.metadata === 'native'); + const edges = getCSVRecords(getCSVTable(source.edges, delimiter), getCSVColumns('edges', schema), 'edges', options.metadata === 'native'); + const root = source.graph === undefined ? { id: '', mode: 'directed', initialNodeId: null, data: null } : JSON.parse(getCSVJSON(source.graph)); + if (!root || typeof root !== 'object' || Array.isArray(root) || Object.hasOwn(root, 'nodes') || Object.hasOwn(root, 'edges')) throw new Error('CSV: graph root must be a property object without tables'); + const graph = { ...root, id: Object.hasOwn(root, 'id') ? root.id : '', mode: Object.hasOwn(root, 'mode') ? root.mode : 'directed', nodes, edges } as Graph; + getCSVValidatedGraph(graph); + return graph; +} + +export function toCSV(graph: Graph, options: CSVExportOptions = {}): CSVGraphSource { + getCSVValidatedGraph(graph); + const delimiter = getCSVDelimiter(undefined, options); + const schema: CSVGraphSchema = { delimiter, nodes: getCSVColumns('nodes', options.schema), edges: getCSVColumns('edges', options.schema) }; + if (options.nativeCarrier === false) { + schema.nodes = schema.nodes!.filter((column) => column.field !== '$extra'); + schema.edges = schema.edges!.filter((column) => column.field !== '$extra'); + } + const ending = options.lineEnding ?? '\r\n'; + if (ending !== '\n' && ending !== '\r\n') throw new Error('CSV: invalid line ending'); + const source = {} as CSVGraphSource; + for (const kind of ['nodes', 'edges'] as const) { + const columns = schema[kind]!; + const mapped = new Set(['type', ...columns.filter((column) => column.field !== '$extra').map((column) => column.field)]); + const lines = [columns.map((column) => toCSVCell(column.name, delimiter)).join(delimiter)]; + for (const entity of graph[kind]) lines.push(columns.map((column) => { + const value = column.field === '$extra' ? Object.fromEntries(Object.entries(entity).filter(([key, value]) => !mapped.has(key) && value !== undefined)) : (entity as any)[column.field]; + return toCSVCell(toCSVValue(value, column), delimiter); + }).join(delimiter)); + source[kind] = lines.join(ending) + ending; + } + source.schema = schema; + if (options.nativeCarrier !== false) source.graph = Object.fromEntries(Object.entries(graph).filter(([key, value]) => !['nodes', 'edges'].includes(key) && value !== undefined).map(([key, value]) => [key, JSON.parse(getCSVJSON(value))])); + return source; +} + +export function fromTSV(source: CSVGraphSource, options: CSVImportOptions = {}): Graph { return fromCSV(source, { ...options, delimiter: '\t' }); } +export function toTSV(graph: Graph, options: CSVExportOptions = {}): CSVGraphSource { return toCSV(graph, { ...options, delimiter: '\t' }); } +export const csvConverter: GraphFormatConverter = createFormatConverter(toCSV, fromCSV); +export const tsvConverter: GraphFormatConverter = createFormatConverter(toTSV, fromTSV); + +function getCSVDocumentPatch(context: GraphDocumentPatchContext, forcedDelimiter?: ',' | '\t') { + const source = context.source; + const delimiter = getCSVDelimiter(source, { delimiter: forcedDelimiter }); + const patches: Record<'nodes' | 'edges', Array<{ start: number; end: number; text: string }>> = { nodes: [], edges: [] }; + const tables = { nodes: getCSVTable(source.nodes, delimiter), edges: getCSVTable(source.edges, delimiter) }; + const records = { nodes: context.sourceGraph.nodes, edges: context.sourceGraph.edges }; + const columnMaps = { nodes: new Map(getCSVColumns('nodes', source.schema).map((column) => [column.field, column])), edges: new Map(getCSVColumns('edges', source.schema).map((column) => [column.field, column])) }; + const rows = { nodes: new Map(records.nodes.map((entity, index) => [entity.id, tables.nodes.rows[index]])), edges: new Map(records.edges.map((entity, index) => [entity.id, tables.edges.rows[index]])) }; + try { + for (const change of context.changes) { + if (change.entity === 'graph') { + if (change.field === 'nodes' || change.field === 'edges') throw new Error('structural edits require explicit source editing'); + const root = source.graph ?? Object.fromEntries(Object.entries(context.sourceGraph).filter(([key]) => !['nodes', 'edges'].includes(key))); + if (change.afterPresent) Object.defineProperty(root, change.field, { value: change.after, enumerable: true, writable: true, configurable: true }); + else delete root[change.field]; + source.graph = root; continue; + } + if (['id', 'sourceId', 'targetId', 'type', 'parentId', 'ports', 'sourcePort', 'targetPort'].includes(change.field)) throw new Error(`${change.field} changes are outside the bounded cell patch policy`); + const kind = change.entity === 'node' ? 'nodes' : 'edges'; + const column = columnMaps[kind].get(change.field); + const index = column ? tables[kind].headers.indexOf(column.name) : -1; + const cell = rows[kind].get(change.id!)?.[index]; + if (!column || !cell) throw new Error(`${change.field} has no declared source cell`); + patches[kind].push({ start: cell.start, end: cell.end, text: toCSVCell(toCSVValue(change.afterPresent ? change.after : undefined, column), delimiter, cell.quoted) }); + } + for (const kind of ['nodes', 'edges'] as const) { + patches[kind].sort((a, b) => a.start - b.start); + let output = ''; let offset = 0; + for (const patch of patches[kind]) { output += source[kind].slice(offset, patch.start) + patch.text; offset = patch.end; } + source[kind] = output + source[kind].slice(offset); + } + return { ok: true as const, source }; + } catch (error) { return { ok: false as const, diagnostics: [{ code: 'unsupported-edit', severity: 'error' as const, message: `CSV: ${error instanceof Error ? error.message : String(error)}` }] }; } +} + +export const csvDocumentConverter = createGraphDocumentConverter({ format: 'csv', codec: { from: fromCSV, to: toCSV }, patch: (context) => getCSVDocumentPatch(context) }); +export const tsvDocumentConverter = createGraphDocumentConverter({ format: 'tsv', codec: { from: fromTSV, to: toTSV }, patch: (context) => getCSVDocumentPatch(context, '\t') }); diff --git a/src/formats/csv/table.ts b/src/formats/csv/table.ts new file mode 100644 index 0000000..9d22cc4 --- /dev/null +++ b/src/formats/csv/table.ts @@ -0,0 +1,54 @@ +export interface CSVCell { value: string; start: number; end: number; quoted: boolean } +export interface CSVTable { headers: string[]; rows: CSVCell[][] } + +/** Strict header-bearing RFC 4180 table with lexical cell spans. */ +export function getCSVTable(source: string, delimiter: ',' | '\t'): CSVTable { + if (typeof source !== 'string' || !source) throw new Error('CSV: a non-empty header is required'); + const records: CSVCell[][] = []; + let row: CSVCell[] = []; + let index = source.charCodeAt(0) === 0xfeff ? 1 : 0; + while (index < source.length) { + const start = index; + const quoted = source[index] === '"'; + let value: string; + if (quoted) { + index++; + const contentStart = index; + let closed = false; + while (index < source.length) { + if (source[index] !== '"') { index++; continue; } + if (source[index + 1] === '"') { index += 2; continue; } + value = source.slice(contentStart, index).replace(/""/g, '"'); + index++; closed = true; break; + } + if (!closed) throw new Error(`CSV: unterminated quoted cell at ${start}`); + if (index < source.length && ![delimiter, '\r', '\n'].includes(source[index])) throw new Error(`CSV: unexpected text after quoted cell at ${index}`); + } else { + while (index < source.length && ![delimiter, '\r', '\n'].includes(source[index])) { + if (source[index] === '"') throw new Error(`CSV: quote in unquoted cell at ${index}`); + index++; + } + value = source.slice(start, index); + } + row.push({ value: value!, start, end: index, quoted }); + if (source[index] === delimiter) { + index++; + if (index === source.length) row.push({ value: '', start: index, end: index, quoted: false }); + continue; + } + if (source[index] === '\r') { + if (source[index + 1] !== '\n') throw new Error(`CSV: bare CR outside a quoted cell at ${index}`); + index += 2; + } else if (source[index] === '\n') index++; + records.push(row); row = []; + } + if (row.length) records.push(row); + const headers = records.shift()?.map((cell) => cell.value) ?? []; + if (!headers.length || headers.some((name) => !name) || new Set(headers).size !== headers.length) throw new Error('CSV: headers must be unique non-empty strings'); + for (const cells of records) if (cells.length !== headers.length) throw new Error(`CSV: row has ${cells.length} cells; header has ${headers.length}`); + return { headers, rows: records }; +} + +export function toCSVCell(value: string, delimiter: ',' | '\t', quoted = false): string { + return quoted || value.includes(delimiter) || /["\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; +} diff --git a/src/formats/cytoscape/README.md b/src/formats/cytoscape/README.md index 3287ffb..015c348 100644 --- a/src/formats/cytoscape/README.md +++ b/src/formats/cytoscape/README.md @@ -7,6 +7,24 @@ Converter for [Cytoscape.js](https://js.cytoscape.org/) JSON format — the stan The converter uses plain JSON and does not require the `cytoscape` runtime. Install `cytoscape` only when passing the converted data to Cytoscape.js. +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **partial**. +Source document preservation: **none**. These are separate guarantees; +selected-field round-trip tests do not prove unrestricted losslessness. + +Imports grouped elements, including omitted empty groups from `cy.json()`, and flat element arrays. Grouped output retains native element properties, viewport and user data through a JSON snapshot. Null labels, empty strings and independent coordinates use versioned metadata. Edge IDs that collide with node IDs are qualified for Cytoscape’s global element namespace, with Graph IDs retained in metadata. Flat input is normalized to grouped output; declaration order across node and edge groups is not preserved. + + + + +Native snapshots use a private JSON convention on Graph data. Serialize `cy.json()` before document import to remove JavaScript-only undefined fields. Import options +`{ metadata: 'native' }` disable adapter marker interpretation. A foreign value +that deliberately matches the marker schema remains ambiguous in automatic mode. +Comments, lexical formatting and source locations are not preserved. + ## Resources - [Cytoscape.js documentation](https://js.cytoscape.org/) @@ -63,7 +81,7 @@ const imported = fromCytoscapeJSON({ -Preserves graph `mode`, `initialNodeId`, `data`, `direction`, `style`; node `parentId` (as `parent`), position (`x`/`y`), `shape`, `color`, `width`, `height`, `style`, `label`, `ports`; and edge `weight`, visual/style fields, `sourcePort`, `targetPort`, and `data`. +Maps selected graph `mode`, `initialNodeId`, `data`, `direction`, `style`; node `parentId` (as `parent`), position (`x`/`y`), `shape`, `color`, `width`, `height`, `style`, `label`, `ports`; and edge `weight`, visual/style fields, `sourcePort`, `targetPort`, and `data`. ### `cytoscapeConverter` @@ -81,3 +99,21 @@ const graph = cytoscapeConverter.from(cyto); | `CytoscapeJSON` | Top-level `{ data?, elements: { nodes, edges } }` | | `CytoscapeNode` | `{ data: { id, parent?, ... }, position? }` | | `CytoscapeEdge` | `{ data: { id, source, target, ... } }` | + + +## Native documents + + + +`cytoscapeDocumentConverter.from(source)` creates a plain JSON document with the +original source and a separate Graph projection. `.to(document)` preserves exact +source when unchanged, including after JSON serialization. Supported edits patch +verified native fields; unsupported edits throw with diagnostics. `.getUpdate(document)` +returns a diagnostic-bearing result. Native semantic projection remains partial. + +Grouped and flat source shapes, including interleaved elements, stay intact. Labels, mapped styles/weights/geometry and user data patch native fields. Structural edits and user data colliding with native entity keys reject. + +Bare exports interpret recognized import carriers by default. Use +`{ nativeCarrier: false }` when Graph data is user-authored and could match the +private carrier schema. Document projection pins importer state to its external +source before unwrapping it, preserving foreign lookalikes. diff --git a/src/formats/cytoscape/index.ts b/src/formats/cytoscape/index.ts index dd99cfa..d60107c 100644 --- a/src/formats/cytoscape/index.ts +++ b/src/formats/cytoscape/index.ts @@ -1,173 +1,143 @@ +import type { FormatExportOptions } from '../preservation'; +import { createGraphDocumentConverter } from '../document'; +import { getStructuredDocumentProjectionGraph, getStructuredDocumentPatch } from '../preservation'; import type { Graph, GraphFormatConverter } from '../../types'; import { createFormatConverter } from '../converter'; - -// --- Cytoscape.js JSON types --- +import { createMetadata, createNativeCarrier, getCarrierData, getClonedJSON, getMetadata, + getNativeCarrier, getRestoredRecord, getMergedNative, isJSONEqual, type FormatImportOptions } from '../preservation'; export interface CytoscapeNode { data: { id: string; parent?: string; [key: string]: any }; position?: { x: number; y: number }; + [key: string]: any; } - export interface CytoscapeEdge { - data: { id: string; source: string; target: string; [key: string]: any }; + data: { id?: string; source: string; target: string; [key: string]: any }; + [key: string]: any; } - export interface CytoscapeJSON { data?: Record; - elements: { - nodes: CytoscapeNode[]; - edges: CytoscapeEdge[]; + elements: { nodes: CytoscapeNode[]; edges: CytoscapeEdge[] }; + [key: string]: any; +} +export interface CytoscapeFlatJSON { + data?: Record; + elements: Array; + [key: string]: any; +} +/** Native cy.json() omits empty element groups. */ +export interface CytoscapeNativeJSON extends Omit { + elements: { nodes?: CytoscapeNode[]; edges?: CytoscapeEdge[]; [key: string]: any }; +} +export type CytoscapeInput = CytoscapeJSON | CytoscapeFlatJSON | CytoscapeNativeJSON; +export interface CytoscapeImportOptions extends FormatImportOptions {} + +const NODE_FIELDS = ['initialNodeId', 'label', 'width', 'height', 'shape', 'color', 'style', 'ports']; +const EDGE_FIELDS = ['label', 'mode', 'weight', 'points', 'routing', 'x', 'y', 'width', 'height', 'color', 'style', 'sourcePort', 'targetPort']; +function getFields(value: Record, fields: string[]): Record { + return Object.fromEntries(fields.filter((key) => Object.hasOwn(value, key)).map((key) => [key, value[key]])); +} +function getUserData(data: Record, key: string, known: string[], native = false): any { + if (!native && Object.hasOwn(data, key)) return data[key]; + const unknown = Object.fromEntries(Object.entries(data).filter(([name]) => !known.includes(name) && (name !== '__statelyai' || native || !['cytoscape.node', 'cytoscape.edge', 'cytoscape.graph'].some((format) => getMetadata(data[name], format))))); + return Object.keys(unknown).length > 0 ? unknown : undefined; +} +function getCytoscapeRoot(cyto: CytoscapeJSON, native = false): Omit { + return { id: cyto.data?.id ?? '', mode: cyto.data?.mode ?? 'directed', + initialNodeId: cyto.data?.initialNodeId ?? null, + data: getUserData(cyto.data ?? {}, 'graphData', ['id', 'mode', 'initialNodeId', 'direction', 'style'], native), + ...getFields(cyto.data ?? {}, ['direction', 'style']) }; +} +function getCytoscapeNode(node: CytoscapeNode, native = false): Graph['nodes'][number] { + return { type: 'node', id: node.data.id, parentId: node.data.parent ?? null, + initialNodeId: node.data.initialNodeId ?? null, label: Object.hasOwn(node.data, 'label') ? node.data.label : '', + data: getUserData(node.data, 'nodeData', ['id', 'parent', 'x', 'y', ...NODE_FIELDS], native), + ...getFields(node.data, NODE_FIELDS), + ...(node.position?.x !== undefined ? { x: node.position.x } : getFields(node.data, ['x'])), + ...(node.position?.y !== undefined ? { y: node.position.y } : getFields(node.data, ['y'])), }; } +function getCytoscapeEdge(edge: CytoscapeEdge, index: number, native = false): Graph['edges'][number] { + return { type: 'edge', id: edge.data.id ?? `e${index}`, sourceId: edge.data.source, targetId: edge.data.target, + label: Object.hasOwn(edge.data, 'label') ? edge.data.label : '', + data: getUserData(edge.data, 'edgeData', ['id', 'source', 'target', ...EDGE_FIELDS], native), + ...getFields(edge.data, EDGE_FIELDS) }; +} -// --- Conversion --- +/** Export grouped Cytoscape elements, with versioned adapter metadata. */ +export interface CytoscapeExportOptions extends FormatExportOptions {} -/** - * Converts a graph to Cytoscape.js JSON format. - * - * @example - * ```ts - * import { createGraph } from '@statelyai/graph'; - * import { toCytoscapeJSON } from '@statelyai/graph/cytoscape'; - * - * const graph = createGraph({ - * nodes: [{ id: 'a' }, { id: 'b' }], - * edges: [{ id: 'e0', sourceId: 'a', targetId: 'b' }], - * }); - * - * const cyto = toCytoscapeJSON(graph); - * // { elements: { nodes: [...], edges: [...] } } - * ``` - */ -export function toCytoscapeJSON(graph: Graph): CytoscapeJSON { - const graphData: Record = {}; - if (graph.id) graphData.id = graph.id; - graphData.mode = graph.mode; - if (graph.initialNodeId) - graphData.initialNodeId = graph.initialNodeId; - if (graph.data !== undefined) graphData.graphData = graph.data; - if (graph.direction) graphData.direction = graph.direction; - if (graph.style !== undefined) graphData.style = graph.style; +export function toCytoscapeJSON(graph: Graph, options: CytoscapeExportOptions = {}): CytoscapeJSON { + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, 'cytoscape'); + if (carrier) return getMergedNative(carrier.native, toRawCytoscapeJSON(carrier.baseline), + toRawCytoscapeJSON({ ...graph, data: getCarrierData(graph.data, 'cytoscape') })); + return toRawCytoscapeJSON(graph); +} - return { - ...(Object.keys(graphData).length > 0 && { data: graphData }), +function toRawCytoscapeJSON(graph: Graph): CytoscapeJSON { + const { nodes, edges, ...root } = graph; + const used = new Set(); + for (const node of nodes) { + if (typeof node.id !== 'string' || !node.id || used.has(node.id)) throw new Error('Cytoscape: node IDs must be unique non-empty strings'); + used.add(node.id); + } + function createEdgeId(preferred: string): string { + if (typeof preferred !== 'string' || !preferred) throw new Error('Cytoscape: edge IDs must be non-empty strings'); + let id = preferred; + for (let suffix = 1; used.has(id); suffix++) id = `${preferred}__statelyai${suffix}`; + used.add(id); return id; + } + const cyto: CytoscapeJSON = { + data: { ...getFields(root, ['id', 'mode', 'initialNodeId', 'direction', 'style']), graphData: graph.data }, elements: { - nodes: graph.nodes.map((n) => { - const data: CytoscapeNode['data'] = { id: n.id }; - if (n.parentId) data.parent = n.parentId; - if (n.label) data.label = n.label; - if (n.initialNodeId) data.initialNodeId = n.initialNodeId; - if (n.data !== undefined) data.nodeData = n.data; - if (n.width !== undefined) data.width = n.width; - if (n.height !== undefined) data.height = n.height; - if (n.shape) data.shape = n.shape; - if (n.color) data.color = n.color; - if (n.style !== undefined) data.style = n.style; - if (n.ports !== undefined) data.ports = n.ports; - - const node: CytoscapeNode = { data }; - if (n.x !== undefined && n.y !== undefined) { - node.position = { x: n.x, y: n.y }; - } - return node; + nodes: nodes.map((node) => { + const native: CytoscapeNode = { data: { id: node.id, + ...getFields(node, NODE_FIELDS), nodeData: node.data, + ...(node.parentId != null && { parent: node.parentId }), + ...(node.x !== undefined && node.y !== undefined ? {} : getFields(node, ['x', 'y'])), + }, ...(node.x !== undefined && node.y !== undefined && { position: { x: node.x, y: node.y } }) }; + native.data.__statelyai = createMetadata('cytoscape.node', node, getCytoscapeNode(native)); + return native; }), - edges: graph.edges.map((e) => { - const data: CytoscapeEdge['data'] = { - id: e.id, - source: e.sourceId, - target: e.targetId, - }; - if (e.label) data.label = e.label; - if (e.mode) data.mode = e.mode; - if (e.data !== undefined) data.edgeData = e.data; - if (e.weight !== undefined) data.weight = e.weight; - if (e.points !== undefined) data.points = e.points; - if (e.routing !== undefined) data.routing = e.routing; - if (e.x !== undefined) data.x = e.x; - if (e.y !== undefined) data.y = e.y; - if (e.width !== undefined) data.width = e.width; - if (e.height !== undefined) data.height = e.height; - if (e.color) data.color = e.color; - if (e.style !== undefined) data.style = e.style; - if (e.sourcePort !== undefined) data.sourcePort = e.sourcePort; - if (e.targetPort !== undefined) data.targetPort = e.targetPort; - return { data } as CytoscapeEdge; + edges: edges.map((edge, index) => { + const native: CytoscapeEdge = { data: { id: createEdgeId(edge.id), source: edge.sourceId, target: edge.targetId, + ...getFields(edge, EDGE_FIELDS), edgeData: edge.data } }; + native.data.__statelyai = createMetadata('cytoscape.edge', edge, getCytoscapeEdge(native, index)); + return native; }), }, }; + cyto.data!.__statelyai = createMetadata('cytoscape.graph', root, getCytoscapeRoot(cyto)); + return getClonedJSON(cyto); } -/** - * Parses a Cytoscape.js JSON object into a graph. - * - * @example - * ```ts - * import { fromCytoscapeJSON } from '@statelyai/graph/cytoscape'; - * - * const graph = fromCytoscapeJSON({ - * elements: { - * nodes: [{ data: { id: 'a' } }, { data: { id: 'b' } }], - * edges: [{ data: { id: 'e0', source: 'a', target: 'b' } }], - * }, - * }); - * ``` - */ -export function fromCytoscapeJSON(cyto: CytoscapeJSON): Graph { - if (!cyto || typeof cyto !== 'object') { - throw new Error('Cytoscape: expected an object'); - } - if (!cyto.elements || typeof cyto.elements !== 'object') { - throw new Error('Cytoscape: missing "elements" property'); +/** Import grouped or flat Cytoscape elements. `metadata: native` treats reserved blobs as user data. */ +export function fromCytoscapeJSON(input: CytoscapeInput, options: CytoscapeImportOptions = {}): Graph { + if (!input || typeof input !== 'object') throw new Error('Cytoscape: expected an object'); + if (!input.elements || typeof input.elements !== 'object') throw new Error('Cytoscape: missing "elements" property'); + let cyto: CytoscapeJSON; + if (Array.isArray(input.elements)) { + cyto = { ...input, elements: { nodes: [], edges: [] } }; + for (const element of input.elements) { + if (element.group === 'edges' || ('source' in element.data && 'target' in element.data)) cyto.elements.edges.push(element as CytoscapeEdge); + else cyto.elements.nodes.push(element as CytoscapeNode); + } + } else { + if (Object.hasOwn(input.elements, 'nodes') && !Array.isArray(input.elements.nodes)) throw new Error('Cytoscape: "elements.nodes" must be an array'); + if (Object.hasOwn(input.elements, 'edges') && !Array.isArray(input.elements.edges)) throw new Error('Cytoscape: "elements.edges" must be an array'); + cyto = { ...input, elements: { ...input.elements, nodes: input.elements.nodes ?? [], edges: input.elements.edges ?? [] } }; } - if (!Array.isArray(cyto.elements.nodes)) { - throw new Error('Cytoscape: "elements.nodes" must be an array'); - } - if (!Array.isArray(cyto.elements.edges)) { - throw new Error('Cytoscape: "elements.edges" must be an array'); - } - return { - id: cyto.data?.id ?? '', - mode: cyto.data?.mode ?? 'directed', - initialNodeId: cyto.data?.initialNodeId ?? null, - data: cyto.data?.graphData, - ...(cyto.data?.direction && { direction: cyto.data.direction }), - ...(cyto.data?.style !== undefined && { style: cyto.data.style }), - nodes: cyto.elements.nodes.map((n) => ({ - type: 'node' as const, - id: n.data.id, - parentId: n.data.parent ?? null, - initialNodeId: n.data.initialNodeId ?? null, - label: n.data.label ?? '', - data: n.data.nodeData, - ...(n.position && { x: n.position.x, y: n.position.y }), - ...(n.data.width !== undefined && { width: n.data.width }), - ...(n.data.height !== undefined && { height: n.data.height }), - ...(n.data.shape && { shape: n.data.shape }), - ...(n.data.color && { color: n.data.color }), - ...(n.data.style !== undefined && { style: n.data.style }), - ...(n.data.ports !== undefined && { ports: n.data.ports }), - })), - edges: cyto.elements.edges.map((e, i) => ({ - type: 'edge' as const, - id: e.data.id ?? `e${i}`, - sourceId: e.data.source, - targetId: e.data.target, - label: e.data.label ?? '', - ...(e.data.mode && { mode: e.data.mode }), - data: e.data.edgeData, - ...(e.data.weight !== undefined && { weight: e.data.weight }), - ...(e.data.points !== undefined && { points: e.data.points }), - ...(e.data.routing !== undefined && { routing: e.data.routing }), - ...(e.data.x !== undefined && { x: e.data.x }), - ...(e.data.y !== undefined && { y: e.data.y }), - ...(e.data.width !== undefined && { width: e.data.width }), - ...(e.data.height !== undefined && { height: e.data.height }), - ...(e.data.color && { color: e.data.color }), - ...(e.data.style !== undefined && { style: e.data.style }), - ...(e.data.sourcePort !== undefined && { sourcePort: e.data.sourcePort }), - ...(e.data.targetPort !== undefined && { targetPort: e.data.targetPort }), - })), + if (!Array.isArray(cyto.elements.nodes)) throw new Error('Cytoscape: "elements.nodes" must be an array'); + if (!Array.isArray(cyto.elements.edges)) throw new Error('Cytoscape: "elements.edges" must be an array'); + const getWireMetadata = (value: any, format: string) => options.metadata === 'native' ? undefined : getMetadata(value, format); + const metadata = getWireMetadata(cyto.data?.__statelyai, 'cytoscape.graph'); + const graph: Graph = { + ...getRestoredRecord(getCytoscapeRoot(cyto, options.metadata === 'native'), metadata), + nodes: cyto.elements.nodes.map((node) => getRestoredRecord(getCytoscapeNode(node, options.metadata === 'native'), getWireMetadata(node.data.__statelyai, 'cytoscape.node'))), + edges: cyto.elements.edges.map((edge, index) => getRestoredRecord(getCytoscapeEdge(edge, index, options.metadata === 'native'), getWireMetadata(edge.data.__statelyai, 'cytoscape.edge'))), }; + if (!metadata || !isJSONEqual(cyto, toRawCytoscapeJSON(graph))) graph.data = createNativeCarrier(graph.data, 'cytoscape', cyto, graph); + return graph; } /** @@ -189,3 +159,12 @@ export function fromCytoscapeJSON(cyto: CytoscapeJSON): Graph { */ export const cytoscapeConverter: GraphFormatConverter = createFormatConverter(toCytoscapeJSON, fromCytoscapeJSON); + + +/** Exact native source preservation, with strict verified field edits. */ +export const cytoscapeDocumentConverter = createGraphDocumentConverter({ + format: 'cytoscape', + codec: { from: (source: CytoscapeInput) => fromCytoscapeJSON(source, { metadata: 'native' }), to: (graph: Graph) => toCytoscapeJSON(graph, { nativeCarrier: false }) }, + project: (source: CytoscapeInput) => ({ graph: getStructuredDocumentProjectionGraph(fromCytoscapeJSON(source, { metadata: 'native' }), source, 'cytoscape') }), + patch: (context) => getStructuredDocumentPatch(context, 'cytoscape'), +}); diff --git a/src/formats/d2/README.md b/src/formats/d2/README.md index 3702729..48d5bbd 100644 --- a/src/formats/d2/README.md +++ b/src/formats/d2/README.md @@ -1,14 +1,26 @@ # d2 Converter for [d2](https://d2lang.com/) diagram syntax. A single bidirectional -converter handles the whole d2 language — containers, connections, shapes, +converter handles supported d2 constructs — containers, connections, shapes, styles, structured shapes (`sql_table` / `class`), sequence diagrams, grids, and source-level abstractions (`vars` / `classes` / imports). -The converter targets **semantic-lossless round-trip**: every node, edge, port, -style, and hierarchy survives `d2 → graph → d2`. Output may be reformatted -(whitespace/blank lines are not preserved), but comments are attached to the -following entity on a best-effort basis. +Supported syntax maps to Graph nodes, edges, ports, styles and hierarchy. +Output is reformatted; comments attach to the following entity on a +best-effort basis. This is not a complete semantic or source-document guarantee. + +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **partial**. +Source document preservation: **partial**. These are separate guarantees; +selected-field round-trip tests do not prove unrestricted losslessness. + +Supports a subset of D2 semantics. Effective Graph/edge modes, scoped key paths, +structured-node attributes, arrowheads and recursive source abstractions are +retained. Arbitrary Graph metadata and unsupported language constructs still +prevent unrestricted losslessness. ## Resources @@ -20,7 +32,7 @@ following entity on a best-effort basis. ```ts -import { fromD2, toD2, d2Converter } from '@statelyai/graph/d2'; +import { fromD2, toD2, d2Converter, d2DocumentConverter } from '@statelyai/graph/d2'; ``` `toD2` exports a graph as d2 syntax; `fromD2` parses d2 syntax into a graph. @@ -48,20 +60,74 @@ const text = toD2(graph); | `shape:` | `node.shape` (canonical) | | `style.*` | `node.style` / `edge.style` record (booleans/numbers coerced) | | `width` / `height` / `top` / `left` | Canonical `node.width` / `height` / `y` / `x` | -| `near` / `icon` / `tooltip` / `link` / `class` | `node.data` (`_d2` namespace) | +| `near` / `icon` / `tooltip` / `link` / `class` | Typed fields on `node.data` | | `sql_table` / `class` fields | `GraphPort`s; `node.field` connections become `sourcePort` / `targetPort`. Field type/constraints/visibility live on `port.data` | | Typed/block labels (`\|md\|`, `\|js\|`, `\|tex\|`) | `node.label` text + `node.data.labelBlock` descriptor | | Sequence diagrams (`shape: sequence_diagram`) | Ordered container; message/child order stored in `node.data.order` | | `grid-rows` / `grid-columns` / `grid-gap` | `node.data.grid` | -| `vars` / `classes` / imports (`@path`) | Preserved on `graph.data.source` | +| `vars` / `classes` | Recursive values on `graph.data.source`; scoped definitions on `node.data.source` | +| Imports | `source.importRecords` retains path, scope, spread kind and ordering; legacy `source.imports` string arrays remain readable | | `direction:` | `graph.direction` | -| `# comments` | `commentsBefore` on the following node/edge; orphans in `graph.data` leading/trailing buckets | +| `# comments` | `commentsBefore` / `commentsAfter`; container trailing comments and graph leading/trailing buckets | + + + +Ordinary dotted IDs remain unchanged. A literal dotted key such as `"a.b"` +uses an encoded Graph ID distinct from the hierarchical key `a.b`; authored +segments live in `node.data.keyPath`, guarded by `sourceId`. Ancestor `_` +references and indexed connection attributes resolve to existing entities. +Connection-chain labels/styles apply to every connection. + +Emission uses `getEdgeMode(graph, edge)`. The authored connector is reused only +when its mode agrees with the current canonical fields. Label source hints are +also reused only while the canonical label still matches their baseline. ## Limitations -- **Cosmetic, not text-faithful.** Whitespace, blank lines, and inline trailing - comments are not preserved. Connection chains (`a -> b -> c`) are expanded to - individual edges and re-emitted as separate lines. +- **Reformatted source.** Whitespace, blank lines and exact comment placement + are not preserved. Inline comments may become standalone comments. + Connection chains expand into individual edges. - **No edge weight.** d2 has no native edge weight concept. - **Imports are opaque.** `@path` references are preserved but not resolved (the converter is a synchronous string transform with no filesystem access). +- **Substitutions are opaque.** Expressions remain authored expressions; + variables/classes/imports are not compiled into effective graph semantics. +- **Unsupported constructs.** Boards and globs are retained in + `graph.data.source.unknownStatements` for inspection. They do not create + ordinary nodes and are not emitted as if their semantics were understood. +- **Graph fields.** Graph/edge IDs, arbitrary user data and ports on ordinary + unstructured shapes have no unrestricted native encoding. + + +## Native documents + + + +`d2DocumentConverter` retains exact original source after JSON persistence, +including comments and unsupported syntax. It exposes a partial Graph projection; +quote caches belong to native source. Unambiguous flat root declaration labels +can be patched without changing unedited bytes. Nested labels, expressions, +structural changes and other unsupported edits fail explicitly. Use +`document.source` for those changes. See [document API](../document/README.md). + + +## Native interoperability + + + +The native suite compiles external and emitted profiles with pinned +`@terrastruct/d2` 0.1.33 in a bounded WASM subprocess. Compiled assertions cover +escaped labels, literal dotted identity, isolated nodes, SQL port columns and +constraints, arrows, scoped ancestor endpoints and variable/class substitution. +These tests characterize a supported profile; opaque source retention does not +imply full D2 interpretation. See [validation scope](../../../docs/native-validation.md). + + + + +D2's UTF-8 compiler replaces lone UTF-16 surrogates in wire labels/IDs. Import, +emission and strict label patches reject that loss explicitly. Valid astral pairs +remain supported. NUL labels remain supported at the compiled-diagram field +boundary: the native compiler retains them exactly. JSON-escaped opaque data and +original unprojectable document source are preserved independently of those wire +string restrictions. diff --git a/src/formats/d2/document.ts b/src/formats/d2/document.ts new file mode 100644 index 0000000..5aa6557 --- /dev/null +++ b/src/formats/d2/document.ts @@ -0,0 +1,47 @@ +import { createGraphDocumentConverter } from '../document'; +import { getGraphDocumentTextLines, getGraphDocumentTextPatch, type GraphDocumentTextSpan } from '../document/text'; +import { fromD2 } from './parser'; +import { toD2 } from './emitter'; +import { getD2ScalarString } from './shared'; + +/** Exact source retention; bounded flat declaration labels can be patched safely. */ +export const d2DocumentConverter = createGraphDocumentConverter({ + format: 'd2', codec: { from: fromD2, to: toD2 }, + project(source) { + const graph = fromD2(source); + // These are importer-created quote caches; the native source owns their bytes. + for (const entity of [...graph.nodes, ...graph.edges]) { + delete entity.data.labelSource; + delete entity.data.sourceLabel; + } + return { graph }; + }, + patch(context) { + const spans: GraphDocumentTextSpan[] = []; + const nodesById = new Map(context.sourceGraph.nodes.map((node) => [node.id, node])); + let depth = 0; + for (const line of getGraphDocumentTextLines(context.source, '#')) { + if (depth === 0) { + const match = line.text.match(/^([A-Za-z_][\w-]*)\s*:\s*("(?:[^"\\]|\\.)*"|'[^']*'|[^{};]+)$/); + if (match) { + const probe = fromD2(line.text); + const node = nodesById.get(match[1]); + if (probe.nodes.length === 1 && node && probe.nodes[0].label === node.label) { + const relative = line.text.lastIndexOf(match[2]); + spans.push({ entity: 'node', id: node.id, start: line.start + relative, end: line.start + relative + match[2].length, encode: (value) => JSON.stringify(getD2ScalarString(value)) }); + } + } + } + // Brace count is bounded to map syntax outside quoted literals. + let quote = ''; + for (let i = 0; i < line.text.length; i++) { + const char = line.text[i]; + let slashes = 0; for (let at = i - 1; at >= 0 && line.text[at] === '\\'; at--) slashes++; + if (quote) { if (char === quote && slashes % 2 === 0) quote = ''; continue; } + if (char === '"' || char === "'") { quote = char; continue; } + if (char === '{') depth++; else if (char === '}') depth--; + } + } + return getGraphDocumentTextPatch(context, spans); + }, +}); diff --git a/src/formats/d2/emitter.ts b/src/formats/d2/emitter.ts index 36e12cb..bfa584a 100644 --- a/src/formats/d2/emitter.ts +++ b/src/formats/d2/emitter.ts @@ -1,4 +1,5 @@ import type { GraphNode, GraphEdge } from '../../types'; +import { getEdgeMode } from '../../mode'; import { type D2Arrow, type D2Graph, @@ -8,6 +9,10 @@ import { type D2LabelBlock, escapeD2Key, escapeD2Label, + getD2ScalarGraph, + unquoteD2, + ARROW_TO_MODE, + RESERVED_KEYWORDS, } from './shared'; const STRUCTURED_SHAPES = new Set(['sql_table', 'class']); @@ -39,7 +44,17 @@ function splitId(id: string): string[] { } else buf += ch; } out.push(buf); - return out; + return out.map(unquoteD2); +} + +function getSourceValueLines(key: string, value: any, ind: number): string[] { + const sourceKey = /^[A-Za-z0-9_-]+$/.test(key) ? key : escapeD2Key(key); + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + return [`${indent(ind)}${sourceKey}: {`, ...Object.entries(value).flatMap(([name, child]) => getSourceValueLines(name, child, ind + 1)), `${indent(ind)}}`]; + } + const scalar = Array.isArray(value) ? `[${value.map((entry) => entry === null ? 'null' : typeof entry === 'string' && /^(?:true|false|null|-?\d)/.test(entry) ? JSON.stringify(entry) : styleValueToD2(entry)).join('; ')}]` + : value === null ? 'null' : typeof value === 'string' && /^(?:true|false|null|-?\d)/.test(value) ? JSON.stringify(value) : styleValueToD2(value); + return [`${indent(ind)}${sourceKey}: ${scalar}`]; } function indent(n: number): string { @@ -69,6 +84,7 @@ function styleValueToD2(v: string | number | boolean): string { } interface EmitCtx { + graph: D2Graph; childrenOf: Map[]>; nodeById: Map>; /** Edges owned by a container (present in its `data.order`), keyed by owner id. */ @@ -79,6 +95,23 @@ interface EmitCtx { emittedEdges: Set; } +function getNodePath(ctx: EmitCtx, id: string): string[] { + const node = ctx.nodeById.get(id); + if (node?.data?.keyPath && node.data.sourceId === node.id) return node.data.keyPath; + const path = splitId(id); + // Graph-created hierarchies need not encode parent IDs into child IDs. + const ancestors: string[] = []; + const visited = new Set([id]); + let parentId = node?.parentId; + while (parentId && !visited.has(parentId)) { + visited.add(parentId); + if (id.startsWith(parentId + '.')) return path; + ancestors.unshift(parentId); + parentId = ctx.nodeById.get(parentId)?.parentId; + } + return [...ancestors.flatMap(splitId), ...path]; +} + function emitComments(comments: string[] | undefined, ind: number, lines: string[]): void { if (!comments) return; @@ -96,7 +129,7 @@ function nodeAttrLines( if (d.icon) lines.push(`${indent(ind)}icon: ${d.icon}`); if (d.tooltip) lines.push(`${indent(ind)}tooltip: ${escapeD2Label(d.tooltip)}`); if (d.link) lines.push(`${indent(ind)}link: ${escapeD2Label(d.link)}`); - if (d.classes?.length) lines.push(`${indent(ind)}class: ${d.classes.join(' ')}`); + if (d.classes?.length) lines.push(`${indent(ind)}class: ${d.classes.length > 1 ? `[${d.classes.map(escapeD2Label).join('; ')}]` : escapeD2Label(d.classes[0])}`); if (node.width !== undefined) lines.push(`${indent(ind)}width: ${node.width}`); if (node.height !== undefined) lines.push(`${indent(ind)}height: ${node.height}`); if (node.x !== undefined) lines.push(`${indent(ind)}left: ${node.x}`); @@ -116,6 +149,8 @@ function nodeAttrLines( lines.push(`${indent(ind)}${k}: ${styleValueToD2(v)}`); } } + if (d.source?.vars) lines.push(...getSourceValueLines('vars', d.source.vars, ind)); + if (d.source?.classes) lines.push(...getClassLines(d.source.classes, ind)); return lines; } @@ -124,6 +159,7 @@ function portLines(node: GraphNode, ind: number): string for (const port of node.ports ?? []) { const pd = port.data ?? ({} as D2PortData); const vis = pd.visibility ?? ''; + emitComments(pd.commentsBefore, ind, lines); const name = `${vis}${escapeD2Key(port.name)}`; let line = `${indent(ind)}${name}`; if (pd.typeName) line += `: ${escapeD2Label(pd.typeName)}`; @@ -138,6 +174,8 @@ function portLines(node: GraphNode, ind: number): string function labelHeader(node: GraphNode): string { if (node.label == null || node.label === '') return ''; const labelBlock = nodeData(node).labelBlock; + const data = nodeData(node); + if (data.labelSource && data.sourceLabel === node.label) return data.labelSource; if (labelBlock) { return emitLabelBlock(node.label, labelBlock); } @@ -185,12 +223,17 @@ function emitScope( ): void { const children = ctx.childrenOf.get(containerId) ?? []; const ownedEdges = container ? (ctx.edgesByOwner.get(container.id) ?? []) : []; - const order = container ? nodeData(container).order : undefined; + const order = container ? nodeData(container).order : ctx.graph.data?.order; if (order) { const childById = new Map(children.map((c) => [c.id, c])); const edgeById = new Map(ownedEdges.map((e) => [e.id, e])); for (const refId of order) { + const imported = ctx.graph.data?.source?.importRecords?.find((record) => record.ref === refId && record.scopeId === containerId); + if (imported) { + lines.push(`${indent(ind)}${imported.spread ? '...' : ''}@${imported.path}`); + continue; + } const child = childById.get(refId); if (child) { emitItem(ctx, child, scopeSegs, ind, lines); @@ -220,7 +263,7 @@ function emitItem( lines: string[], ): void { // Relative dotted key from the current scope. - const rel = splitId(node.id).slice(scopeSegs.length).map(escapeD2Key).join('.'); + const rel = getNodePath(ctx, node.id).slice(scopeSegs.length).map(escapeD2Key).join('.'); // Pure prefix: don't emit a block; recurse so descendants carry the prefix. if (isPurePrefix(ctx, node)) { @@ -239,6 +282,7 @@ function emitItem( if (!needsBlock) { lines.push(`${indent(ind)}${rel}${label ? `: ${label}` : ''}`); + emitComments(nodeData(node).commentsAfter, ind, lines); return; } @@ -247,18 +291,23 @@ function emitItem( lines.push(`${indent(ind)}${rel}: {`); if (label) lines.push(`${indent(ind + 1)}label: ${label}`); if (structured) { - lines.push(`${indent(ind + 1)}shape: ${node.shape}`); + lines.push(...attrLines); lines.push(...ports); } else { lines.push(...attrLines); - emitScope(ctx, node, node.id, splitId(node.id), ind + 1, lines); + emitScope(ctx, node, node.id, getNodePath(ctx, node.id), ind + 1, lines); } + emitComments(nodeData(node).trailingComments, ind + 1, lines); lines.push(`${indent(ind)}}`); + emitComments(nodeData(node).commentsAfter, ind, lines); } -function endpointRef(nodeId: string, port: string | undefined, scopeSegs: string[]): string { - const segs = splitId(nodeId).slice(scopeSegs.length); - let ref = segs.map(escapeD2Key).join('.'); +function endpointRef(ctx: EmitCtx, nodeId: string, port: string | undefined, scopeSegs: string[]): string { + const path = getNodePath(ctx, nodeId); + let shared = 0; + while (shared < scopeSegs.length && path[shared] === scopeSegs[shared]) shared++; + const segs = path.slice(shared); + let ref = [...Array(scopeSegs.length - shared).fill('_'), ...segs.map(escapeD2Key)].join('.'); if (port) ref += `.${escapeD2Key(port)}`; return ref; } @@ -274,9 +323,10 @@ function emitEdge( const d = edgeData(edge); emitComments(d.commentsBefore, ind, lines); - const arrow: D2Arrow = d.arrow ?? modeToArrow(edge); + const mode = getEdgeMode(ctx.graph, edge); + const arrow: D2Arrow = d.arrow && ARROW_TO_MODE[d.arrow] === mode ? d.arrow : modeToArrow(ctx.graph, edge); const owner = scoped ? ctx.ownerOfEdge.get(edge.id) : undefined; - const scopeSegs = owner ? splitId(owner) : []; + const scopeSegs = owner ? getNodePath(ctx, owner) : []; // Honor authored reversed `<-`: swap endpoints back for display. let sId = edge.sourceId; @@ -288,11 +338,11 @@ function emitEdge( [sPort, tPort] = [tPort, sPort]; } - const left = endpointRef(sId, sPort, scopeSegs); - const right = endpointRef(tId, tPort, scopeSegs); + const left = endpointRef(ctx, sId, sPort, scopeSegs); + const right = endpointRef(ctx, tId, tPort, scopeSegs); let line = `${indent(ind)}${left} ${arrow} ${right}`; if (edge.label != null && edge.label !== '') { - const lbl = d.labelBlock + const lbl = d.labelSource && d.sourceLabel === edge.label ? d.labelSource : d.labelBlock ? emitLabelBlock(edge.label, d.labelBlock) : escapeD2Label(edge.label); line += `: ${lbl}`; @@ -306,6 +356,7 @@ function emitEdge( } else { lines.push(line); } + emitComments(d.commentsAfter, ind, lines); } function edgeBlockLines(edge: GraphEdge): string[] { @@ -317,8 +368,13 @@ function edgeBlockLines(edge: GraphEdge): string[] { } const d = edgeData(edge); const { sourceArrowhead, targetArrowhead } = d; - if (sourceArrowhead?.shape) out.push(`source-arrowhead.shape: ${sourceArrowhead.shape}`); - if (targetArrowhead?.shape) out.push(`target-arrowhead.shape: ${targetArrowhead.shape}`); + for (const [name, spec] of [['source-arrowhead', sourceArrowhead], ['target-arrowhead', targetArrowhead]] as const) { + if (!spec) continue; + if (spec.shape) out.push(`${name}.shape: ${escapeD2Label(spec.shape)}`); + if (spec.label !== undefined) out.push(`${name}.label: ${escapeD2Label(spec.label)}`); + for (const [key, value] of Object.entries(spec.style ?? {})) out.push(`${name}.style.${key}: ${styleValueToD2(value)}`); + for (const [key, value] of Object.entries(spec.attributes ?? {})) out.push(`${name}.${key}: ${styleValueToD2(value)}`); + } if (d.reserved) { for (const [k, v] of Object.entries(d.reserved)) { out.push(`${k}: ${styleValueToD2(v)}`); @@ -327,8 +383,8 @@ function edgeBlockLines(edge: GraphEdge): string[] { return out; } -function modeToArrow(edge: GraphEdge): D2Arrow { - switch (edge.mode) { +function modeToArrow(graph: D2Graph, edge: GraphEdge): D2Arrow { + switch (getEdgeMode(graph, edge)) { case 'undirected': return '--'; case 'bidirectional': @@ -338,6 +394,20 @@ function modeToArrow(edge: GraphEdge): D2Arrow { } } +function getClassLines(classes: Record>, ind: number): string[] { + const lines = [`${indent(ind)}classes: {`]; + for (const [name, attributes] of Object.entries(classes)) { + lines.push(`${indent(ind + 1)}${escapeD2Key(name)}: {`); + for (const [key, value] of Object.entries(attributes)) { + if (RESERVED_KEYWORDS.has(key) || (value && typeof value === 'object')) lines.push(...getSourceValueLines(key, value, ind + 2)); + else lines.push(`${indent(ind + 2)}style.${key}: ${styleValueToD2(value)}`); + } + lines.push(`${indent(ind + 1)}}`); + } + lines.push(`${indent(ind)}}`); + return lines; +} + /** * Single pass over container `data.order` lists, building both edge→owner and * owner→edges indexes. An edge "belongs" to a container when that container @@ -367,6 +437,7 @@ function buildEdgeOwnership(graph: D2Graph): { } export function toD2(graph: D2Graph): string { + getD2ScalarGraph(graph); const lines: string[] = []; const data = graph.data ?? { diagramType: 'd2' }; @@ -380,28 +451,16 @@ export function toD2(graph: D2Graph): string { // vars if (data.source?.vars && Object.keys(data.source.vars).length > 0) { - lines.push('vars: {'); - for (const [k, v] of Object.entries(data.source.vars)) { - lines.push(` ${escapeD2Key(k)}: ${styleValueToD2(v as any)}`); - } - lines.push('}'); + lines.push(...getSourceValueLines('vars', data.source.vars, 0)); } // classes if (data.source?.classes && Object.keys(data.source.classes).length > 0) { - lines.push('classes: {'); - for (const [name, style] of Object.entries(data.source.classes)) { - lines.push(` ${escapeD2Key(name)}: {`); - for (const [k, v] of Object.entries(style)) { - lines.push(` style.${k}: ${styleValueToD2(v)}`); - } - lines.push(' }'); - } - lines.push('}'); + lines.push(...getClassLines(data.source.classes, 0)); } // imports - if (data.source?.imports) { + if (data.source?.imports && !data.source.importRecords) { for (const imp of data.source.imports) lines.push(`@${imp}`); } @@ -426,6 +485,7 @@ export function toD2(graph: D2Graph): string { } const { edgesByOwner, ownerOfEdge } = buildEdgeOwnership(graph); const ctx: EmitCtx = { + graph, childrenOf, nodeById, edgesByOwner, @@ -451,4 +511,3 @@ export function toD2(graph: D2Graph): string { return lines.join('\n') + '\n'; } - diff --git a/src/formats/d2/index.ts b/src/formats/d2/index.ts index 2acfc84..5c9574d 100644 --- a/src/formats/d2/index.ts +++ b/src/formats/d2/index.ts @@ -30,3 +30,5 @@ export type { * ``` */ export const d2Converter = createFormatConverter(toD2, fromD2); + +export { d2DocumentConverter } from './document'; diff --git a/src/formats/d2/parser.ts b/src/formats/d2/parser.ts index fffc865..1d79b31 100644 --- a/src/formats/d2/parser.ts +++ b/src/formats/d2/parser.ts @@ -12,6 +12,7 @@ import { NUMERIC_STYLE_KEYS, unquoteD2, validateD2Input, + getD2ScalarGraph, } from './shared'; // --- Statement scanning --- @@ -26,6 +27,7 @@ interface RawStatement { head: string; /** Block body (between braces), or undefined. */ block?: string; + commentsAfter?: string[]; } type RawToken = RawComment | RawStatement; @@ -71,6 +73,7 @@ function splitStatements(body: string): RawToken[] { const len = body.length; let buf = ''; let braceDepth = 0; + let bracketDepth = 0; let blockStart = -1; // index in buf where top-level block began const flush = () => { @@ -106,16 +109,20 @@ function splitStatements(body: string): RawToken[] { continue; } - // Inline trailing comment (dropped, best-effort) + // Inline annotations belong to the preceding statement. if (ch === '#' && braceDepth === 0 && buf.trim() !== '') { const nl = body.indexOf('\n', i); - i = nl === -1 ? len : nl; + const end = nl === -1 ? len : nl; + const comment = body.slice(i + 1, end).trim(); + i = end; flush(); + const previous = tokens[tokens.length - 1]; + if (previous?.type === 'stmt') previous.commentsAfter = [comment]; continue; } // Quoted string - if (ch === '"') { + if (ch === '"' || ch === "'") { const endQuote = (() => { let j = i + 1; while (j < len) { @@ -123,7 +130,7 @@ function splitStatements(body: string): RawToken[] { j += 2; continue; } - if (body[j] === '"') return j; + if (body[j] === ch) return j; j++; } return len - 1; @@ -143,6 +150,17 @@ function splitStatements(body: string): RawToken[] { } } + // Substitution braces are expressions, not map delimiters. + if (ch === '$' && body[i + 1] === '{') { + const end = body.indexOf('}', i + 2); + if (end < 0) throw new Error('D2: unterminated substitution'); + buf += body.slice(i, end + 1); + i = end + 1; + continue; + } + if (ch === '[') bracketDepth++; + if (ch === ']') bracketDepth = Math.max(0, bracketDepth - 1); + if (ch === '{') { if (braceDepth === 0) blockStart = buf.length; braceDepth++; @@ -157,7 +175,7 @@ function splitStatements(body: string): RawToken[] { continue; } - if ((ch === '\n' || ch === ';') && braceDepth === 0) { + if ((ch === '\n' || ch === ';') && braceDepth === 0 && bracketDepth === 0) { flush(); i++; continue; @@ -206,7 +224,7 @@ function skipQuoted(s: string, i: number): number { j += 2; continue; } - if (s[j] === '"') return j; + if (s[j] === s[i]) return j; j++; } return s.length; @@ -334,7 +352,7 @@ interface ParseCtx { } function nodeId(path: string[]): string { - return path.join('.'); + return path.map((segment) => /[."\\]/.test(segment) ? JSON.stringify(segment) : segment).join('.'); } function ensureNode( @@ -352,7 +370,7 @@ function ensureNode( id, parentId, label: null, - data: { declarationForm }, + data: { declarationForm, ...(path.some((segment) => /[."\\]/.test(segment)) && { keyPath: [...path], sourceId: id }) }, }; ctx.nodes.set(id, node); registerChildOrder(ctx, parentId, id); @@ -386,6 +404,7 @@ function applyReserved( const { text, labelBlock } = parseLabelBlock(value); node.label = text; if (labelBlock) node.data.labelBlock = labelBlock; + if (/^["']/.test(value)) { node.data.labelSource = value; node.data.sourceLabel = text; } break; } case 'icon': @@ -402,6 +421,7 @@ function applyReserved( break; case 'class': node.data.classes = unquoteD2(value) + .replace(/^\[|\]$/g, '') .split(/[\s;]+/) .filter(Boolean); break; @@ -506,21 +526,24 @@ function parseStructuredBody( classMember: boolean, ): void { node.ports = node.ports ?? []; + let comments: string[] = []; for (const tok of splitStatements(block)) { - if (tok.type !== 'stmt') continue; + if (tok.type === 'comment') { comments.push(tok.text); continue; } const kv = splitKeyValue(tok.head); - if (kv && kv.key === 'shape') { - node.shape = unquoteD2(kv.value); - continue; - } - if (kv && (kv.key === 'style' || kv.key.startsWith('style.'))) { - applyStyleBlock(node, tok.block ?? kv.value); + const rawPath = kv ? parseKeyPath(kv.key) : []; + if (rawPath.length && isReservedSegment(rawPath[0])) { + applyReserved(node, rawPath[0], rawPath.slice(1), kv!.value); + if (rawPath[0] === 'style' && tok.block !== undefined) applyStyleBlock(node, tok.block); continue; } const fieldName = kv ? kv.key : tok.head.trim(); const fieldType = kv ? kv.value : ''; - node.ports.push(parseFieldPort(fieldName, fieldType, tok.block, classMember)); + const port = parseFieldPort(fieldName, fieldType, tok.block, classMember); + if (comments.length) port.data.commentsBefore = comments; + comments = []; + node.ports.push(port); } + if (comments.length) node.data.trailingComments = comments; } // --- Connection parsing --- @@ -588,6 +611,36 @@ function resolveEndpoint( return { nodeId: nodeId(path) }; } +function getResolvedPath(scopePath: string[], rawSegments: string[]): string[] { + const path = [...scopePath]; + let index = 0; + while (rawSegments[index] === '_') { path.pop(); index++; } + return [...path, ...unquotePath(rawSegments.slice(index))]; +} + +function hasConnectionReference(ctx: ParseCtx, head: string, block: string | undefined, scopePath: string[], comments: string[]): boolean { + const kv = splitKeyValue(head); + const match = (kv?.key ?? head).match(/^\((.+)\)\s*\[(\d+)\](?:\.(.+))?$/s); + if (!match) return false; + const connector = findConnector(match[1]); + if (!connector) throw new Error('D2: invalid connection reference'); + const left = resolveEndpoint(ctx, getResolvedPath(scopePath, parseKeyPath(match[1].slice(0, connector.index).trim()))); + const right = resolveEndpoint(ctx, getResolvedPath(scopePath, parseKeyPath(match[1].slice(connector.index + connector.arrow.length).trim()))); + const source = connector.arrow === '<-' ? right : left; + const target = connector.arrow === '<-' ? left : right; + const matches = ctx.edges.filter((edge) => edge.sourceId === source.nodeId && edge.targetId === target.nodeId + && edge.sourcePort === source.port && edge.targetPort === target.port && edge.data.arrow === connector.arrow); + const edge = matches[Number(match[2])]; + if (!edge) throw new Error(`D2: connection reference index ${match[2]} not found`); + if (comments.length) edge.data.commentsBefore = [...(edge.data.commentsBefore ?? []), ...comments]; + if (match[3]) applyEdgeBlock(edge, `${match[3]}: ${kv?.value ?? ''}${block === undefined ? '' : ` {${block}}`}`); + else { + if (kv?.value) edge.label = unquoteD2(kv.value); + if (block !== undefined) applyEdgeBlock(edge, block); + } + return true; +} + // --- Top-level scope parsing --- function parseScope( @@ -607,12 +660,30 @@ function parseScope( const head = tok.head; + if (hasConnectionReference(ctx, head, tok.block, scopePath, comments)) { + comments = []; + continue; + } + + // Globs and boards require an expansion/document model. Preserve them as + // unsupported source constructs instead of introducing fabricated nodes. + if (/^(?:layers|scenarios|steps)(?:\s*:|$)|^(?:\*|!&|&)|^\.\.\.\$\{/.test(head)) { + ctx.graph.data.source ??= {}; + (ctx.graph.data.source.unknownStatements ??= []).push(head + (tok.block === undefined ? '' : ` {${tok.block}}`)); + continue; + } + // Import: `key: @path` or `...@path` if (head.startsWith('...@') || head.startsWith('@')) { const importPath = head.replace(/^\.\.\./, '').replace(/^@/, '').trim(); ctx.graph.data.source = ctx.graph.data.source ?? {}; ctx.graph.data.source.imports = ctx.graph.data.source.imports ?? []; ctx.graph.data.source.imports.push(importPath); + const ref = `@import:${ctx.graph.data.source.importRecords?.length ?? 0}`; + (ctx.graph.data.source.importRecords ??= []).push({ path: importPath, spread: head.startsWith('...@'), scopeId: scopePath.length ? nodeId(scopePath) : null, ref }); + if (scopePath.length) registerChildOrder(ctx, nodeId(scopePath), ref); + else (ctx.graph.data.order ??= []).push(ref); + if (comments.length) (ctx.graph.data.leadingComments ??= []).push(...comments); comments = []; continue; } @@ -621,6 +692,7 @@ function parseScope( const conn = findConnector(head); if (conn && scopePath.length >= 0) { handleConnection(ctx, head, tok.block, scopePath, comments); + if (tok.commentsAfter) ctx.edges[ctx.edges.length - 1].data.commentsAfter = tok.commentsAfter; comments = []; continue; } @@ -638,17 +710,27 @@ function parseScope( scopePath.length === 0 ) { handleScopeReserved(ctx, rawSegs, value, tok.block, scopePath); + if (comments.length) (ctx.graph.data.leadingComments ??= []).push(...comments); comments = []; continue; } handleDeclaration(ctx, rawSegs, value, tok.block, scopePath, comments); + const declared = ctx.nodes.get(nodeId(getResolvedPath(scopePath, rawSegs))); + if (declared && tok.commentsAfter) declared.data.commentsAfter = tok.commentsAfter; + if (scopePath.length === 0 && declared) { + const order = ctx.graph.data.order ??= []; + if (!order.includes(declared.id)) order.push(declared.id); + } comments = []; } // Leftover comments → attach to graph trailing (only at root) if (scopePath.length === 0 && comments.length > 0) { ctx.graph.data.trailingComments = comments; + } else if (comments.length) { + const parent = ctx.nodes.get(nodeId(scopePath)); + if (parent) parent.data.trailingComments = comments; } } @@ -693,32 +775,59 @@ function handleScopeReserved( } } +function getSourceValue(value: string): any { + const text = value.trim(); + if (text.startsWith('[') && text.endsWith(']')) { + const items: any[] = []; + const body = text.slice(1, -1); + let start = 0; + let depth = 0; + for (let index = 0; index <= body.length; index++) { + if (body[index] === '"' || body[index] === "'") { index = skipQuoted(body, index); continue; } + if (body[index] === '[') depth++; + if (body[index] === ']') depth--; + if (index === body.length || (depth === 0 && (body[index] === ';' || body[index] === '\n'))) { + const item = body.slice(start, index).trim(); + if (item) items.push(getSourceValue(item)); + start = index + 1; + } + } + return items; + } + if (/^["']/.test(text)) return unquoteD2(text); + if (text === 'true') return true; + if (text === 'false') return false; + if (text === 'null') return null; + if (/^-?\d+(?:\.\d+)?$/.test(text)) return Number(text); + return text; +} + function parseVarsBlock(block: string): Record { - const out: Record = {}; + const out: Record = Object.create(null); for (const tok of splitStatements(block)) { if (tok.type !== 'stmt') continue; const kv = splitKeyValue(tok.head); - if (kv) out[kv.key] = unquoteD2(kv.value); + if (kv) out[unquoteD2(kv.key)] = tok.block === undefined ? getSourceValue(kv.value) : parseVarsBlock(tok.block); } return out; } function parseClassesBlock( block: string, -): Record> { - const out: Record> = {}; +): Record> { + const out: Record> = Object.create(null); for (const tok of splitStatements(block)) { if (tok.type !== 'stmt' || tok.block === undefined) continue; const kv = splitKeyValue(tok.head); const className = (kv ? kv.key : tok.head).trim(); - const style: Record = {}; + const style: Record = Object.create(null); for (const inner of splitStatements(tok.block)) { if (inner.type !== 'stmt') continue; const ikv = splitKeyValue(inner.head); if (!ikv) continue; - const segs = parseKeyPath(ikv.key); - const styleKey = segs[0] === 'style' ? segs[1] : segs[0]; - style[styleKey] = coerceStyleValue(styleKey, ikv.value); + const segs = unquotePath(parseKeyPath(ikv.key)); + const styleKey = segs[0] === 'style' && segs.length > 1 ? segs[1] : segs[0]; + style[styleKey] = inner.block === undefined ? coerceStyleValue(styleKey, ikv.value) : parseVarsBlock(inner.block); } out[className] = style; } @@ -764,23 +873,22 @@ function handleConnection( parts.push({ text: rest.trim() }); for (let k = 0; k < arrows.length; k++) { - const sourcePath = unquotePath(parseKeyPath(parts[k].text)); - const targetPath = unquotePath(parseKeyPath(parts[k + 1].text)); - const fullSource = [...scopePath, ...sourcePath]; - const fullTarget = [...scopePath, ...targetPath]; + const fullSource = getResolvedPath(scopePath, parseKeyPath(parts[k].text)); + const fullTarget = getResolvedPath(scopePath, parseKeyPath(parts[k + 1].text)); const edge = makeEdge( ctx, fullSource, fullTarget, arrows[k], - k === arrows.length - 1 ? label : null, - k === arrows.length - 1 ? labelBlock : undefined, + label, + labelBlock, ); + if (kv && /^["']/.test(kv.value)) { edge.data.labelSource = kv.value; edge.data.sourceLabel = label ?? ''; } if (comments.length > 0 && k === 0) edge.data.commentsBefore = comments; ctx.edges.push(edge); registerChildOrder(ctx, scopePath.length ? nodeId(scopePath) : null, edge.id); // edge style/arrowhead block - if (block !== undefined && k === arrows.length - 1) { + if (block !== undefined) { applyEdgeBlock(edge, block); } } @@ -806,8 +914,19 @@ function applyEdgeBlock(edge: GraphEdge, block: string): void { } else if (segs[0] === 'source-arrowhead' || segs[0] === 'target-arrowhead') { const side = segs[0] === 'source-arrowhead' ? 'sourceArrowhead' : 'targetArrowhead'; edge.data[side] = edge.data[side] ?? {}; - if (segs[1] === 'shape') edge.data[side]!.shape = unquoteD2(kv.value); - else edge.data[side]!.label = unquoteD2(kv.value); + const arrowhead = edge.data[side]!; + if (segs[1] === 'shape') arrowhead.shape = unquoteD2(kv.value); + else if (segs[1] === 'style' && segs[2]) (arrowhead.style ??= {})[segs[2]] = coerceStyleValue(segs[2], kv.value); + else if (segs[1] === 'label' || segs.length === 1) { + if (kv.value) arrowhead.label = unquoteD2(kv.value); + if (tok.block !== undefined) { + const attributes = parseVarsBlock(tok.block); + if (attributes.shape !== undefined) arrowhead.shape = attributes.shape; + if (attributes.label !== undefined) arrowhead.label = attributes.label; + if (attributes.style) arrowhead.style = Object.fromEntries(Object.entries(attributes.style).map(([key, value]) => [key, coerceStyleValue(key, String(value))])); + arrowhead.attributes = Object.fromEntries(Object.entries(attributes).filter(([key]) => !['shape', 'label', 'style'].includes(key))); + } + } } else if (segs[0] === 'label') { edge.label = unquoteD2(kv.value); } else { @@ -827,7 +946,7 @@ function handleDeclaration( ): void { // Node-id segments (quotes stripped); scopePath is already unquoted. const segs = unquotePath(rawSegs); - const fullPath = [...scopePath, ...segs]; + const fullPath = getResolvedPath(scopePath, rawSegs); // If the leading segment(s) form a node path and the trailing is a reserved // attribute, find the split. Quoted segments are literal ids, never reserved. @@ -846,9 +965,13 @@ function handleDeclaration( if (comments.length > 0 && !node.data.commentsBefore) { node.data.commentsBefore = comments; } - applyReserved(node, rawSegs[reservedAt], segs.slice(reservedAt + 1), value); + if (!(['vars', 'classes'].includes(rawSegs[reservedAt]) && block !== undefined)) applyReserved(node, rawSegs[reservedAt], segs.slice(reservedAt + 1), value); if (rawSegs[reservedAt] === 'style' && block !== undefined) { applyStyleBlock(node, block); + } else if (rawSegs[reservedAt] === 'vars' && block !== undefined) { + (node.data.source ??= {}).vars = parseVarsBlock(block); + } else if (rawSegs[reservedAt] === 'classes' && block !== undefined) { + (node.data.source ??= {}).classes = parseClassesBlock(block); } return; } @@ -864,6 +987,7 @@ function handleDeclaration( const { text, labelBlock } = parseLabelBlock(value); node.label = text; if (labelBlock) node.data.labelBlock = labelBlock; + if (/^["']/.test(value)) { node.data.labelSource = value; node.data.sourceLabel = text; } } if (block !== undefined) { @@ -977,5 +1101,5 @@ export function fromD2(input: string): D2Graph { } } - return graph; + return getD2ScalarGraph(graph); } diff --git a/src/formats/d2/shared.ts b/src/formats/d2/shared.ts index 31268e8..171de9f 100644 --- a/src/formats/d2/shared.ts +++ b/src/formats/d2/shared.ts @@ -87,9 +87,12 @@ export interface D2Source { /** `vars: { ... }` blocks, stored as nested key/value maps. */ vars?: Record; /** `classes: { name: { ...style } }` definitions. */ - classes?: Record>; + classes?: Record>; /** `@path` import references in declaration order. */ imports?: string[]; + importRecords?: Array<{ path: string; spread: boolean; scopeId: string | null; ref: string }>; + /** Unsupported language constructs retained for inspection, not applied to topology. */ + unknownStatements?: string[]; } export interface D2GraphData { @@ -99,9 +102,18 @@ export interface D2GraphData { leadingComments?: string[]; /** Comments after the last statement. */ trailingComments?: string[]; + order?: string[]; } export interface D2NodeData { + labelSource?: string; + sourceLabel?: string; + /** Authored key segments, guarded by sourceId so later ID edits take precedence. */ + keyPath?: string[]; + sourceId?: string; + source?: D2Source; + trailingComments?: string[]; + commentsAfter?: string[]; /** Whether the author declared this node via dot-path or a `{ }` block. */ declarationForm?: 'dot' | 'block'; /** Relative positioning keyword/target (`near: top-center`, `near: a.b`). */ @@ -131,9 +143,13 @@ export interface D2NodeData { export interface D2ArrowheadSpec { shape?: string; label?: string; + style?: Record; + attributes?: Record; } export interface D2EdgeData { + labelSource?: string; + sourceLabel?: string; /** Authored connector glyph, for faithful re-emit (incl. reversed `<-`). */ arrow: D2Arrow; sourceArrowhead?: D2ArrowheadSpec; @@ -141,10 +157,13 @@ export interface D2EdgeData { classes?: string[]; labelBlock?: D2LabelBlock; commentsBefore?: string[]; + commentsAfter?: string[]; reserved?: Record; } export interface D2PortData { + commentsBefore?: string[]; + attributes?: Record; /** SQL column type (`int`, `varchar`, ...) or class member type. */ typeName?: string; /** SQL constraints (`primary_key`, `foreign_key`, `unique`). */ @@ -163,19 +182,40 @@ const SAFE_ID = /^[A-Za-z0-9_][A-Za-z0-9_ -]*$/; /** Quote a d2 key/id if it contains characters that need escaping. */ export function escapeD2Key(id: string): string { + getD2ScalarString(id); if (id === '') return '""'; // Quote reserved keywords so a node literally named e.g. `shape` round-trips // as a node id rather than being re-parsed as a reserved attribute. if (RESERVED_KEYWORDS.has(id)) return `"${id}"`; if (SAFE_ID.test(id) && !id.includes(' ')) return id; - return `"${id.replace(/"/g, '\\"')}"`; + return JSON.stringify(id); } +/** D2 accepts NUL labels, but its UTF-8 compiler cannot preserve lone surrogates. */ +export function getD2ScalarString(value: string): string { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(++index); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new Error('D2: wire labels/IDs require Unicode scalar strings'); + } else if (code >= 0xdc00 && code <= 0xdfff) throw new Error('D2: wire labels/IDs require Unicode scalar strings'); + } + return value; +} +export function getD2ScalarGraph(graph: D2Graph): D2Graph { + for (const node of graph.nodes) { + getD2ScalarString(node.id); if (node.label != null) getD2ScalarString(node.label); + for (const port of node.ports ?? []) { getD2ScalarString(port.name); if (port.label !== undefined) getD2ScalarString(port.label); if (typeof port.data?.typeName === 'string') getD2ScalarString(port.data.typeName); } + } + for (const edge of graph.edges) { getD2ScalarString(edge.sourceId); getD2ScalarString(edge.targetId); if (edge.label != null) getD2ScalarString(edge.label); } + return graph; +} /** Quote a d2 label value if needed (contains `:`, `#`, leading/trailing space, etc.). */ export function escapeD2Label(label: string): string { + getD2ScalarString(label); if (label === '') return '""'; if (/[:;#{}|<>"\n]/.test(label) || label !== label.trim()) { - return `"${label.replace(/"/g, '\\"')}"`; + return JSON.stringify(label); } return label; } @@ -184,7 +224,7 @@ export function escapeD2Label(label: string): string { export function unquoteD2(s: string): string { const t = s.trim(); if (t.length >= 2 && t[0] === '"' && t[t.length - 1] === '"') { - return t.slice(1, -1).replace(/\\"/g, '"'); + try { return JSON.parse(t); } catch { return t.slice(1, -1).replace(/\\(["\\])/g, '$1'); } } if (t.length >= 2 && t[0] === "'" && t[t.length - 1] === "'") { return t.slice(1, -1); diff --git a/src/formats/d3/README.md b/src/formats/d3/README.md index 78d1ecf..a5bb554 100644 --- a/src/formats/d3/README.md +++ b/src/formats/d3/README.md @@ -2,6 +2,24 @@ Converter for [D3.js](https://d3js.org/) force-directed graph JSON — the `{ nodes, links }` format used by `d3-force`. +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **partial**. +Source document preservation: **none**. These are separate guarantees; +selected-field round-trip tests do not prove unrestricted losslessness. + +Numeric endpoints resolve as node indices and are rebased after node edits. String IDs and live node references are supported. Imported native properties, including fixed positions and velocities, are retained in a JSON snapshot; serializable node lineage preserves native attributes across renames. New replacement nodes require their own attributes. Null labels, independent geometry and Graph field presence use versioned metadata. + + + + +Native snapshots use a private JSON convention on Graph data. Import options +`{ metadata: 'native' }` disable adapter marker interpretation. A foreign value +that deliberately matches the marker schema remains ambiguous in automatic mode. +Comments, lexical formatting and source locations are not preserved. + ## Resources - [d3-force documentation](https://d3js.org/d3-force) @@ -54,11 +72,11 @@ const imported = fromD3Graph({ // edges: - (app -> auth) - (app -> store) - (auth -> store) ``` -Handles D3's mutated link objects where `source`/`target` may be node objects (after simulation) or strings. +Handles D3's mutated link objects where `source`/`target` may be node objects (after simulation) strings, or numeric node indices. -Also preserves graph `id`, `mode`, `initialNodeId`, `data`, `direction`, `style`, node hierarchy/layout/style fields, and edge layout/style/weight fields through the loose `{ nodes, links }` object shape. Ports and edge port references round-trip as `ports`, `sourcePort`, and `targetPort`. +Also maps selected graph `id`, `mode`, `initialNodeId`, `data`, `direction`, `style`, node hierarchy/layout/style fields, and edge layout/style/weight fields through the loose `{ nodes, links }` object shape. Ports and edge port references round-trip as `ports`, `sourcePort`, and `targetPort`. ### `d3Converter` @@ -75,4 +93,23 @@ const graph = d3Converter.from(d3); |------|-------------| | `D3Graph` | `{ nodes: D3Node[], links: D3Link[] }` | | `D3Node` | `{ id: string, [key: string]: any }` | -| `D3Link` | `{ source: string, target: string, [key: string]: any }` | +| `D3Link` | String IDs, numeric indices or live node objects as endpoints | +| `D3ImportOptions` | Reserved metadata interpretation override | + + +## Native documents + + + +`d3DocumentConverter.from(source)` creates a plain JSON document with the +original source and a separate Graph projection. `.to(document)` preserves exact +source when unchanged, including after JSON serialization. Supported edits patch +verified native fields; unsupported edits throw with diagnostics. `.getUpdate(document)` +returns a diagnostic-bearing result. Native semantic projection remains partial. + +Labels, Graph data, styles, weights and mapped geometry use direct native fields. Node/edge IDs, endpoints, hierarchy and array order edits reject. Numeric and object endpoint tokens retain their original source representation. Node edits that would leave duplicated fields in embedded object endpoints stale reject: runtime alias identity is outside the JSON source domain. ID-only object endpoints support independent node edits. + +Bare exports interpret recognized import carriers by default. Use +`{ nativeCarrier: false }` when Graph data is user-authored and could match the +private carrier schema. Document projection pins importer state to its external +source before unwrapping it, preserving foreign lookalikes. diff --git a/src/formats/d3/index.ts b/src/formats/d3/index.ts index 3e0c237..f2e222e 100644 --- a/src/formats/d3/index.ts +++ b/src/formats/d3/index.ts @@ -1,16 +1,22 @@ +import type { FormatExportOptions } from '../preservation'; +import { createGraphDocumentConverter } from '../document'; +import { getStructuredDocumentProjectionGraph, getStructuredDocumentPatch } from '../preservation'; import type { Graph, GraphFormatConverter } from '../../types'; import { createFormatConverter } from '../converter'; - -// --- D3.js force-directed JSON types --- +import { createMetadata, createNativeCarrier, getCarrierData, getClonedJSON, getMetadata, + getNativeCarrier, getRestoredRecord, getMergedNative, type FormatImportOptions } from '../preservation'; +import { isJSONEqual } from '../preservation'; export interface D3Node { id: string; + x?: number; y?: number; vx?: number; vy?: number; + fx?: number | null; fy?: number | null; [key: string]: any; } export interface D3Link { - source: string; - target: string; + source: string | number | D3Node; + target: string | number | D3Node; [key: string]: any; } @@ -26,7 +32,43 @@ export interface D3Graph { [key: string]: any; } -// --- Conversion --- +export interface D3ImportOptions extends FormatImportOptions {} + +const NODE_FIELDS = ['x', 'y', 'width', 'height', 'color', 'shape', 'style', 'ports']; +const EDGE_FIELDS = ['mode', 'weight', 'points', 'routing', 'x', 'y', 'width', 'height', 'color', 'style', 'sourcePort', 'targetPort']; + +function getFields(value: Record, fields: string[]): Record { + return Object.fromEntries(fields.filter((key) => Object.hasOwn(value, key)).map((key) => [key, value[key]])); +} + +function getD3Root(d3: D3Graph): Omit { + return { id: d3.id ?? '', mode: d3.mode ?? 'directed', initialNodeId: d3.initialNodeId ?? null, + data: d3.data, ...getFields(d3, ['direction', 'style']) }; +} + +function getD3Node(node: D3Node): Graph['nodes'][number] { + return { type: 'node', id: node.id, parentId: node.parentId ?? null, + initialNodeId: node.initialNodeId ?? null, label: Object.hasOwn(node, 'label') ? node.label : '', + data: node.data, ...getFields(node, NODE_FIELDS) }; +} + +function getD3Endpoint(value: D3Link['source'], nodes: D3Node[]): string { + const id = typeof value === 'number' + ? (Number.isInteger(value) && value >= 0 ? nodes[value]?.id : undefined) + : typeof value === 'string' ? value : value?.id; + // Numeric endpoints are array indices. String IDs and object IDs remain + // transparent, including dangling IDs supported by the Graph model. + if (typeof id !== 'string') { + throw new Error(`D3: invalid endpoint or node index ${JSON.stringify(value)}`); + } + return id; +} + +function getD3Edge(link: D3Link, index: number, nodes: D3Node[]): Graph['edges'][number] { + return { type: 'edge', id: link.id ?? `e${index}`, sourceId: getD3Endpoint(link.source, nodes), + targetId: getD3Endpoint(link.target, nodes), label: Object.hasOwn(link, 'label') ? link.label : '', + data: link.data, ...getFields(link, EDGE_FIELDS) }; +} /** * Converts a graph to D3.js force-directed format. @@ -45,122 +87,95 @@ export interface D3Graph { * // { nodes: [{ id: 'a' }, { id: 'b' }], links: [{ source: 'a', target: 'b' }] } * ``` */ -export function toD3Graph(graph: Graph): D3Graph { - return { - ...(graph.id && { id: graph.id }), - mode: graph.mode, - initialNodeId: graph.initialNodeId, - ...(graph.data !== undefined && { data: graph.data }), - ...(graph.direction && { direction: graph.direction }), - ...(graph.style !== undefined && { style: graph.style }), - nodes: graph.nodes.map((n) => { - const node: D3Node = { id: n.id }; - if (n.parentId) node.parentId = n.parentId; - if (n.initialNodeId) node.initialNodeId = n.initialNodeId; - if (n.label) node.label = n.label; - if (n.data !== undefined) node.data = n.data; - if (n.x !== undefined) node.x = n.x; - if (n.y !== undefined) node.y = n.y; - if (n.width !== undefined) node.width = n.width; - if (n.height !== undefined) node.height = n.height; - if (n.color) node.color = n.color; - if (n.shape) node.shape = n.shape; - if (n.style !== undefined) node.style = n.style; - if (n.ports !== undefined) node.ports = n.ports; - return node; +export interface D3ExportOptions extends FormatExportOptions {} + +export function toD3Graph(graph: Graph, options: D3ExportOptions = {}): D3Graph { + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, 'd3'); + if (carrier) { + const original = getClonedJSON(carrier.native) as D3Graph; + const baseline = toRawD3Graph(carrier.baseline); + const originalById = new Map(original.nodes.map((node) => [node.id, node])); + const beforeById = new Map(baseline.nodes.map((node) => [node.id, node])); + const lineage = new Map(); + for (const node of graph.nodes) { + const marker = (node as any).__statelyaiD3Node; + if (marker?.kind === '@statelyai/graph/d3-node' && marker.version === 1 && + typeof marker.id === 'string' && originalById.has(marker.id)) { + if (lineage.has(marker.id)) throw new Error('D3: duplicate native node lineage'); + lineage.set(marker.id, node.id); + } + } + for (const [id, currentId] of lineage) { + originalById.get(id)!.id = currentId; + if (beforeById.has(id)) beforeById.get(id)!.id = currentId; + } + const native = getMergedNative(original, baseline, + toRawD3Graph({ ...graph, data: getCarrierData(graph.data, 'd3') })) as D3Graph; + const indices = new Map(graph.nodes.map((node, index) => [node.id, index])); + const sourceNodes = new Map(carrier.native.nodes.map((node: D3Node) => [node.id, node])); + const sourceLinks = new Map(carrier.baseline.edges.map((edge: Graph['edges'][number], index: number) => [edge.id, carrier.native.links[index]])); + const references = (carrier as any).objectEndpoints ?? {}; + native.links.forEach((link, index) => { + const edge = graph.edges[index]; + const original = sourceLinks.get(edge.id) as D3Link | undefined; + for (const [key, id] of [['source', edge.sourceId], ['target', edge.targetId]] as const) { + const originalEndpoint = original?.[key]; + if (typeof originalEndpoint === 'number') link[key] = indices.get(id) ?? id; + else if (originalEndpoint && typeof originalEndpoint === 'object') { + const position = indices.get(id); + const currentNode = position !== undefined ? native.nodes[position] : { id }; + if (references[edge.id]?.[key] && position !== undefined) link[key] = currentNode; + else link[key] = getMergedNative(originalEndpoint, sourceNodes.get(originalEndpoint.id) ?? {}, currentNode); + } + } + }); + return native; + } + return toRawD3Graph(graph); +} + +function toRawD3Graph(graph: Graph): D3Graph { + const { nodes, edges, ...root } = graph; + const d3: D3Graph = { + ...getClonedJSON(root), + nodes: nodes.map((node) => { + const { type: _type, __statelyaiD3Node: _lineage, ...native } = getClonedJSON(node) as any; + return { ...native, __statelyai: createMetadata('d3.node', Object.fromEntries(Object.entries(node).filter(([key]) => key !== '__statelyaiD3Node')), getD3Node(native)) }; }), - links: graph.edges.map((e) => { - const link: D3Link = { - source: e.sourceId, - target: e.targetId, - }; - if (e.id) link.id = e.id; - if (e.label) link.label = e.label; - if (e.mode) link.mode = e.mode; - if (e.data !== undefined) link.data = e.data; - if (e.weight !== undefined) link.weight = e.weight; - if (e.points !== undefined) link.points = e.points; - if (e.routing !== undefined) link.routing = e.routing; - if (e.x !== undefined) link.x = e.x; - if (e.y !== undefined) link.y = e.y; - if (e.width !== undefined) link.width = e.width; - if (e.height !== undefined) link.height = e.height; - if (e.color) link.color = e.color; - if (e.style !== undefined) link.style = e.style; - if (e.sourcePort !== undefined) link.sourcePort = e.sourcePort; - if (e.targetPort !== undefined) link.targetPort = e.targetPort; - return link; + links: edges.map((edge, index) => { + const { type: _type, sourceId, targetId, ...fields } = getClonedJSON(edge); + const native: D3Link = { ...fields, source: sourceId, target: targetId }; + return { ...native, __statelyai: createMetadata('d3.edge', edge, getD3Edge(native, index, nodes)) }; }), }; + d3.__statelyai = createMetadata('d3.graph', root, getD3Root(d3)); + return d3; } -/** - * Parses a D3.js force-directed JSON object into a graph. - * - * @example - * ```ts - * import { fromD3Graph } from '@statelyai/graph/d3'; - * - * const graph = fromD3Graph({ - * nodes: [{ id: 'a' }, { id: 'b' }], - * links: [{ source: 'a', target: 'b' }], - * }); - * ``` - */ -export function fromD3Graph(d3: D3Graph): Graph { - if (!d3 || typeof d3 !== 'object') { - throw new Error('D3: expected an object'); - } - if (!Array.isArray(d3.nodes)) { - throw new Error('D3: "nodes" must be an array'); - } - if (!Array.isArray(d3.links)) { - throw new Error('D3: "links" must be an array'); - } - return { - id: d3.id ?? '', - mode: d3.mode ?? 'directed', - initialNodeId: d3.initialNodeId ?? null, - data: d3.data, - ...(d3.direction && { direction: d3.direction }), - ...(d3.style !== undefined && { style: d3.style }), - nodes: d3.nodes.map((n) => ({ - type: 'node' as const, - id: n.id, - parentId: n.parentId ?? null, - initialNodeId: n.initialNodeId ?? null, - label: n.label ?? '', - data: n.data, - ...(n.x !== undefined && { x: n.x }), - ...(n.y !== undefined && { y: n.y }), - ...(n.width !== undefined && { width: n.width }), - ...(n.height !== undefined && { height: n.height }), - ...(n.color && { color: n.color }), - ...(n.shape && { shape: n.shape }), - ...(n.style !== undefined && { style: n.style }), - ...(n.ports !== undefined && { ports: n.ports }), - })), - edges: d3.links.map((l, i) => ({ - type: 'edge' as const, - id: l.id ?? `e${i}`, - sourceId: typeof l.source === 'string' ? l.source : (l.source as any).id, - targetId: typeof l.target === 'string' ? l.target : (l.target as any).id, - label: l.label ?? '', - ...(l.mode && { mode: l.mode }), - data: l.data, - ...(l.weight !== undefined && { weight: l.weight }), - ...(l.points !== undefined && { points: l.points }), - ...(l.routing !== undefined && { routing: l.routing }), - ...(l.x !== undefined && { x: l.x }), - ...(l.y !== undefined && { y: l.y }), - ...(l.width !== undefined && { width: l.width }), - ...(l.height !== undefined && { height: l.height }), - ...(l.color && { color: l.color }), - ...(l.style !== undefined && { style: l.style }), - ...(l.sourcePort !== undefined && { sourcePort: l.sourcePort }), - ...(l.targetPort !== undefined && { targetPort: l.targetPort }), - })), +/** Import numeric indices, string IDs and live D3 node references. */ +export function fromD3Graph(d3: D3Graph, options: D3ImportOptions = {}): Graph { + if (!d3 || typeof d3 !== 'object') throw new Error('D3: expected an object'); + if (!Array.isArray(d3.nodes)) throw new Error('D3: "nodes" must be an array'); + if (!Array.isArray(d3.links)) throw new Error('D3: "links" must be an array'); + const getWireMetadata = (value: any, format: string) => options.metadata === 'native' ? undefined : getMetadata(value, format); + const metadata = getWireMetadata(d3.__statelyai, 'd3.graph'); + const graph: Graph = { + ...getRestoredRecord(getD3Root(d3), metadata), + nodes: d3.nodes.map((node) => getRestoredRecord(getD3Node(node), getWireMetadata(node.__statelyai, 'd3.node'))), + edges: d3.links.map((link, index) => getRestoredRecord(getD3Edge(link, index, d3.nodes), getWireMetadata(link.__statelyai, 'd3.edge'))), }; + if (!metadata || !isJSONEqual(d3, toRawD3Graph(graph))) { + graph.nodes.forEach((node) => { + (node as any).__statelyaiD3Node = { kind: '@statelyai/graph/d3-node', version: 1, id: node.id }; + }); + graph.data = createNativeCarrier(graph.data, 'd3', d3, graph); + const nativeNodes = new Set(d3.nodes); + graph.data.__statelyaiNative.objectEndpoints = Object.fromEntries(graph.edges.map((edge, index) => [edge.id, { + source: typeof d3.links[index].source === 'object' && nativeNodes.has(d3.links[index].source as D3Node), + target: typeof d3.links[index].target === 'object' && nativeNodes.has(d3.links[index].target as D3Node), + }])); + } + return graph; } /** @@ -182,3 +197,12 @@ export function fromD3Graph(d3: D3Graph): Graph { */ export const d3Converter: GraphFormatConverter = createFormatConverter(toD3Graph, fromD3Graph); + + +/** Exact native source preservation, with strict verified field edits. */ +export const d3DocumentConverter = createGraphDocumentConverter({ + format: 'd3', + codec: { from: (source: D3Graph) => fromD3Graph(source, { metadata: 'native' }), to: (graph: Graph) => toD3Graph(graph, { nativeCarrier: false }) }, + project: (source: D3Graph) => ({ graph: getStructuredDocumentProjectionGraph(fromD3Graph(source, { metadata: 'native' }), source, 'd3') }), + patch: (context) => getStructuredDocumentPatch(context, 'd3'), +}); diff --git a/src/formats/document/README.md b/src/formats/document/README.md new file mode 100644 index 0000000..4042bc0 --- /dev/null +++ b/src/formats/document/README.md @@ -0,0 +1,39 @@ +# Graph documents + +Exact native source retention with a separate, possibly partial Graph projection. Existing format converters still return Graph. + + + +```ts +import { createGraphDocumentConverter, getGraphDocumentUpdate } from '@statelyai/graph/document'; +import { graphMLDocumentConverter } from '@statelyai/graph/graphml'; + +const document = graphMLDocumentConverter.from(xml); +const persisted = JSON.parse(JSON.stringify(document)); +const original = graphMLDocumentConverter.to(persisted); // exact original text + +if (document.graph) document.graph.nodes[0].label = 'Updated'; +const update = getGraphDocumentUpdate(document, graphMLDocumentConverter); +if (update.ok) console.log(update.source); +else console.log(update.diagnostics); +``` + +`GraphDocument` contains `kind`, `version`, `format`, `source`, `graph`, `baseline`, `diagnostics` and optional adapter `state`. `graph` can be null when projection is unavailable. The envelope, source and state remain JSON-serializable. Graph and VisualGraph codec types are inferred without weakening their fields. + + + +Source must be text or JSON values: finite numbers, strings, booleans, null, dense arrays and plain objects. Invalid values are rejected before anything is silently dropped. Runtime object aliases/prototypes are outside the source contract. Graph optional undefined object fields compare using their JSON view. + +Unchanged documents reproduce exact text or native JSON values after persistence, including comments and unknown constructs. Exact retention does not prove complete semantic projection. Each import reports projection limitations; unavailable projections retain source and diagnostics. `.from(source, { strict: true })` throws for unavailable/error projections. + +Source edits win when Graph is unchanged. Independent Graph/native edits merge by stable entity ID; edits to the same mapped field conflict. Object-valued fields currently conflict as whole fields. Restoring an unchanged Graph snapshot never overwrites a newer native source edit. + +`.to(document)` throws an ordinary Error with `.diagnostics` when an edit is unsupported, ambiguous, conflicting, or fails reparse proof. Edit `document.source` to change syntax outside the adapter's verified patch scope. Adapter state/projection provenance are not editable user fields. + +Explicit regeneration is available through `getGraphDocumentUpdate(document, converter, { mode: 'regenerate' })`. Successful regeneration returns a `lossy-regeneration` warning because source details and requested Graph fields may be lost. Regenerated source is reparsed; `graph-regeneration-loss` warnings identify differing Graph field paths, and `graph-regeneration-unavailable` reports an unavailable comparison/projection. A successful result in this explicit mode does not promise semantic fidelity. `.to` always stays strict. + + + +Custom registrations pass `{ format, codec: { from, to }, project?, patch?, getComparisonGraph?, getComparisonState? }` to `createGraphDocumentConverter`. The factory loads no codecs or optional peers. `project` can provide diagnostics/state and a null projection. Patch context contains cloned current/baseline source, normalized baseline/current Graphs, requested changes and a merged Graph. Changes use JSON pointers with stable escaped entity IDs, for example `/nodes/a~1b/label`. + +A successful patch is reparsed and compared with the merged requested Graph. Comparison hooks can remove only verified private provenance, retaining all editable user fields. If normalization changes an editable field's representation, `getComparisonState` must extract only private state to detect hidden state edits; otherwise the core rejects edits in that field conservatively. See [architecture decision](../../../docs/adr/003-serializable-native-documents.md). diff --git a/src/formats/document/index.ts b/src/formats/document/index.ts new file mode 100644 index 0000000..1f5ec1c --- /dev/null +++ b/src/formats/document/index.ts @@ -0,0 +1,322 @@ +import type { Graph } from '../../types'; + +export type JSONValue = null | boolean | number | string | JSONValue[] | { [key: string]: JSONValue }; + +export interface GraphDocumentDiagnostic { + code: string; + message: string; + path?: string; + severity: 'info' | 'warning' | 'error'; +} + +/** Exact native source and an explicitly separate, possibly partial Graph projection. */ +export interface GraphDocument { + kind: '@statelyai/graph/document'; + version: 1; + format: string; + source: TSource; + graph: TGraph | null; + baseline: { source: TSource; graph: TGraph | null }; + diagnostics: GraphDocumentDiagnostic[]; + state?: JSONValue; +} + +export interface GraphDocumentProjection { + graph: TGraph | null; + diagnostics?: GraphDocumentDiagnostic[]; + state?: JSONValue; +} + +export interface GraphDocumentChange { + entity: 'graph' | 'node' | 'edge'; + id?: string; + field: string; + /** JSON pointer using stable entity IDs instead of source-array offsets. */ + path: string; + before?: unknown; + after?: unknown; + beforePresent: boolean; + afterPresent: boolean; +} + +export interface GraphDocumentComparisonContext { + source: TSource; + baselineSource: TSource; + state?: JSONValue; +} + +export interface GraphDocumentPatchContext { + source: TSource; + baselineSource: TSource; + /** Fresh source projection with requested Graph changes merged into it. */ + graph: TGraph; + baselineGraph: TGraph; + sourceGraph: TGraph; + changes: GraphDocumentChange[]; + state?: JSONValue; +} + +export type GraphDocumentPatchResult = + | { ok: true; source: TSource; diagnostics?: GraphDocumentDiagnostic[] } + | { ok: false; diagnostics: GraphDocumentDiagnostic[] }; + +export interface GraphDocumentConverterConfig { + format: string; + codec: { from(source: TSource): TGraph; to(graph: TGraph): TSource }; + project?: (source: TSource) => GraphDocumentProjection; + /** Normalize only verified adapter provenance; never discard arbitrary user fields. */ + getComparisonGraph?: (graph: TGraph, context: GraphDocumentComparisonContext) => TGraph; + /** Extract verified private provenance only, so edits cannot hide behind user data changes. */ + getComparisonState?: (graph: TGraph, context: GraphDocumentComparisonContext) => JSONValue; + patch?: (context: GraphDocumentPatchContext) => GraphDocumentPatchResult; +} + +export interface GraphDocumentImportOptions { + /** Throw instead of retaining an unavailable projection or error diagnostic. */ + strict?: boolean; +} + +export interface GraphDocumentUpdateOptions { + /** Regeneration is explicit and diagnostic-bearing; converter.to always stays strict. */ + mode?: 'strict' | 'regenerate'; +} + +export interface GraphDocumentConverter { + format: string; + from(source: TSource, options?: GraphDocumentImportOptions): GraphDocument; + to(document: GraphDocument): TSource; + getUpdate(document: GraphDocument, options?: GraphDocumentUpdateOptions): GraphDocumentPatchResult; +} + +function getDiagnostic(code: string, message: string, path?: string): GraphDocumentDiagnostic { + return { code, message, severity: 'error', ...(path !== undefined && { path }) }; +} + +function getDocumentError(diagnostics: GraphDocumentDiagnostic[]): Error & { diagnostics: GraphDocumentDiagnostic[] } { + return Object.assign(new Error(diagnostics.map((item) => `${item.code}: ${item.message}`).join('; ')), { diagnostics }); +} + +/** Validate the wire domain before cloning; object undefined is allowed only for Graph views. */ +function getJSONClone(value: T, optionalUndefined = false): T { + const ancestors = new Set(); + const stack: Array<{ value: unknown; path: string; exit?: boolean; objectProperty?: boolean }> = [{ value, path: '' }]; + while (stack.length) { + const item = stack.pop()!; + const current = item.value; + if (item.exit) { ancestors.delete(current as object); continue; } + if (current === undefined && optionalUndefined && item.objectProperty) continue; + if (current === null || typeof current === 'string' || typeof current === 'boolean') continue; + if (typeof current === 'number' && Number.isFinite(current)) continue; + if (typeof current !== 'object' || current === null) throw getDocumentError([getDiagnostic('invalid-json-value', 'Source/state must contain only finite JSON values', item.path)]); + if (ancestors.has(current)) throw getDocumentError([getDiagnostic('invalid-json-value', 'Cyclic source/state cannot be serialized', item.path)]); + const array = Array.isArray(current); + if (!array && Object.getPrototypeOf(current) !== Object.prototype && Object.getPrototypeOf(current) !== null) { + throw getDocumentError([getDiagnostic('invalid-json-value', 'Class instances are outside the JSON source domain', item.path)]); + } + if (Object.getOwnPropertySymbols(current).length) throw getDocumentError([getDiagnostic('invalid-json-value', 'Symbol properties are outside the JSON source domain', item.path)]); + ancestors.add(current); + stack.push({ ...item, exit: true }); + const keys = Object.getOwnPropertyNames(current); + for (const key of keys) { + if (array && key === 'length') continue; + const descriptor = Object.getOwnPropertyDescriptor(current, key)!; + if (!descriptor.enumerable || !('value' in descriptor) || (array && !/^(?:0|[1-9]\d*)$/.test(key))) { + throw getDocumentError([getDiagnostic('invalid-json-value', 'Accessors/non-JSON properties cannot be preserved', `${item.path}/${key}`)]); + } + stack.push({ value: descriptor.value, path: `${item.path}/${key}`, objectProperty: !array }); + } + if (array && keys.length !== current.length + 1) throw getDocumentError([getDiagnostic('invalid-json-value', 'Sparse arrays are outside the JSON source domain', item.path)]); + } + return JSON.parse(JSON.stringify(value)); +} + +export function isGraphDocumentJSONEqual(a: unknown, b: unknown): boolean { + const stack: Array<[unknown, unknown]> = [[a, b]]; + while (stack.length) { + const [left, right] = stack.pop()!; + if (left === right) continue; + if (!left || !right || typeof left !== 'object' || typeof right !== 'object' || Array.isArray(left) !== Array.isArray(right)) return false; + const keys = Object.keys(left); + if (keys.length !== Object.keys(right).length) return false; + for (const key of keys) { + if (!Object.hasOwn(right, key)) return false; + stack.push([(left as Record)[key], (right as Record)[key]]); + } + } + return true; +} + +function getPointerToken(value: string): string { return value.replace(/~/g, '~0').replace(/\//g, '~1'); } + +function getGraphChanges(before: Graph, after: Graph): GraphDocumentChange[] { + const changes: GraphDocumentChange[] = []; + const addRecordChanges = (left: object, right: object, entity: GraphDocumentChange['entity'], id?: string) => { + for (const field of new Set([...Object.keys(left), ...Object.keys(right)])) { + if (entity === 'graph' && ['nodes', 'edges'].includes(field)) continue; + const beforePresent = Object.hasOwn(left, field); + const afterPresent = Object.hasOwn(right, field); + const previous = (left as Record)[field]; + const next = (right as Record)[field]; + if (beforePresent === afterPresent && isGraphDocumentJSONEqual(previous, next)) continue; + changes.push({ entity, ...(id !== undefined && { id }), field, + path: entity === 'graph' ? `/${getPointerToken(field)}` : `/${entity === 'node' ? 'nodes' : 'edges'}/${getPointerToken(id!)}/${getPointerToken(field)}`, + ...(beforePresent && { before: previous }), ...(afterPresent && { after: next }), beforePresent, afterPresent }); + } + }; + addRecordChanges(before, after, 'graph'); + for (const [collection, entity] of [['nodes', 'node'], ['edges', 'edge']] as const) { + const left = before[collection]; + const right = after[collection]; + const leftIds = left.map((item) => item.id); + const rightIds = right.map((item) => item.id); + if (new Set(leftIds).size !== leftIds.length || new Set(rightIds).size !== rightIds.length) throw getDocumentError([getDiagnostic('invalid-projection', `Duplicate ${entity} IDs prevent stable document updates`)]); + if (!isGraphDocumentJSONEqual(leftIds, rightIds)) changes.push({ entity: 'graph', field: collection, path: `/${collection}`, before: leftIds, after: rightIds, beforePresent: true, afterPresent: true }); + const leftById = new Map(left.map((item) => [item.id, item])); + for (const item of right) { + const previous = leftById.get(item.id); + if (previous) addRecordChanges(previous, item, entity, item.id); + } + } + return changes; +} + +function getMergedGraph(native: TGraph, requested: TGraph, changes: GraphDocumentChange[]): TGraph { + const merged = getJSONClone(native, true); + const entityMaps = { node: new Map(merged.nodes.map((item) => [item.id, item])), edge: new Map(merged.edges.map((item) => [item.id, item])) }; + for (const change of changes) { + if (change.entity === 'graph' && (change.field === 'nodes' || change.field === 'edges')) { + const collection = change.field; + const currentById = new Map(merged[collection].map((entity) => [entity.id, entity])); + const requestedById = new Map(requested[collection].map((entity) => [entity.id, entity])); + (merged as Graph)[collection] = (change.after as string[]).map((id) => currentById.get(id) ?? requestedById.get(id)!) as never; + const entity = collection === 'nodes' ? 'node' : 'edge'; + entityMaps[entity] = new Map(merged[collection].map((item) => [item.id, item])) as never; + continue; + } + const record = change.entity === 'graph' ? merged : entityMaps[change.entity].get(change.id!); + if (!record) throw getDocumentError([getDiagnostic('conflicting-edit', 'The native source removed an edited entity', change.path)]); + if (change.afterPresent) Object.defineProperty(record, change.field, { value: getJSONClone(change.after), enumerable: true, configurable: true, writable: true }); + else delete (record as unknown as Record)[change.field]; + } + return merged; +} + +/** Create an explicit codec registration; this module never eagerly imports native adapters. */ +export function createGraphDocumentConverter(config: GraphDocumentConverterConfig): GraphDocumentConverter { + if (!config.format.trim()) throw new Error('Graph document format must be non-empty'); + const getProjection = (source: TSource): GraphDocumentProjection => { + try { + const projection = config.project ? config.project(getJSONClone(source)) : { graph: config.codec.from(getJSONClone(source)) }; + const diagnostics = getJSONClone(projection.diagnostics ?? []); + if (projection.graph === null && !diagnostics.some((item) => item.severity === 'error')) diagnostics.push(getDiagnostic('projection-unavailable', 'Native source has no supported Graph projection')); + if (projection.graph && !diagnostics.some((item) => item.code.startsWith('projection-'))) diagnostics.push({ code: 'projection-partial', message: 'Exact source retention does not establish complete native semantic projection', severity: 'warning' }); + return { graph: projection.graph === null ? null : getJSONClone(projection.graph, true), + diagnostics, + ...(projection.state !== undefined && { state: getJSONClone(projection.state) }) }; + } catch (error) { + return { graph: null, diagnostics: [getDiagnostic('projection-unavailable', error instanceof Error ? error.message : String(error))] }; + } + }; + const getComparison = (graph: TGraph, source: TSource, baselineSource: TSource, state?: JSONValue): TGraph => getJSONClone(config.getComparisonGraph + ? config.getComparisonGraph(getJSONClone(graph, true), { source: getJSONClone(source), baselineSource: getJSONClone(baselineSource), ...(state !== undefined && { state: getJSONClone(state) }) }) + : graph, true); + + const getUpdate = (document: GraphDocument, options: GraphDocumentUpdateOptions = {}): GraphDocumentPatchResult => { + try { + if (document?.kind !== '@statelyai/graph/document' || document.version !== 1 || document.format !== config.format) return { ok: false, diagnostics: [getDiagnostic('invalid-document', 'Document kind/version/format does not match this converter')] }; + const source = getJSONClone(document.source); + const baselineSource = getJSONClone(document.baseline.source); + const graph = document.graph === null ? null : getJSONClone(document.graph, true); + const baselineGraph = document.baseline.graph === null ? null : getJSONClone(document.baseline.graph, true); + if (document.state !== undefined) getJSONClone(document.state); + const baselineProjection = getProjection(baselineSource); + if (!isGraphDocumentJSONEqual(document.state, baselineProjection.state)) return { ok: false, diagnostics: [getDiagnostic('unsupported-state-edit', 'Adapter state must match the baseline native source; edit document.source instead')] }; + if (baselineGraph && baselineProjection.graph && config.getComparisonState) { + const context = { source: baselineSource, baselineSource, ...(document.state !== undefined && { state: document.state }) }; + if (!isGraphDocumentJSONEqual(getJSONClone(config.getComparisonState(getJSONClone(baselineGraph, true), getJSONClone(context))), getJSONClone(config.getComparisonState(getJSONClone(baselineProjection.graph, true), getJSONClone(context))))) return { ok: false, diagnostics: [getDiagnostic('invalid-baseline', 'Stored private projection state does not match its baseline source')] }; + } + if ((baselineProjection.graph === null) !== (baselineGraph === null)) return { ok: false, diagnostics: [getDiagnostic('invalid-baseline', 'Stored projection availability does not match its baseline source')] }; + if (baselineGraph && baselineProjection.graph && !isGraphDocumentJSONEqual(getComparison(baselineGraph, baselineSource, baselineSource, document.state), getComparison(baselineProjection.graph, baselineSource, baselineSource, baselineProjection.state))) { + return { ok: false, diagnostics: [getDiagnostic('invalid-baseline', 'Stored Graph baseline does not match its native source')] }; + } + if (graph === null && baselineGraph === null) return { ok: true, source, diagnostics: getProjection(source).diagnostics ?? [] }; + if (graph === null || baselineGraph === null) return { ok: false, diagnostics: [getDiagnostic('unsupported-edit', 'Projection creation/removal requires an explicit native editing policy')] }; + const baseline = getComparison(baselineGraph, baselineSource, baselineSource, document.state); + // document.graph was authored against the baseline projection; fresh + // source edits must not make its original provenance appear untrusted. + const requested = getComparison(graph, baselineSource, baselineSource, document.state); + const changes = getGraphChanges(baseline, requested); + const rawChanges = getGraphChanges(baselineGraph, graph); + const normalizedPaths = new Set(changes.map((change) => change.path)); + const comparisonContext = { source: baselineSource, baselineSource, ...(document.state !== undefined && { state: document.state }) }; + const normalizedFieldPaths = config.getComparisonGraph && !config.getComparisonState ? new Set(getGraphChanges(baselineGraph, baseline).map((change) => change.path)) : undefined; + const privateStateChanged = config.getComparisonState + ? !isGraphDocumentJSONEqual(getJSONClone(config.getComparisonState(getJSONClone(baselineGraph, true), getJSONClone(comparisonContext))), getJSONClone(config.getComparisonState(getJSONClone(graph, true), getJSONClone(comparisonContext)))) + : normalizedFieldPaths && rawChanges.some((change) => normalizedFieldPaths.has(change.path)); + if (privateStateChanged || rawChanges.some((change) => !normalizedPaths.has(change.path))) return { ok: false, diagnostics: [getDiagnostic('unsupported-state-edit', 'Graph projection provenance/annotations were edited; edit document.source instead')] }; + if (!changes.length) return { ok: true, source, diagnostics: getProjection(source).diagnostics ?? [] }; + const currentProjection = getProjection(source); + if (!currentProjection.graph) return { ok: false, diagnostics: [getDiagnostic('projection-unavailable', 'Changed native source cannot project the requested Graph edit'), ...(currentProjection.diagnostics ?? [])] }; + const current = getComparison(currentProjection.graph, source, baselineSource, currentProjection.state); + const nativeChanges = getGraphChanges(baseline, current); + const nativeByPath = new Map(nativeChanges.map((change) => [change.path, change])); + const conflicts = changes.filter((change) => { + const native = nativeByPath.get(change.path); + return native !== undefined && (native.afterPresent !== change.afterPresent || !isGraphDocumentJSONEqual(native.after, change.after)); + }); + if (conflicts.length) return { ok: false, diagnostics: conflicts.map((change) => getDiagnostic('conflicting-edit', 'Source and Graph changed the same mapped field differently', change.path)) }; + const merged = getMergedGraph(current, requested, changes); + if (options.mode === 'regenerate') { + const generated = getJSONClone(config.codec.to(merged)); + const diagnostics: GraphDocumentDiagnostic[] = [{ code: 'lossy-regeneration', message: 'Native source regenerated; source details and requested Graph fields may be lost', severity: 'warning' }]; + const generatedProjection = getProjection(generated); + if (!generatedProjection.graph) { + diagnostics.push({ code: 'graph-regeneration-unavailable', message: `Generated source cannot reproduce a Graph projection: ${(generatedProjection.diagnostics ?? []).map((item) => item.message).join('; ')}`, severity: 'warning' }); + } else { + try { + const regenerated = getComparison(generatedProjection.graph, generated, baselineSource, generatedProjection.state); + for (const loss of getGraphChanges(merged, regenerated)) diagnostics.push({ code: 'graph-regeneration-loss', message: 'Regenerated source does not reproduce this requested Graph field', path: loss.path, severity: 'warning' }); + } catch (error) { + diagnostics.push({ code: 'graph-regeneration-unavailable', message: `Generated projection cannot be compared: ${error instanceof Error ? error.message : String(error)}`, severity: 'warning' }); + } + } + return { ok: true, source: generated, diagnostics }; + } + if (!config.patch) return { ok: false, diagnostics: [getDiagnostic('unsupported-edit', 'This converter has no verified native patch for Graph edits')] }; + const patched = config.patch({ source: getJSONClone(source), baselineSource: getJSONClone(baselineSource), graph: getJSONClone(merged, true), baselineGraph: getJSONClone(baseline, true), sourceGraph: getJSONClone(current, true), changes: getJSONClone(changes), ...(currentProjection.state !== undefined && { state: getJSONClone(currentProjection.state) }) }); + if (!patched.ok) return patched; + const patchedSource = getJSONClone(patched.source); + const proof = getProjection(patchedSource); + if (!proof.graph || !isGraphDocumentJSONEqual(getComparison(proof.graph, patchedSource, baselineSource, proof.state), merged)) { + return { ok: false, diagnostics: [getDiagnostic('patch-verification-failed', 'Patched source does not reproduce the requested merged Graph projection'), ...(proof.diagnostics ?? [])] }; + } + return { ok: true, source: patchedSource, diagnostics: [...(proof.diagnostics ?? []), ...(patched.diagnostics ?? [])] }; + } catch (error) { + return { ok: false, diagnostics: error && typeof error === 'object' && 'diagnostics' in error ? (error as { diagnostics: GraphDocumentDiagnostic[] }).diagnostics : [getDiagnostic('document-update-failed', error instanceof Error ? error.message : String(error))] }; + } + }; + return { + format: config.format, + from(source, options = {}) { + const saved = getJSONClone(source); + const projection = getProjection(saved); + const diagnostics = [...(projection.diagnostics ?? [])]; + if (projection.graph && !diagnostics.some((item) => item.code.startsWith('projection-'))) diagnostics.push({ code: 'projection-partial', message: 'Exact source retention does not establish complete native semantic projection', severity: 'warning' }); + if (options.strict && (!projection.graph || diagnostics.some((item) => item.severity === 'error'))) throw getDocumentError(diagnostics); + return { kind: '@statelyai/graph/document', version: 1, format: config.format, source: saved, + graph: projection.graph === null ? null : getJSONClone(projection.graph, true), + baseline: { source: getJSONClone(saved), graph: projection.graph === null ? null : getJSONClone(projection.graph, true) }, diagnostics, + ...(projection.state !== undefined && { state: getJSONClone(projection.state) }) }; + }, + to(document) { + const result = getUpdate(document); + if (!result.ok) throw getDocumentError(result.diagnostics); + return result.source; + }, + getUpdate, + }; +} + +export function getGraphDocumentUpdate(document: GraphDocument, converter: GraphDocumentConverter, options?: GraphDocumentUpdateOptions): GraphDocumentPatchResult { + return converter.getUpdate(document, options); +} diff --git a/src/formats/document/text.ts b/src/formats/document/text.ts new file mode 100644 index 0000000..0294f3c --- /dev/null +++ b/src/formats/document/text.ts @@ -0,0 +1,68 @@ +import type { Graph } from '../../types'; +import type { GraphDocumentPatchContext, GraphDocumentPatchResult } from './index'; + +export interface GraphDocumentTextSpan { entity: 'node' | 'edge'; id: string; start: number; end: number; encode(value: string): string } + +/** Lexical line offsets; delimiters inside strings never become comments. */ +export function getGraphDocumentTextLines(source: string, comment: '#' | '%%'): Array<{ text: string; start: number }> { + const lines: Array<{ text: string; start: number }> = []; + let offset = 0; + let frontmatter = false; + let first = true; + for (const full of source.match(/[^\n]*(?:\n|$)/g) ?? []) { + if (!full) continue; + const line = full.replace(/\r?\n$/, ''); + if (comment === '%%' && ((first && line.trim() === '---') || (frontmatter && line.trim() === '---'))) { + frontmatter = !frontmatter; + first = false; + offset += full.length; + continue; + } + first = false; + if (frontmatter) { offset += full.length; continue; } + let quote = ''; + let depth = 0; + let end = line.length; + for (let index = 0; index < line.length; index++) { + const char = line[index]; + let slashes = 0; + for (let at = index - 1; at >= 0 && line[at] === '\\'; at--) slashes++; + if (quote) { if (char === quote && slashes % 2 === 0) quote = ''; continue; } + if (char === '"' || char === "'" || char === '`') { quote = char; continue; } + if ('[{('.includes(char)) depth++; + if (']})'.includes(char)) depth--; + if (depth === 0 && line.startsWith(comment, index)) { end = index; break; } + } + const trimmed = line.slice(0, end).trim(); + if (trimmed) lines.push({ text: trimmed, start: offset + line.indexOf(trimmed) }); + offset += full.length; + } + return lines; +} + +/** Apply uniquely resolved lexical spans; unchanged bytes are copied verbatim. */ +export function getGraphDocumentTextPatch(context: GraphDocumentPatchContext, spans: GraphDocumentTextSpan[]): GraphDocumentPatchResult { + const byEntity = new Map(); + for (const span of spans) { + const key = `${span.entity}\0${span.id}`; + const entries = byEntity.get(key) ?? []; + entries.push(span); byEntity.set(key, entries); + } + const replacements: Array<{ start: number; end: number; value: string }> = []; + for (const change of context.changes) { + if (change.entity === 'graph' || change.field !== 'label' || !change.afterPresent || typeof change.after !== 'string') return { ok: false, diagnostics: [{ code: 'unsupported-edit', message: 'This text adapter supports uniquely declared string labels only', path: change.path, severity: 'error' }] }; + const candidates = byEntity.get(`${change.entity}\0${change.id}`) ?? []; + if (candidates.length !== 1) return { ok: false, diagnostics: [{ code: 'unsupported-edit', message: 'Label has no unique supported lexical span; edit document.source instead', path: change.path, severity: 'error' }] }; + const span = candidates[0]; + replacements.push({ start: span.start, end: span.end, value: span.encode(change.after) }); + } + replacements.sort((a, b) => a.start - b.start); + let output = ''; + let offset = 0; + for (const replacement of replacements) { + if (replacement.start < offset) return { ok: false, diagnostics: [{ code: 'unsupported-edit', message: 'Overlapping source spans prevent a safe edit', severity: 'error' }] }; + output += context.source.slice(offset, replacement.start) + replacement.value; + offset = replacement.end; + } + return { ok: true, source: output + context.source.slice(offset) }; +} diff --git a/src/formats/dot/README.md b/src/formats/dot/README.md index 2decaac..42f62e6 100644 --- a/src/formats/dot/README.md +++ b/src/formats/dot/README.md @@ -8,6 +8,18 @@ Converter for the [DOT language](https://graphviz.org/doc/info/lang.html) used b npm install dotparser ``` +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **partial**. +Bare Graph APIs preserve no source document. `dotDocumentConverter` retains +exact native source, including comments and unknown syntax, after JSON persistence. +Its Graph projection and supported edits remain partial; selected-field tests do +not prove unrestricted semantic losslessness. + +Merges repeated declarations and strict edges, keeps rank-subgraph contents and emits native clusters. Resolved defaults, independent fill/border/style, HTML labels, record/HTML ports and compass-only endpoints are preserved. A lexical adapter handles comma node lists for the older parser. Comments, complete grammar coverage and exact declaration order remain outside this Graph converter. + ## Resources - [DOT language specification](https://graphviz.org/doc/info/lang.html) @@ -19,7 +31,7 @@ npm install dotparser ```ts -import { toDOT, fromDOT, dotConverter } from '@statelyai/graph/dot'; +import { toDOT, fromDOT, dotConverter, dotDocumentConverter } from '@statelyai/graph/dot'; import { createGraph } from '@statelyai/graph'; // Export @@ -82,29 +94,16 @@ Parses: ### Round-trip preservation -Constructs that have no native `Graph`/`Node`/`Edge` field are preserved under a -single namespaced `dot` key inside the relevant entity's `data`, so -`fromDOT` → `toDOT` is non-lossy for them (matching how the graphml/gexf/gml -converters store unmapped state in `data`). The value is plain JSON. - -- **Graph** `data.dot`: - - `attrs` — leftover `graph [...]` attributes (`bgcolor`, `fontname`, …; `rankdir` is excluded, it maps to `direction`). - - `nodeDefaults` / `edgeDefaults` — the `node [...]` / `edge [...]` default attribute bags declared at graph scope. - - `ranks` — `rank=same` (and `min`/`max`/…) groups, as `{ rank, nodes }`. Rank subgraphs are treated as layout constraints, not compound nodes. -- **Node / Edge** `data.dot`: - - `attrs` — any attribute not mapped to a native field (`label`, `shape`, `color`). - - `labelHtml` — set when the label came from an HTML-like `<...>` value; the label string is stored verbatim and re-emitted with `<>` delimiters. - - `sourceCompass` / `targetCompass` (edges) — compass points from `node:port:compass` port syntax, re-emitted as the third `:`-segment. - -**Remaining known losses:** -- Compass points only survive when written in the full three-segment - `node:port:compass` form. A two-segment `node:se` is ambiguous (dotparser - reports `se` as the port id, not a compass), so it round-trips as a port. -- Statement ordering and comments are not preserved (attributes are re-emitted - in a canonical order). -- `style` on nodes is partly interpreted: `color`/`fillcolor` become the native - `color` field; the raw `style` attribute is otherwise kept in the `dot.attrs` - bag rather than reconstructed field-by-field. + + +Native attributes and rank/subgraph membership are retained in legacy `data.dot` bags. When user data also needs preserving, a discriminated `_dot` carrier keeps that data, including primitives and foreign `dot` payloads. DOT `_stately` attributes carry Graph-only fields such as original edge IDs, initial states, geometry, ports, arbitrary data and nullable-label presence. Native label presence, including explicit empty labels, remains authoritative. + +- Graph metadata retains strictness, graph/default attributes, rank groups and additional subgraph membership. +- Node metadata retains HTML label markers, resolved inherited attributes, border color and style independently of fill. +- Record and HTML port declarations become advisory Graph ports. A bare compass token is a compass when no matching port is declared; full `node:port:compass` syntax retains both. +- Strict redeclarations update explicit ports/compass and attributes while retaining untouched endpoints/attributes. + +**Remaining limits:** comments, statement spelling/order, complete Graphviz attribute semantics and arbitrary unsupported grammar are not preserved by this Graph API. Reserved metadata names have adapter meaning; source-document guarantees require a separate document layer. The installed parser still supplies the underlying grammar beyond the tested comma-list normalization. ### `dotConverter` @@ -114,3 +113,48 @@ Pre-built `GraphFormatConverter`: const dot = dotConverter.to(graph); const graph = dotConverter.from(dot); ``` + +### `dotDocumentConverter` + + + +```ts +const document = dotDocumentConverter.from(dot); +const persisted = JSON.parse(JSON.stringify(document)); +const unchanged = dotDocumentConverter.to(persisted); // exact original text +if (document.graph) { + document.graph.nodes[0].label = 'Updated'; + const edited = dotDocumentConverter.to(document); +} +``` + +`from` retains source when projection is unavailable (`graph: null`) and reports +its diagnostics; `{ strict: true }` rejects unavailable projections. `to` patches +verified literal node/edge labels and proves the resulting Graph projection by +reparsing. Independent source edits survive; conflicting native and Graph field +edits throw an ordinary error carrying diagnostics. Unedited bytes stay intact. + +Label patches target the last explicit quoted node label, or add a local override to an explicit node statement with a resolved plain label default. Edge patches require one simple statement per projected edge. Missing native default-ID labels, HTML/bare/concatenated labels, implicit nodes, cluster labels, strict merged edges and chains reject safe patching. Grammar unsupported by the legacy parser retains source with an unavailable projection. Graph streams project the first graph with an explicit diagnostic. + +Nullable labels, structure, data, geometry and style edits currently require explicit +regeneration: `dotDocumentConverter.getUpdate(document, { mode: 'regenerate' })` returns a +result with loss diagnostics. Strict `to` never silently regenerates or replays +stale source after unsupported Graph edits. Native explicit labels, including empty strings, +are authoritative over private null metadata; null exports omit native labels. + + + +Explicit Graph empty labels emit `label=""`; null labels omit the native label. +A missing native node label uses Graphviz's default `\N`, which displays its ID. +The importer retains this distinction in versioned `implicitLabel` provenance; +bare import keeps its historical empty-string Graph view, while document import +uses `null` for this absent native label. Edit native source to choose an explicit +literal in that case. The native suite uses Graphviz 15.0.0 to check topology, +parallel edges, ranks/clusters, strict merges, ports, attributes and label presence; +Graphviz JSON fixtures record engine/package versions and hashes. + + + +Native display strings/IDs and strict document label patches reject literal NUL +and lone surrogates. Arbitrary user data remains JSON-escaped in private metadata, +including these values, and is not rejected merely for containing them. diff --git a/src/formats/dot/document.ts b/src/formats/dot/document.ts new file mode 100644 index 0000000..0f230f9 --- /dev/null +++ b/src/formats/dot/document.ts @@ -0,0 +1,164 @@ +import type { Graph } from '../../types'; +import type { GraphDocumentPatchContext, GraphDocumentPatchResult, GraphDocumentProjection } from '../document'; +import { getEditedSource, getLabelChangeError, getUnsupported, type TextEdit } from '../xml-document'; + +/** Native DOT wire strings require Unicode scalar values and cannot contain NUL. */ +export function getValidatedDOTString(value: string): string { + for (const character of value) { const code = character.codePointAt(0)!; if (code === 0 || code >= 0xd800 && code <= 0xdfff) throw new Error('invalid-native-label: DOT strings/IDs require Unicode scalar values without NUL'); } + return value; +} + +interface Token { start: number; end: number; text: string; kind: 'id' | 'quoted' | 'html' | 'punctuation' } +interface Statement { kind: 'node' | 'edge'; private: boolean; id?: string; sourceId?: string; targetId?: string; labels: Token[]; end: number } + +function getTokens(source: string): Token[] { + const tokens: Token[] = []; + let cursor = 0; + while (cursor < source.length) { + if (/\s/.test(source[cursor])) { cursor++; continue; } + if (source.startsWith('//', cursor) || source[cursor] === '#') { const end = source.indexOf('\n', cursor); cursor = end < 0 ? source.length : end; continue; } + if (source.startsWith('/*', cursor)) { const end = source.indexOf('*/', cursor + 2); if (end < 0) throw new Error('unsupported-native-edit: unterminated DOT comment'); cursor = end + 2; continue; } + const start = cursor; + if (source[cursor] === '"') { + cursor++; + while (cursor < source.length && source[cursor] !== '"') { if (source[cursor] === '\\') cursor++; cursor++; } + if (cursor === source.length) throw new Error('unsupported-native-edit: unterminated DOT string'); + cursor++; + tokens.push({ start, end: cursor, text: source.slice(start, cursor), kind: 'quoted' }); continue; + } + if (source[cursor] === '<') { + let depth = 0, quote = ''; + while (cursor < source.length) { + if (source.startsWith('', cursor + 4); if (end < 0) throw new Error('unsupported-native-edit: unterminated HTML comment'); cursor = end + 3; continue; } + const character = source[cursor++]; + if (quote) { if (character === quote) quote = ''; } + else if (character === '"' || character === "'") quote = character; + else if (character === '<') depth++; + else if (character === '>' && --depth === 0) break; + } + if (depth) throw new Error('unsupported-native-edit: unterminated HTML token'); + tokens.push({ start, end: cursor, text: source.slice(start, cursor), kind: 'html' }); continue; + } + if (source.startsWith('->', cursor) || source.startsWith('--', cursor)) cursor += 2; + else if ('{}[];,=: +'.includes(source[cursor])) cursor++; + else { + while (cursor < source.length && !/[\s{}\[\];,=:+"<>]/.test(source[cursor]) && !source.startsWith('->', cursor) && !source.startsWith('--', cursor) && !source.startsWith('//', cursor) && !source.startsWith('/*', cursor)) cursor++; + if (cursor === start) throw new Error('unsupported-native-edit: unknown DOT token'); + } + const text = source.slice(start, cursor); + tokens.push({ start, end: cursor, text, kind: '{}[];,=:+'.includes(text) || text === '->' || text === '--' ? 'punctuation' : 'id' }); + } + return tokens; +} + +function getId(token: Token): string { + return token.kind === 'quoted' ? token.text.slice(1, -1).replace(/\\"/g, '"').replace(/\\\r?\n/g, '') : token.text; +} + +function getStatements(tokens: Token[]): Statement[] { + const statements: Statement[] = []; + let cursor = tokens.findIndex((token) => token.text === '{') + 1; + while (cursor > 0 && cursor < tokens.length) { + const first = tokens[cursor], previous = tokens[cursor - 1]; + if (!['id', 'quoted'].includes(first.kind) || !['{', '}', ';', ']'].includes(previous.text)) { cursor++; continue; } + if (first.kind === 'id' && ['graph', 'node', 'edge', 'subgraph', 'strict', 'digraph'].includes(first.text)) { cursor++; continue; } + let next = cursor + 1; + while (tokens[next]?.text === ':' && ['id', 'quoted'].includes(tokens[next + 1]?.kind)) next += 2; + let kind: 'node' | 'edge' = 'node', target: Token | undefined; + if (['->', '--'].includes(tokens[next]?.text)) { + kind = 'edge'; target = tokens[next + 1]; + if (!target || !['id', 'quoted'].includes(target.kind)) { cursor++; continue; } + next += 2; + while (tokens[next]?.text === ':' && ['id', 'quoted'].includes(tokens[next + 1]?.kind)) next += 2; + // Chained/Cartesian/subgraph edges need a richer projection identity contract. + if (['->', '--', ',', '+'].includes(tokens[next]?.text)) { cursor++; continue; } + } else if (['=', ',', '+'].includes(tokens[next]?.text)) { cursor++; continue; } + const labels: Token[] = []; + let hasPrivate = false; + while (tokens[next]?.text === '[') { + next++; + while (next < tokens.length && tokens[next].text !== ']') { + if (tokens[next + 1]?.text === '=' && tokens[next + 2]) { + if (getId(tokens[next]) === '_stately') { + try { + const value = JSON.parse(getId(tokens[next + 2]).replace(/\\(\\|n)/g, (_, character) => character === 'n' ? '\n' : '\\')); + hasPrivate ||= value?.format === 'stately' && value.version === 1; + } catch { /* Invalid reserved strings remain native source, not a private carrier. */ } + } + if (getId(tokens[next]) === 'label') { + // Concatenated quoted strings are retained, never patched as one token. + if (tokens[next + 3]?.text === '+') labels.push({ ...tokens[next + 2], kind: 'html' }); + else labels.push(tokens[next + 2]); + } + next += 3; + } else next++; + } + if (next === tokens.length) throw new Error('unsupported-native-edit: unterminated DOT attribute list'); + next++; + } + if (!tokens[next] || ![';', '}', ']'].includes(tokens[next].text) && !['id', 'quoted'].includes(tokens[next].kind)) { cursor++; continue; } + const end = tokens[next - 1].end; + statements.push(kind === 'node' ? { kind, id: getId(first), labels, end, private: hasPrivate } + : { kind, sourceId: getId(first), targetId: getId(target!), labels, end, private: hasPrivate }); + cursor = next; + } + return statements; +} + +/** Conservative native token patching: explicit labels, repeated nodes, simple edges. */ +export function getDOTDocumentPatch(context: GraphDocumentPatchContext): GraphDocumentPatchResult { + const invalid = getLabelChangeError(context.changes); + if (invalid) return invalid; + const statements = getStatements(getTokens(context.source)); + const nodeStatements = new Map(); + const edges = statements.filter((statement) => statement.kind === 'edge'); + for (const statement of statements) if (statement.kind === 'node') { + const group = nodeStatements.get(statement.id!) ?? []; group.push(statement); nodeStatements.set(statement.id!, group); + } + const edits: TextEdit[] = []; + for (const change of context.changes) { + let candidates: Statement[]; + if (change.entity === 'node') candidates = nodeStatements.get(change.id!) ?? []; + else { + const index = context.sourceGraph.edges.findIndex((edge) => edge.id === change.id); + if (edges.length !== context.sourceGraph.edges.length || index < 0 || edges.some((statement, offset) => statement.sourceId !== context.sourceGraph.edges[offset].sourceId || statement.targetId !== context.sourceGraph.edges[offset].targetId)) return getUnsupported(change, 'DOT edge labels require one simple statement per projected edge; chains, strict merging and subgraph endpoints are unsupported'); + candidates = [edges[index]]; + } + if (!candidates.length) return getUnsupported(change, 'DOT label editing requires an explicit entity statement; implicit nodes and clusters are unsupported'); + const explicit = candidates.filter((statement) => statement.labels.length); + const label = explicit.at(-1)?.labels.at(-1); + if (label && label.kind !== 'quoted') return getUnsupported(change, 'DOT HTML, bare and concatenated labels are retained but cannot be safely edited'); + const value = getValidatedDOTString(change.after as string).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + if (label) edits.push({ start: label.start + 1, end: label.end - 1, text: value }); + else { + // An inherited HTML label changes interpretation if replaced with plain text. + const entity = change.entity === 'node' ? context.sourceGraph.nodes.find((node) => node.id === change.id) : context.sourceGraph.edges.find((edge) => edge.id === change.id); + const preserve = (entity?.data as any)?._dot?.native ?? (entity?.data as any)?.dot; + if (change.entity === 'node' && preserve?.implicitLabel && candidates.every((candidate) => !candidate.private)) return getUnsupported(change, 'Missing native DOT labels use Graphviz defaults rather than a literal label; edit source explicitly'); + if ((entity?.data as any)?.dot?.labelHtml || (entity?.data as any)?._dot?.labelHtml) return getUnsupported(change, 'Inherited HTML label editing requires an explicit HTML-aware update'); + const statement = candidates.at(-1)!; + edits.push({ start: statement.end, end: statement.end, text: ` [label="${value}"]` }); + } + } + return { ok: true, source: getEditedSource(context.source, edits) }; +} + +/** DOT graph streams retain all bytes while the legacy Graph view selects the first. */ +export function getDOTDocumentProjection(source: string, from: (source: string) => Graph): GraphDocumentProjection { + const tokens = getTokens(source); + let depth = 0, graphs = 0; + for (const token of tokens) { + if (!depth && token.kind === 'id' && ['graph', 'digraph'].includes(token.text)) graphs++; + if (token.text === '{') depth++; + else if (token.text === '}') depth--; + } + const graph = from(source); + const privateNodes = new Set(getStatements(tokens).filter((statement) => statement.kind === 'node' && statement.private).map((statement) => statement.id)); + for (const node of graph.nodes) { + const preserve = (node.data as any)?._dot?.native ?? (node.data as any)?.dot; + // The document view distinguishes absent native labels from explicit empty; + // bare fromDOT keeps its historical empty-string projection. + if (node.label === '' && preserve?.implicitLabel && !privateNodes.has(node.id)) node.label = null; + } + return { graph, diagnostics: [{ code: graphs > 1 ? 'projection-multiple-graphs' : 'projection-partial', message: graphs > 1 ? 'Only the first DOT graph is projected; the complete native graph stream remains preserved' : 'The Graph view is partial; comments, statement syntax and unmodeled attributes remain in native source', severity: 'warning' }] }; +} diff --git a/src/formats/dot/index.ts b/src/formats/dot/index.ts index 8c78fb6..d16ec90 100644 --- a/src/formats/dot/index.ts +++ b/src/formats/dot/index.ts @@ -1,6 +1,9 @@ +import { createGraphDocumentConverter } from '../document'; +import { getDOTDocumentPatch, getDOTDocumentProjection, getValidatedDOTString } from './document'; import parse from 'dotparser'; import type { Graph, GraphNode, GraphEdge, GraphFormatConverter } from '../../types'; import { createFormatConverter } from '../converter'; +import { createFormatData, getFormatMetadata, getUserData } from '../xml-preservation'; /** * Round-trip preservation of DOT constructs that this library does not model @@ -23,10 +26,17 @@ import { createFormatConverter } from '../converter'; * `fromDOT(toDOT(fromDOT(x)))` is stable for the covered constructs. */ interface DotEntityPreserve { + inheritedAttrs?: Record; + borderColor?: string; + style?: string; + subgraph?: boolean; + memberships?: string[]; /** DOT attributes with no native field, verbatim. */ attrs?: Record; /** True when `label` came from an HTML-like `<...>` value; re-emit with `<>`. */ labelHtml?: boolean; + /** Missing native node label inherits Graphviz's default \N; distinct from Graph ''. */ + implicitLabel?: boolean; /** Compass point on the source endpoint of an edge (e.g. `n`, `se`). */ sourceCompass?: string; /** Compass point on the target endpoint of an edge. */ @@ -41,6 +51,8 @@ interface DotRankGroup { } interface DotGraphPreserve { + strict?: boolean; + subgraphs?: Array<{ id: string; parentId: string | null; attrs: Record; nodes: string[] }>; /** Leftover `graph [...]` attributes (excludes `rankdir`, mapped to direction). */ attrs?: Record; /** `node [...]` default attributes in effect at graph scope. */ @@ -52,9 +64,9 @@ interface DotGraphPreserve { } /** Node attribute names mapped to native fields — excluded from the `dot` bag. */ -const MODELED_NODE_ATTRS = new Set(['label', 'shape', 'fillcolor', 'color', 'style']); +const MODELED_NODE_ATTRS = new Set(['label', 'shape', 'fillcolor', 'color', 'style', '_stately']); /** Edge attribute names mapped to native fields — excluded from the `dot` bag. */ -const MODELED_EDGE_ATTRS = new Set(['label', 'color']); +const MODELED_EDGE_ATTRS = new Set(['label', 'color', '_stately']); /** DOT compass points, in the port syntax `node:port:compass`. */ const COMPASS_POINTS = new Set([ @@ -64,6 +76,8 @@ const COMPASS_POINTS = new Set([ function getDotPreserve( data: unknown, ): DotEntityPreserve | undefined { + const metadata = getFormatMetadata(data, 'dot'); + if (metadata) return metadata.native; if (data && typeof data === 'object' && 'dot' in data) { return (data as { dot?: DotEntityPreserve }).dot; } @@ -71,7 +85,7 @@ function getDotPreserve( } /** Partition an attr map into modeled vs. leftover (preserved) attributes. */ -function leftoverAttrs( +function getLeftoverAttrs( attrs: Record, modeled: Set, ): Record | undefined { @@ -95,7 +109,8 @@ const DOT_KEYWORDS = new Set([ ]); /** Escape a DOT identifier */ -function escapeId(id: string): string { +function getEscapedId(id: string): string { + getValidatedDOTString(id); if ( /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(id) && !DOT_KEYWORDS.has(id.toLowerCase()) @@ -106,8 +121,8 @@ function escapeId(id: string): string { } /** Escape a DOT label string */ -function escapeLabel(label: string): string { - return label +function getEscapedLabel(label: string): string { + return getValidatedDOTString(label) .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') // A raw newline inside a quoted string is invalid DOT; \n is the DOT @@ -116,17 +131,17 @@ function escapeLabel(label: string): string { } /** - * Invert {@link escapeLabel}. dotparser unescapes `\"` itself but passes + * Invert {@link getEscapedLabel}. dotparser unescapes `\"` itself but passes * `\\` and `\n` through verbatim. */ -function unescapeLabel(label: string): string { +function getUnescapedLabel(label: string): string { return label.replace(/\\(\\|n)/g, (_, ch) => (ch === 'n' ? '\n' : '\\')); } -function formatEndpoint(id: string, port?: string, compass?: string): string { - let out = escapeId(id); - if (port) out += `:${escapeId(port)}`; - if (compass) out += `:${escapeId(compass)}`; +function getFormattedEndpoint(id: string, port?: string, compass?: string): string { + let out = getEscapedId(id); + if (port) out += `:${getEscapedId(port)}`; + if (compass) out += `:${getEscapedId(compass)}`; return out; } @@ -134,9 +149,10 @@ function formatEndpoint(id: string, port?: string, compass?: string): string { * Format a `key=value` attribute. HTML-like labels are emitted with `<...>` * delimiters (verbatim, no escaping); everything else is a quoted string. */ -function formatAttr(key: string, value: string, html = false): string { - if (html) return `${key}=<${value}>`; - return `${key}="${escapeLabel(value)}"`; +function getFormattedAttr(key: string, value: string, html = false): string { + getValidatedDOTString(key); + if (html) return `${key}=<${getValidatedDOTString(value)}>`; + return `${key}="${getEscapedLabel(value)}"`; } const DIRECTION_TO_RANKDIR: Record = { @@ -183,81 +199,122 @@ const SHAPE_TO_DOT: Record = { * ``` */ export function toDOT(graph: Graph): string { + const gp = getDotPreserve(graph.data) as DotGraphPreserve | undefined; const isDirected = graph.mode !== 'undirected'; const keyword = isDirected ? 'digraph' : 'graph'; const edgeOp = isDirected ? '->' : '--'; - - const lines: string[] = []; - lines.push(`${keyword} ${escapeId(graph.id)} {`); - - if (graph.direction) { - const rankdir = DIRECTION_TO_RANKDIR[graph.direction] ?? 'TB'; - lines.push(` rankdir=${rankdir};`); - } - - // Preserved graph-level state: bgcolor/fontname/… graph attrs and node/edge - // default attribute bags (see DotGraphPreserve). - const gp = (graph.data as { dot?: DotGraphPreserve } | undefined)?.dot; - if (gp?.attrs) { - for (const [k, v] of Object.entries(gp.attrs)) { - lines.push(` ${formatAttr(k, v)};`); - } - } - if (gp?.nodeDefaults && Object.keys(gp.nodeDefaults).length > 0) { - const parts = Object.entries(gp.nodeDefaults).map(([k, v]) => formatAttr(k, v)); - lines.push(` node [${parts.join(', ')}];`); - } - if (gp?.edgeDefaults && Object.keys(gp.edgeDefaults).length > 0) { - const parts = Object.entries(gp.edgeDefaults).map(([k, v]) => formatAttr(k, v)); - lines.push(` edge [${parts.join(', ')}];`); + const lines = [`${gp?.strict ? 'strict ' : ''}${keyword} ${getEscapedId(graph.id)} {`]; + const graphExtension = { + format: 'stately', version: 1, mode: graph.mode, + ...(graph.initialNodeId != null && { initialNodeId: graph.initialNodeId }), + ...(getDotUserData(graph.data) !== undefined && { data: getDotUserData(graph.data) }), + ...(graph.style !== undefined && { style: graph.style }), + }; + if (Object.keys(graphExtension).length > 3 || graph.mode === 'bidirectional') { + lines.push(` ${getFormattedAttr('_stately', JSON.stringify(graphExtension))};`); } - + if (graph.direction) lines.push(` rankdir=${DIRECTION_TO_RANKDIR[graph.direction]};`); + for (const [key, value] of Object.entries(gp?.attrs ?? {})) lines.push(` ${getFormattedAttr(getEscapedId(key), value)};`); + if (gp?.nodeDefaults) lines.push(` node [${Object.entries(gp.nodeDefaults).map(([key,value]) => getFormattedAttr(getEscapedId(key),value)).join(', ')}];`); + if (gp?.edgeDefaults) lines.push(` edge [${Object.entries(gp.edgeDefaults).map(([key,value]) => getFormattedAttr(getEscapedId(key),value)).join(', ')}];`); + const nodeIndex = new Map(graph.nodes.map((node) => [node.id,node])); + const subgraphIndex = new Map(gp?.subgraphs?.map((subgraph) => [subgraph.id,subgraph]) ?? []); + const parentIds = new Set(graph.nodes.flatMap((node) => node.parentId == null ? [] : [node.parentId])); + const containers = new Set(graph.nodes.filter((node) => getDotPreserve(node.data)?.subgraph || parentIds.has(node.id)).map((node) => node.id)); + const children = new Map(); for (const node of graph.nodes) { + const parent = node.parentId != null && containers.has(node.parentId) ? node.parentId : null; + const group = children.get(parent) ?? []; + group.push(node); children.set(parent, group); + } + const tasks: Array<{ node?: GraphNode; close?: boolean; indent: string }> = [...(children.get(null) ?? [])].reverse().map((node) => ({ node, indent: ' ' })); + const visited = new Set(); + while (tasks.length) { + const task = tasks.pop()!; + if (task.close) { lines.push(`${task.indent}}`); continue; } + const node = task.node!; + if (visited.has(node.id)) continue; + visited.add(node.id); const p = getDotPreserve(node.data); - const attrs: string[] = []; - if (node.label) attrs.push(formatAttr('label', node.label, p?.labelHtml)); - if (node.shape) { - const dotShape = SHAPE_TO_DOT[node.shape] ?? node.shape; - attrs.push(`shape=${dotShape}`); - } - if (node.color) attrs.push(`fillcolor="${escapeLabel(node.color)}" style=filled`); - if (p?.attrs) { - for (const [k, v] of Object.entries(p.attrs)) attrs.push(formatAttr(k, v)); - } - if (attrs.length > 0) { - lines.push(` ${escapeId(node.id)} [${attrs.join(', ')}];`); - } else { - lines.push(` ${escapeId(node.id)};`); + if (containers.has(node.id)) { + lines.push(`${task.indent}subgraph ${getEscapedId(node.id)} {`); + if (node.label != null) lines.push(`${task.indent} ${getFormattedAttr('label', node.label)};`); + const extension = getEntityExtension(node); + if (!p?.subgraph) extension.compound = true; + if (node.shape !== undefined) extension.shape = node.shape; + if (node.color !== undefined) extension.color = node.color; + if (Object.keys(extension).length > 2) lines.push(`${task.indent} ${getFormattedAttr('_stately',JSON.stringify(extension))};`); + const native = subgraphIndex.get(node.id); + for (const [key,value] of Object.entries(native?.attrs ?? {})) if (key !== 'label' && key !== '_stately') lines.push(`${task.indent} ${getFormattedAttr(getEscapedId(key),value)};`); + tasks.push({ close: true, indent: task.indent }); + for (const child of [...(children.get(node.id) ?? [])].reverse()) tasks.push({ node: child, indent: task.indent + ' ' }); + continue; } + const attrs: string[] = []; + if (node.label != null && !(node.label === '' && getFormatMetadata(node.data, 'dot')?.native?.implicitLabel)) attrs.push(getFormattedAttr('label',node.label,p?.labelHtml)); + if (node.shape) attrs.push(`shape=${getEscapedId(SHAPE_TO_DOT[node.shape] ?? node.shape)}`); + if (node.color !== undefined) attrs.push(getFormattedAttr('fillcolor',node.color)); + if (p?.borderColor !== undefined) attrs.push(getFormattedAttr('color', p.borderColor)); + if (p?.style !== undefined) attrs.push(getFormattedAttr('style',p.style)); + else if (node.color !== undefined) attrs.push('style=filled'); + for (const [key,value] of Object.entries({ ...p?.inheritedAttrs, ...p?.attrs })) attrs.push(getFormattedAttr(getEscapedId(key),value)); + const extension = getEntityExtension(node); + if (Object.keys(extension).length > 2) attrs.push(getFormattedAttr('_stately',JSON.stringify(extension))); + lines.push(`${task.indent}${getEscapedId(node.id)}${attrs.length ? ` [${attrs.join(', ')}]` : ''};`); } - for (const edge of graph.edges) { const p = getDotPreserve(edge.data); const attrs: string[] = []; - if (edge.label) attrs.push(formatAttr('label', edge.label, p?.labelHtml)); - if (edge.color) attrs.push(`color="${escapeLabel(edge.color)}"`); - if (p?.attrs) { - for (const [k, v] of Object.entries(p.attrs)) attrs.push(formatAttr(k, v)); - } - const attrStr = attrs.length > 0 ? ` [${attrs.join(', ')}]` : ''; - lines.push( - ` ${formatEndpoint(edge.sourceId, edge.sourcePort, p?.sourceCompass)} ${edgeOp} ${formatEndpoint(edge.targetId, edge.targetPort, p?.targetCompass)}${attrStr};`, - ); + if (edge.label != null) attrs.push(getFormattedAttr('label',edge.label,p?.labelHtml)); + if (edge.color !== undefined) attrs.push(getFormattedAttr('color',edge.color)); + for (const [key,value] of Object.entries({ ...p?.inheritedAttrs, ...p?.attrs })) attrs.push(getFormattedAttr(getEscapedId(key),value)); + const extension = getEntityExtension(edge); + if (Object.keys(extension).length > 2) attrs.push(getFormattedAttr('_stately',JSON.stringify(extension))); + if (edge.mode === 'bidirectional' || (edge.mode === undefined && graph.mode === 'bidirectional')) attrs.push('dir=both'); + else if (edge.mode === 'undirected' && isDirected) attrs.push('dir=none'); + lines.push(` ${getFormattedEndpoint(edge.sourceId,edge.sourcePort,p?.sourceCompass)} ${edgeOp} ${getFormattedEndpoint(edge.targetId,edge.targetPort,p?.targetCompass)}${attrs.length ? ` [${attrs.join(', ')}]` : ''};`); } - - // Preserved `rank=same` (and other rank) groups, emitted as anonymous - // subgraphs so Graphviz re-applies the constraint. - if (gp?.ranks) { - for (const group of gp.ranks) { - const members = group.nodes.map((id) => `${escapeId(id)};`).join(' '); - lines.push(` { rank=${escapeId(group.rank)}; ${members} }`); - } + // Additional memberships are DOT subgraph references, not extra Graph nodes. + for (const subgraph of gp?.subgraphs ?? []) { + const extra = subgraph.nodes.filter((id) => nodeIndex.get(id)?.parentId !== subgraph.id); + if (extra.length) lines.push(` subgraph ${getEscapedId(subgraph.id)} { ${extra.map((id) => `${getEscapedId(id)};`).join(' ')} }`); } - + for (const rank of gp?.ranks ?? []) lines.push(` { rank=${getEscapedId(rank.rank)}; ${rank.nodes.map((id) => `${getEscapedId(id)};`).join(' ')} }`); lines.push('}'); return lines.join('\n'); } +function getDotUserData(data: unknown): any { + if (getFormatMetadata(data,'dot')) return getUserData(data,'dot'); + if (data && typeof data === 'object' && Object.keys(data).length === 1 && 'dot' in data && (data as any).dot && typeof (data as any).dot === 'object' && ['attrs','inheritedAttrs','labelHtml','sourceCompass','targetCompass','borderColor','style','subgraph','nodeDefaults','edgeDefaults','ranks','subgraphs','strict'].some((key) => key in (data as any).dot)) return undefined; + return data; +} + +function getEntityExtension(entity: GraphNode | GraphEdge): Record { + const extension: Record = { format: 'stately', version: 1 }; + if (entity.type === 'edge') extension.id = entity.id; + if (entity.label === null) extension.nullLabel = true; + for (const key of ['initialNodeId','ports','x','y','width','height','style','weight','points','routing','mode']) { + if ((entity as any)[key] !== undefined && (key !== 'initialNodeId' || (entity as any)[key] !== null)) extension[key] = (entity as any)[key]; + } + const data = getDotUserData(entity.data); + if (data !== undefined) extension.data = data; + return extension; +} + +function getPrivateData(value: string | undefined): any { + if (value === undefined) return undefined; + try { + const result = JSON.parse(getUnescapedLabel(value)); + return result?.format === 'stately' && result.version === 1 ? result : undefined; + } catch { return undefined; } +} + +function getPreservedData(data: unknown, native: DotEntityPreserve | DotGraphPreserve): any { + if (Object.keys(native).length === 0) return data; + return data === undefined && !('implicitLabel' in native) ? { dot: native } : createFormatData(data,'dot',{ native }); +} + // --- fromDOT --- const RANKDIR_TO_DIRECTION: Record = { @@ -310,7 +367,7 @@ type DotAttrEq = | number | { type: 'id'; value: string; html?: boolean }; -function attrValueString(eq: DotAttrEq): string { +function getAttrValueString(eq: DotAttrEq): string { if (eq !== null && typeof eq === 'object') return eq.value; return String(eq); } @@ -319,16 +376,16 @@ function isHtmlAttr(eq: DotAttrEq): boolean { return eq !== null && typeof eq === 'object' && eq.html === true; } -function attrsToMap(attrList: { id: string; eq: DotAttrEq }[]): AttrMap { +function getAttrsMap(attrList: { id: string; eq: DotAttrEq }[]): AttrMap { const map: AttrMap = {}; for (const a of attrList) { - map[a.id] = attrValueString(a.eq); + map[a.id] = getAttrValueString(a.eq); } return map; } /** Names of attributes in the list whose value is an HTML-like `<...>` value. */ -function htmlAttrNames(attrList: { id: string; eq: DotAttrEq }[]): Set { +function getHtmlAttrNames(attrList: { id: string; eq: DotAttrEq }[]): Set { const names = new Set(); for (const a of attrList) { if (isHtmlAttr(a.eq)) names.add(a.id); @@ -336,7 +393,7 @@ function htmlAttrNames(attrList: { id: string; eq: DotAttrEq }[]): Set { return names; } -function nodeFromAttrs( +function getNodeFromAttrs( id: string, attrs: AttrMap, defaults: AttrMap, @@ -348,30 +405,38 @@ function nodeFromAttrs( const rawLabel = merged['label'] ?? ''; // HTML-like labels are preserved verbatim (no unescaping); plain labels use // the DOT string escapes. - const label = labelIsHtml ? rawLabel : unescapeLabel(rawLabel); + const label = labelIsHtml ? rawLabel : getUnescapedLabel(rawLabel); const rawShape = merged['shape']; const shape = rawShape ? (DOT_TO_SHAPE[rawShape] ?? rawShape) : undefined; const color = merged['fillcolor'] ?? merged['color'] ?? undefined; // Preserve leftover node-explicit attributes (defaults are preserved once at // graph scope, so they are not folded into the per-node bag). - const rest = leftoverAttrs(attrs, MODELED_NODE_ATTRS); + const rest = getLeftoverAttrs(attrs, MODELED_NODE_ATTRS); const preserve: DotEntityPreserve = { ...(rest && { attrs: rest }), + ...(getLeftoverAttrs(defaults, MODELED_NODE_ATTRS) && { inheritedAttrs: getLeftoverAttrs(defaults, MODELED_NODE_ATTRS) }), + ...(merged['color'] !== undefined && { borderColor: merged['color'] }), + ...((merged['style'] !== undefined || color !== undefined) && { style: merged['style'] ?? '' }), ...(labelIsHtml && { labelHtml: true }), + ...(merged['label'] === undefined && !getPrivateData(attrs['_stately']) && { implicitLabel: true }), }; - const data = - Object.keys(preserve).length > 0 ? ({ dot: preserve } as any) : undefined; + const privateData = getPrivateData(attrs['_stately']); + const data = getPreservedData(privateData?.data, preserve); + const portNames = labelIsHtml ? [...label.matchAll(/\bPORT\s*=\s*["']([^"']+)["']/gi)].map((match) => match[1]) + : ['record','Mrecord'].includes(rawShape ?? '') ? [...label.matchAll(/<([^<>]+)>/g)].map((match) => match[1]) : []; return { type: 'node', id, parentId, - initialNodeId: null, - label, + initialNodeId: privateData?.initialNodeId ?? null, + label: merged['label'] === undefined && privateData?.nullLabel ? null : label, data, + ...(portNames.length && { ports:[...new Set(portNames)].map((name) => ({name,direction:'inout' as const,data:null})) }), + ...Object.fromEntries(['ports','x','y','width','height','style'].filter((key) => privateData?.[key] !== undefined).map((key) => [key,privateData[key]])), ...(shape && { shape }), - ...(color && { color }), + ...(color !== undefined && { color }), }; } @@ -408,7 +473,7 @@ export function fromDOT(dot: string): Graph { let ast: ReturnType; try { - ast = parse(dot); + ast = parse(getNormalizedNodeLists(dot)); } catch (e: any) { throw new Error(`DOT: parse error — ${e.message}`); } @@ -423,6 +488,11 @@ export function fromDOT(dot: string): Graph { const nodeMap = new Map(); const edges: GraphEdge[] = []; let edgeIdx = 0; + const strict = (root as typeof root & { strict?: boolean }).strict === true; + const strictEdges = new Map(); + const nodeStates = new Map }>(); + const subgraphs: NonNullable = []; + let privateGraph: any; let direction: Graph['direction'] | undefined; // Accumulated graph-level DOT state with no native field, for round-tripping. @@ -431,20 +501,21 @@ export function fromDOT(dot: string): Graph { let rootEdgeDefaults: Record | undefined; const rankGroups: DotRankGroup[] = []; - function ensureNode( + function addImplicitNode( id: string, parentId: string | null, defaults: AttrMap, ): void { if (!nodeMap.has(id)) { - nodeMap.set(id, nodeFromAttrs(id, {}, defaults, parentId)); + nodeStates.set(id,{ attrs: {}, defaults: { ...defaults }, html: new Set() }); + nodeMap.set(id, getNodeFromAttrs(id, {}, defaults, parentId)); } } function getNodeIdsFromSubgraph(children: any[]): string[] { const ids = new Set(); - function collect(statements: any[]): void { + function addNodeIds(statements: any[]): void { for (const stmt of statements) { switch (stmt.type) { case 'node_stmt': { @@ -456,54 +527,64 @@ export function fromDOT(dot: string): Graph { if (item.type === 'node_id') { ids.add(item.id); } else if (item.type === 'subgraph') { - collect(item.children); + addNodeIds(item.children); } } break; } case 'subgraph': { - collect(stmt.children); + addNodeIds(stmt.children); break; } } } } - collect(children); + addNodeIds(children); return [...ids]; } - function walkChildren( + function addStatements( children: typeof root.children, parentId: string | null, nodeDefaults: AttrMap, edgeDefaults: AttrMap, + inheritedNodeHtml = new Set(), + inheritedEdgeHtml = new Set(), ): void { let nd = { ...nodeDefaults }; let ed = { ...edgeDefaults }; + const nodeHtml = new Set(inheritedNodeHtml); + const edgeDefaultHtml = new Set(inheritedEdgeHtml); for (const stmt of children) { switch (stmt.type) { case 'attr_stmt': { if (stmt.target === 'node') { - const map = attrsToMap(stmt.attr_list); + const map = getAttrsMap(stmt.attr_list); nd = { ...nd, ...map }; + for (const key of Object.keys(map)) nodeHtml.delete(key); + for (const key of getHtmlAttrNames(stmt.attr_list)) nodeHtml.add(key); // Preserve `node [...]` defaults declared at graph scope. if (parentId === null) { rootNodeDefaults = { ...(rootNodeDefaults ?? {}), ...map }; } } else if (stmt.target === 'edge') { - const map = attrsToMap(stmt.attr_list); + const map = getAttrsMap(stmt.attr_list); ed = { ...ed, ...map }; + for (const key of Object.keys(map)) edgeDefaultHtml.delete(key); + for (const key of getHtmlAttrNames(stmt.attr_list)) edgeDefaultHtml.add(key); // Preserve `edge [...]` defaults declared at graph scope. if (parentId === null) { rootEdgeDefaults = { ...(rootEdgeDefaults ?? {}), ...map }; } } else if (stmt.target === 'graph') { - const graphAttrs = attrsToMap(stmt.attr_list); + const graphAttrs = getAttrsMap(stmt.attr_list); for (const [k, v] of Object.entries(graphAttrs)) { if (k === 'rankdir') { direction = RANKDIR_TO_DIRECTION[v.toUpperCase()] ?? undefined; + } else if (k === '_stately' && parentId === null) { + privateGraph = getPrivateData(v); } else if (parentId === null) { // Preserve other graph attributes (bgcolor, fontname, …). graphPreserveAttrs[k] = v; @@ -515,20 +596,27 @@ export function fromDOT(dot: string): Graph { case 'node_stmt': { const id = stmt.node_id.id; - const attrs = attrsToMap(stmt.attr_list); - const html = htmlAttrNames(stmt.attr_list); - const node = nodeFromAttrs(id, attrs, nd, parentId, html); + const attrs = getAttrsMap(stmt.attr_list); + const previous = nodeStates.get(id); + const html = new Set(previous?.html ?? nodeHtml); + if ('label' in attrs) html.delete('label'); + for (const name of getHtmlAttrNames(stmt.attr_list)) html.add(name); + const combined = { ...previous?.attrs, ...attrs }; + const defaults = previous?.defaults ?? nd; + nodeStates.set(id, { attrs: combined, defaults: { ...defaults }, html }); + const original = nodeMap.get(id); + const node = getNodeFromAttrs(id, combined, defaults, original?.parentId ?? parentId, html); nodeMap.set(id, node); break; } case 'edge_stmt': { - const edgeAttrs = attrsToMap(stmt.attr_list); - const edgeHtml = htmlAttrNames(stmt.attr_list); + const edgeAttrs = getAttrsMap(stmt.attr_list); + const edgeHtml = getHtmlAttrNames(stmt.attr_list); const mergedEdgeAttrs = { ...ed, ...edgeAttrs }; // Leftover edge-explicit attrs (defaults preserved once at graph scope). - const edgeRest = leftoverAttrs(edgeAttrs, MODELED_EDGE_ATTRS); - const labelIsHtml = edgeHtml.has('label'); + const edgeRest = getLeftoverAttrs(edgeAttrs, MODELED_EDGE_ATTRS); + const labelIsHtml = edgeHtml.has('label') || (!('label' in edgeAttrs) && edgeDefaultHtml.has('label')); // Walk edge_list: each consecutive pair forms edges between endpoint sets. // DOT allows node IDs or subgraphs as endpoints; subgraphs expand to all @@ -536,7 +624,12 @@ export function fromDOT(dot: string): Graph { const endpointGroups: EndpointRef[][] = []; for (const item of stmt.edge_list) { if (item.type === 'node_id') { - ensureNode(item.id, parentId, nd); + const existed = nodeMap.has(item.id); + addImplicitNode(item.id, parentId, nd); + if (!existed && nodeHtml.has('label')) { + const state = nodeStates.get(item.id)!; state.html = new Set(nodeHtml); + nodeMap.set(item.id,getNodeFromAttrs(item.id,{},nd,parentId,nodeHtml)); + } endpointGroups.push([ { id: item.id, @@ -545,10 +638,10 @@ export function fromDOT(dot: string): Graph { }, ]); } else if (item.type === 'subgraph') { - walkChildren(item.children, parentId, nd, ed); + addStatements(item.children, parentId, nd, ed,nodeHtml,edgeDefaultHtml); const subNodeIds = getNodeIdsFromSubgraph(item.children); for (const subNodeId of subNodeIds) { - ensureNode(subNodeId, parentId, nd); + addImplicitNode(subNodeId, parentId, nd); } if (subNodeIds.length > 0) { endpointGroups.push(subNodeIds.map((id) => ({ id }))); @@ -564,6 +657,7 @@ export function fromDOT(dot: string): Graph { const rawLabel = mergedEdgeAttrs['label'] ?? ''; const preserve: DotEntityPreserve = { ...(edgeRest && { attrs: edgeRest }), + ...(getLeftoverAttrs(ed, MODELED_EDGE_ATTRS) && { inheritedAttrs: getLeftoverAttrs(ed, MODELED_EDGE_ATTRS) }), ...(labelIsHtml && { labelHtml: true }), ...(source.compass && { sourceCompass: source.compass }), ...(target.compass && { targetCompass: target.compass }), @@ -573,7 +667,7 @@ export function fromDOT(dot: string): Graph { id: `e${edgeIdx++}`, sourceId: source.id, targetId: target.id, - label: labelIsHtml ? rawLabel : unescapeLabel(rawLabel), + label: labelIsHtml ? rawLabel : getUnescapedLabel(rawLabel), data: Object.keys(preserve).length > 0 ? ({ dot: preserve } as any) @@ -584,7 +678,34 @@ export function fromDOT(dot: string): Graph { color: mergedEdgeAttrs['color'], }), }; - edges.push(edge); + const privateData = getPrivateData(edgeAttrs['_stately']); + if (privateData) { + if (typeof privateData.id === 'string') edge.id = privateData.id; + if (mergedEdgeAttrs['label'] === undefined && privateData.nullLabel) edge.label = null; + for (const key of ['x','y','width','height','style','weight','points','routing','mode']) if (privateData[key] !== undefined) (edge as any)[key] = privateData[key]; + edge.data = getPreservedData(privateData.data, preserve); + } + const pair = isDirected ? [source.id,target.id] : [source.id,target.id].sort(); + const strictKey = JSON.stringify(pair); + const previous = strictEdges.get(strictKey); + if (strict && previous) { + if ('label' in edgeAttrs) previous.edge.label = edge.label; + if ('color' in edgeAttrs) previous.edge.color = edge.color; + const oldPreserve = { ...getDotPreserve(previous.edge.data) }; + if (source.port !== undefined || source.compass !== undefined) { + previous.edge.sourcePort = source.port; + delete oldPreserve.sourceCompass; + } + if (target.port !== undefined || target.compass !== undefined) { + previous.edge.targetPort = target.port; + delete oldPreserve.targetCompass; + } + previous.edge.data = getPreservedData(getDotUserData(previous.edge.data), { ...oldPreserve, ...preserve, attrs: { ...oldPreserve.attrs, ...edgeRest } }); + previous.attrs = { ...previous.attrs, ...edgeAttrs }; + } else { + edges.push(edge); + if (strict) strictEdges.set(strictKey,{ edge, attrs: edgeAttrs }); + } } } } @@ -594,12 +715,15 @@ export function fromDOT(dot: string): Graph { case 'subgraph': { // Extract subgraph-level graph attributes (label, rank, …). let subLabel = ''; + let subHasLabel = false; let rank: string | undefined; + let subPrivate: any; for (const child of stmt.children) { if (child.type === 'attr_stmt' && child.target === 'graph') { - const ga = attrsToMap(child.attr_list); - if (ga['label']) subLabel = unescapeLabel(ga['label']); + const ga = getAttrsMap(child.attr_list); + if ('label' in ga) { subLabel = getUnescapedLabel(ga['label']); subHasLabel = true; } if (ga['rank']) rank = ga['rank']; + if (ga['_stately']) subPrivate = getPrivateData(ga['_stately']); } } @@ -611,7 +735,8 @@ export function fromDOT(dot: string): Graph { if (rank) { const members = getNodeIdsFromSubgraph(stmt.children); rankGroups.push({ rank, nodes: members }); - for (const m of members) ensureNode(m, parentId, nd); + addStatements(stmt.children.filter((child) => !(child.type === 'attr_stmt' && child.target === 'graph')), parentId, nd, ed,nodeHtml,edgeDefaultHtml); + for (const m of members) addImplicitNode(m, parentId, nd); break; } @@ -624,21 +749,54 @@ export function fromDOT(dot: string): Graph { label: subLabel, data: undefined as any, }; - nodeMap.set(subId, subNode); - - walkChildren(stmt.children, subId, nd, ed); + subNode.data = subPrivate?.compound ? subPrivate.data : getPreservedData(subPrivate?.data,{subgraph:true}); + if (!subHasLabel && subPrivate?.nullLabel) subNode.label = null; + for (const key of ['initialNodeId','ports','x','y','width','height','style','shape','color']) if (subPrivate?.[key] !== undefined) (subNode as any)[key] = subPrivate[key]; + if (!nodeMap.has(subId)) nodeMap.set(subId, subNode); + const attrs: Record = {}; + for (const child of stmt.children) if (child.type === 'attr_stmt' && child.target === 'graph') Object.assign(attrs,getAttrsMap(child.attr_list)); + delete attrs['_stately']; + const existing = subgraphs.find((subgraph) => subgraph.id === subId); + if (existing) { Object.assign(existing.attrs,attrs); existing.nodes = [...new Set([...existing.nodes,...getNodeIdsFromSubgraph(stmt.children)])]; } + else if (!subPrivate?.compound) subgraphs.push({ id: subId, parentId, attrs, nodes: getNodeIdsFromSubgraph(stmt.children) }); + + addStatements(stmt.children, subId, nd, ed,nodeHtml,edgeDefaultHtml); break; } } } } - walkChildren(root.children, null, {}, {}); - + addStatements(root.children, null, {}, {}); + + for (const edge of edges) { + for (const end of ['source','target'] as const) { + const portKey = end === 'source' ? 'sourcePort' : 'targetPort'; + const id = end === 'source' ? edge.sourceId : edge.targetId; + const port = edge[portKey]; + if (port && COMPASS_POINTS.has(port) && !nodeMap.get(id)?.ports?.some((declared) => declared.name === port)) { + const native = {...getDotPreserve(edge.data),[end === 'source' ? 'sourceCompass' : 'targetCompass']:port}; + delete edge[portKey]; + edge.data = getPreservedData(getDotUserData(edge.data),native); + } + } + } + // Defaults introduced later must not retroactively style existing objects. + for (const [id,state] of nodeStates) { + const missing: Record = {}; + for (const key of Object.keys(rootNodeDefaults ?? {})) if (!(key in state.defaults) && !(key in state.attrs)) missing[key] = ''; + if (Object.keys(missing).length) { + const node = nodeMap.get(id)!; + const native = getDotPreserve(node.data) ?? {}; + node.data = getPreservedData(getDotUserData(node.data),{ ...native,inheritedAttrs:{ ...missing,...native.inheritedAttrs } }); + } + } // Assemble preserved graph-level DOT state (see DotGraphPreserve). Only // populated when there is something to preserve, so plain graphs keep // `data: undefined`. const graphPreserve: DotGraphPreserve = { + ...(strict && { strict: true }), + ...(subgraphs.length && { subgraphs }), ...(Object.keys(graphPreserveAttrs).length > 0 && { attrs: graphPreserveAttrs, }), @@ -653,11 +811,12 @@ export function fromDOT(dot: string): Graph { return { id: root.id ?? '', - mode: isDirected ? 'directed' : 'undirected', - initialNodeId: null, + mode: privateGraph?.mode === 'bidirectional' && isDirected ? 'bidirectional' : (isDirected ? 'directed' : 'undirected'), + initialNodeId: privateGraph?.initialNodeId ?? null, nodes: [...nodeMap.values()], edges, - data: graphData, + data: privateGraph?.data !== undefined ? getPreservedData(privateGraph.data,graphPreserve) : graphData, + ...(privateGraph?.style !== undefined && { style: privateGraph.style }), ...(direction && { direction }), }; } @@ -686,3 +845,96 @@ export function fromDOT(dot: string): Graph { */ export const dotConverter: GraphFormatConverter = createFormatConverter(toDOT, fromDOT); + +/** Adapt the current node-list grammar to dotparser's older endpoint grammar. */ +function getNormalizedNodeLists(source: string): string { + const tokens: Array<{ text:string; start:number; end:number; square:number }> = []; + let pos=0, square=0; + while (pos') depth--; + } while (pos0); + } else if (source.startsWith('->',pos) || source.startsWith('--',pos)) pos+=2; + else if ('[]{},;=:'.includes(source[pos])) pos++; + else { + while (pos'.includes(source[pos]) && !source.startsWith('->',pos) && !source.startsWith('--',pos)) pos++; + if (pos===start) pos++; + } + const text=source.slice(start,pos); + tokens.push({text,start,end:pos,square}); + if (text==='[') square++; + if (text===']') square--; + } + const getEndpoint = (start:number): number => { + const token=tokens[start]; + if (!token || token.square!==0 || ['[',']','{','}',',',';','=','->','--',':'].includes(token.text)) return start; + let end=start+1; + while (token.text.startsWith('"') && tokens[end]?.text==='+' && tokens[end+1]?.text.startsWith('"')) end+=2; + for (let part=0;part<2 && tokens[end]?.text===':';part++) { + if (!tokens[end+1] || [',',';','=','->','--','{','}','[',']',':'].includes(tokens[end+1].text)) return start; + end+=2; + } + return end; + }; + const edits: Array<{start:number;end:number;text:string}> = []; + for (let i=0;i','--'].includes(previous ?? '') || ['->','--'].includes(following ?? '')) { + edits.push({start:tokens[i].start,end:tokens[end-1].end,text:`{ ${members.map((id)=>`${id};`).join(' ')} }`}); + } else if (['{','}',';',']'].includes(previous ?? '') && ['[',';','}'].includes(following ?? '')) { + const attrsStart=end; + while (tokens[end]?.text==='[') { + end++; + while (endattrsStart ? source.slice(tokens[attrsStart].start,tokens[end-1].end) : ''; + edits.push({start:tokens[i].start,end:tokens[end-1].end,text:members.map((id)=>`${id}${attrs}`).join('; ')}); + } + i=end-1; + } + let result=source; + for (const edit of edits.reverse()) result=result.slice(0,edit.start)+edit.text+result.slice(edit.end); + return result; +} + +/** Exact DOT source with a partial Graph view and conservative literal-label patches. */ +export const dotDocumentConverter = createGraphDocumentConverter({ + format: 'dot', codec: { from: fromDOT, to: toDOT }, + project: (source) => getDOTDocumentProjection(source, fromDOT), + patch: getDOTDocumentPatch, +}); diff --git a/src/formats/drawio/README.md b/src/formats/drawio/README.md new file mode 100644 index 0000000..a2e35c4 --- /dev/null +++ b/src/formats/drawio/README.md @@ -0,0 +1,77 @@ +# draw.io XML + + + +```ts +import { + fromDrawio, toDrawio, createDrawioDocumentConverter, +} from '@statelyai/graph/drawio'; + +const graph = fromDrawio(xml, { pageId: 'page-one' }); +const output = toDrawio(graph); // unchanged imported source +const fresh = toDrawio(graph, { nativeCarrier: false, compressed: true }); +const codec = createDrawioDocumentConverter({ pageId: 'page-one' }); +const document = codec.from(xml); +if (document.graph) document.graph.nodes[0].label = 'Updated'; +const edited = codec.to(document); +``` + +Install optional peers `fast-xml-parser` and `fflate`. The package root remains +independent of these peers. `drawioConverter` and `drawioDocumentConverter` provide +default conversion/document APIs. Public option types are `DrawioImportOptions`, +`DrawioExportOptions` and `DrawioDocumentOptions`. + + + +Accepts bare `mxGraphModel`, uncompressed `mxfile`, and base64/raw-DEFLATE/URI-encoded +pages. Multiple pages require explicit `pageId`; otherwise the document retains +all source with `graph: null` and diagnostics. Duplicate page matches reject. +Supplied and decoded XML strings are bounded to 16 × 1024² UTF-16 code +units; base64 payload length has the same limit. Inflated data is bounded to +16 MiB using a fixed output buffer. Projection validates XML, identities, references and parent cycles. + +Vertices/groups become Graph nodes. Layers and structural cells remain native +state; structural wire IDs `0`/`1` never become fabricated nodes. `UserObject` and +`object` attributes, raw HTML labels, style strings, relative vertex geometry and +edge label offsets remain available as native state. Ordinary vertex coordinates +are relative to their parent. Fractional relative vertex x/y are retained without +misrepresenting them as absolute coordinates. Edge waypoints map to `points`; +relative edge-label x/y/offsets remain native metadata, never an absolute label +rectangle. Dangling or non-vertex terminals reject projection. + +Direction defaults to `directed`; arrowheads are decorative. Explicit `mode` +selects a Graph mode. `{ mode: 'infer-arrows' }` opts into inference, including +reversing a start-only arrow, preserving two arrows as bidirectional and no arrows +as undirected. This is a caller policy, not native draw.io direction semantics. + +Fresh XML uses collision-safe node/edge wire IDs and versioned UserObject metadata +for original Graph IDs, data, ports, nullable labels and other Graph-only fields. +Visible native label/geometry/style changes take precedence over their recorded +baselines. `{ metadata: 'native' }` imports private markers as ordinary native +attributes. Deliberate indistinguishable marker collisions require explicit policy; +private extensions do not establish unrestricted external semantic losslessness. + + + +Document no-op output preserves **exact source text**, including comments, unknown +XML, unselected pages and compressed payloads, after JSON persistence. Original file encoding lies outside this string API. +Projection is partial. Safe patches update literal node/edge labels through verified XML +attribute spans. HTML/placeholder labels, structural edits, styles, data, geometry +and nullable-label requests reject with diagnostics. XML-invalid characters and +lone surrogates reject. Source comments/custom attributes survive independent Graph +label edits; same-field conflicts reject. Uncompressed edits retain all other string content; +compressed edits recompress only the selected page. Strict `.to` never replays +stale source for unsupported edits. Explicit regeneration via `.getUpdate(document, +{ mode: 'regenerate' })` reports loss and creates a new single-page file. + + + +Tests validate uncompressed generated XML with actual libxml2 and the pinned +official drawio-mcp XSD, then import models using maxgraph 0.25.0's actual +mxGraph-compatible codec. This proves model import compatibility, not operation of +the draw.io editor/renderer. Independent Node zlib inflation checks compressed +pages. The official generation XSD currently requires a child `mxGraphModel` and +rejects compressed diagram text despite documenting compressed pages; compressed +outer files therefore do not claim validity against that XSD. Their decoded models +have independent codec proof. A 10,000-node bulk-label regression exercises bounded +span editing without repeated full-string reconstruction. diff --git a/src/formats/drawio/index.ts b/src/formats/drawio/index.ts new file mode 100644 index 0000000..d9ba853 --- /dev/null +++ b/src/formats/drawio/index.ts @@ -0,0 +1,277 @@ +import { inflateSync, deflateSync, strFromU8, strToU8 } from 'fflate'; +import { XMLValidator } from 'fast-xml-parser'; +import { createFormatData } from '../xml-preservation'; +import type { Graph, GraphMode } from '../../types'; +import { createFormatConverter } from '../converter'; +import { createGraphDocumentConverter, type GraphDocumentPatchContext, type GraphDocumentPatchResult } from '../document'; +import { createMetadata, getMetadata, getRestoredRecord, createNativeCarrier, getNativeCarrier, getCarrierData, getClonedJSON, getNativeDocumentGraph, isJSONEqual, type FormatImportOptions, type FormatExportOptions } from '../preservation'; +import { getValidatedGraph } from '../structured-json'; +import { getXMLElements, getXMLAttribute, getDecodedXML, getEncodedXML, getEditedSource, getLabelChangeError, getUnsupported, type XMLElement, type TextEdit } from '../xml-document'; + +export interface DrawioImportOptions extends FormatImportOptions { pageId?: string; mode?: GraphMode | 'infer-arrows' } +export interface DrawioExportOptions extends FormatExportOptions { pageId?: string; compressed?: boolean } +export interface DrawioDocumentOptions extends DrawioImportOptions {} +const FORMAT = 'drawio'; +const MAX_MODEL_BYTES = 16 * 1024 * 1024; +interface DrawioPage { source: string; elements: XMLElement[]; model: number; outer: XMLElement | null; compressed: boolean; id: string } +interface DrawioCell { id: string; cell: XMLElement; wrapper: XMLElement; attributes: Record; user: Record; metadataKey?: string } +function getRecord(mapped: any, metadata?: ReturnType): any { + const record = getRestoredRecord(mapped, metadata); + if (metadata && !isJSONEqual(mapped.data, metadata.mapped.data)) record.data = createFormatData(metadata.original.data, FORMAT, { native: mapped.data }); + return record; +} + +function getInput(source: string, options: DrawioImportOptions) { return { source, options: getClonedJSON(options) }; } +function getAttribute(source: string, element: XMLElement, name: string): string | undefined { + const attribute = getXMLAttribute(element, name); + if (!attribute) return undefined; + const raw = source.slice(attribute.start, attribute.end); + if (/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[\da-fA-F]+);)/.test(raw)) throw new Error('draw.io: undeclared entities are unsupported'); + const value = getDecodedXML(raw.replace(/\r\n?/g, '\n').replace(/[\t\n]/g, ' ')); + getEncodedXML(value, attribute.quote); + return value; +} +function getAttributes(source: string, element: XMLElement) { + return Object.fromEntries(element.attributes.map((attribute) => [attribute.name, getAttribute(source, element, attribute.name)!])); +} +function getBase64(bytes: Uint8Array): string { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + let output = ''; + for (let index = 0; index < bytes.length; index += 3) { + const value = bytes[index] << 16 | (bytes[index + 1] ?? 0) << 8 | (bytes[index + 2] ?? 0); + output += alphabet[value >>> 18] + alphabet[value >>> 12 & 63] + (index + 1 < bytes.length ? alphabet[value >>> 6 & 63] : '=') + (index + 2 < bytes.length ? alphabet[value & 63] : '='); + } + return output; +} +function getBytes(source: string): Uint8Array { + const value = source.replace(/\s/g, ''); + if (value.length > MAX_MODEL_BYTES || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error('draw.io: invalid or oversized base64 page'); + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', bytes: number[] = []; + for (let index = 0; index < value.length; index += 4) { + const number = alphabet.indexOf(value[index]) << 18 | alphabet.indexOf(value[index + 1]) << 12 | Math.max(0, alphabet.indexOf(value[index + 2])) << 6 | Math.max(0, alphabet.indexOf(value[index + 3])); + bytes.push(number >>> 16 & 255); + if (value[index + 2] !== '=') bytes.push(number >>> 8 & 255); + if (value[index + 3] !== '=') bytes.push(number & 255); + } + return Uint8Array.from(bytes); +} +function getInflated(source: string): string { + // A fixed output buffer bounds decompression of untrusted native pages. + const output = inflateSync(getBytes(source), { out: new Uint8Array(MAX_MODEL_BYTES + 1) }); + if (output.length > MAX_MODEL_BYTES) throw new Error('draw.io: inflated page exceeds 16MiB'); + const value = decodeURIComponent(strFromU8(output)); + if (value.length > MAX_MODEL_BYTES) throw new Error('draw.io: decoded page exceeds 16MiB'); + return value; +} +function getCompressed(source: string): string { return getBase64(deflateSync(strToU8(encodeURIComponent(source)))); } +function getElements(source: string): XMLElement[] { + if (source.length > MAX_MODEL_BYTES || XMLValidator.validate(source) !== true) throw new Error('draw.io: malformed or oversized XML'); + const elements = getXMLElements(source); + for (const element of elements) if (element.uri === '') for (const attr of element.attributes) getAttribute(source, element, attr.name); + return elements; +} +function getPage(source: string, options: DrawioImportOptions): DrawioPage { + const elements = getElements(source), root = elements.find((element) => element.parent === null); + if (!root || root.uri !== '') throw new Error('draw.io: unsupported root namespace'); + if (root.name === 'mxGraphModel') return { source, elements, model: elements.indexOf(root), outer: null, compressed: false, id: options.pageId ?? '' }; + if (root.name !== 'mxfile') throw new Error('draw.io: expected mxfile or mxGraphModel'); + const pages = root.children.map((index) => elements[index]).filter((element) => element.name === 'diagram' && element.uri === ''); + if (pages.length === 0 || (pages.length > 1 && options.pageId === undefined)) throw new Error('draw.io: pageId required for a multiple-page projection'); + const selected = options.pageId === undefined ? pages : pages.filter((page) => getAttribute(source, page, 'id') === options.pageId); + if (selected.length !== 1) throw new Error('draw.io: pageId must select exactly one page'); + const outer = selected[0], models = outer.children.filter((index) => elements[index].name === 'mxGraphModel' && elements[index].uri === ''); + const id = getAttribute(source, outer, 'id') ?? ''; + if (models.length === 1 && outer.children.length === 1) return { source, elements, model: models[0], outer, compressed: false, id }; + if (outer.children.length) throw new Error('draw.io: unsupported diagram child structure'); + const modelSource = getInflated(source.slice(outer.openEnd, outer.closeStart)); + const modelElements = getElements(modelSource), model = modelElements.findIndex((element) => element.parent === null && element.name === 'mxGraphModel' && element.uri === ''); + if (model < 0) throw new Error('draw.io: compressed page is not an mxGraphModel'); + return { source: modelSource, elements: modelElements, model, outer, compressed: true, id }; +} +function getCells(page: DrawioPage): DrawioCell[] { + const roots = page.elements[page.model].children.filter((index) => page.elements[index].name === 'root' && page.elements[index].uri === ''); + if (roots.length !== 1) throw new Error('draw.io: expected one model root'); + const cells: DrawioCell[] = [], ids = new Set(); + for (const index of page.elements[roots[0]].children) { + const wrapper = page.elements[index]; + if (wrapper.uri !== '') continue; + let cell = wrapper; + if (wrapper.name === 'UserObject' || wrapper.name === 'object') { + const matches = wrapper.children.filter((child) => page.elements[child].name === 'mxCell' && page.elements[child].uri === ''); + if (matches.length !== 1) throw new Error('draw.io: ambiguous UserObject cell'); + cell = page.elements[matches[0]]; + } else if (wrapper.name !== 'mxCell') continue; + const attributes = getAttributes(page.source, cell), user = wrapper === cell ? {} : getAttributes(page.source, wrapper); + const id = user.id ?? attributes.id; + if (id === undefined || ids.has(id) || (user.id !== undefined && attributes.id !== undefined && user.id !== attributes.id)) throw new Error('draw.io: duplicate or missing cell identity'); + ids.add(id); cells.push({ id, cell, wrapper, attributes, user }); + } + if (!ids.has('0') || !ids.has('1')) throw new Error('draw.io: reserved structural cells 0 and 1 are required'); + const byId = new Map(cells.map((cell) => [cell.id, cell])); + const completed = new Set(); + for (const cell of cells) { + const visiting = new Set(); let current: DrawioCell | undefined = cell; + while (current && !completed.has(current.id)) { + if (visiting.has(current.id)) throw new Error('draw.io: cyclic parent references'); + visiting.add(current.id); + const parent: string | undefined = current.attributes.parent; + if (parent !== undefined && !byId.has(parent)) throw new Error('draw.io: missing parent reference'); + current = parent === undefined ? undefined : byId.get(parent); + } + for (const id of visiting) completed.add(id); + } + return cells; +} +function getStyle(value = ''): Record { + const style: Record = Object.create(null); + for (const part of value.split(';').filter(Boolean)) { const equals = part.indexOf('='); Object.defineProperty(style, equals < 0 ? part : part.slice(0, equals), { value: equals < 0 ? '1' : part.slice(equals + 1), enumerable: true, writable: true }); } + return style; +} +function getNumber(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const number = Number(value); + if (!Number.isFinite(number)) throw new Error('draw.io: nonfinite native geometry'); + return number; +} +function getWire(cell: DrawioCell, kind: string, native: boolean) { + if (native) return undefined; + for (const [key, value] of Object.entries(cell.user)) if (/^stately(?:\d+)?$/.test(key)) { + try { const metadata = getMetadata(JSON.parse(value), `${FORMAT}.${kind}`); if (metadata) { cell.metadataKey = key; return metadata; } } catch { /* Opaque custom attributes. */ } + } + return undefined; +} +function getMappedData(cell: DrawioCell) { + const attrs = Object.fromEntries(Object.entries(cell.attributes).filter(([key]) => !['id', 'value', 'parent', 'source', 'target', 'vertex', 'edge', 'style'].includes(key))); + const user = Object.fromEntries(Object.entries(cell.user).filter(([key]) => !['id', 'label', cell.metadataKey].includes(key))); + return { attributes: attrs, user }; +} +function getMode(style: Record): GraphMode { + const start = style.startArrow !== undefined && style.startArrow !== 'none', end = (style.endArrow ?? 'classic') !== 'none'; + return start && end ? 'bidirectional' : !start && !end ? 'undirected' : 'directed'; +} + +/** Selected-page projection; arrowheads remain decorative unless inference is explicitly requested. */ +export function fromDrawio(source: string, options: DrawioImportOptions = {}): Graph { + const page = getPage(source, options), cells = getCells(page), byId = new Map(cells.map((cell) => [cell.id, cell])); + const nodeCells = cells.filter((cell) => cell.attributes.vertex === '1'), edgeCells = cells.filter((cell) => cell.attributes.edge === '1'); + if (nodeCells.some((cell) => cell.attributes.edge === '1')) throw new Error('draw.io: a cell cannot be both vertex and edge'); + const nodeIDs = new Set(nodeCells.map((cell) => cell.id)); + const native = options.metadata === 'native', mode = options.mode === undefined || options.mode === 'infer-arrows' ? 'directed' : options.mode; + const records = new Map(); + const nodes = nodeCells.map((cell) => { + const metadata = getWire(cell, 'node', native), style = getStyle(cell.attributes.style); + const geometryIndex = cell.cell.children.find((index) => page.elements[index].name === 'mxGeometry' && page.elements[index].uri === ''); + const geometry = geometryIndex === undefined ? {} : getAttributes(page.source, page.elements[geometryIndex]); + const mapped: any = { type: 'node', id: cell.id, parentId: nodeIDs.has(cell.attributes.parent) ? cell.attributes.parent : null, initialNodeId: null, + label: cell.user.label ?? cell.attributes.value ?? null, data: getMappedData(cell), style, + ...(style.fillColor !== undefined && { color: style.fillColor }), ...(style.shape !== undefined && { shape: style.shape }) }; + for (const field of ['x', 'y', 'width', 'height']) if (geometry[field] !== undefined && (!(field === 'x' || field === 'y') || geometry.relative !== '1')) mapped[field] = getNumber(geometry[field]); + if (geometry.relative === '1') mapped.data.relativeGeometry = geometry; + const node = getRecord(mapped, metadata); records.set(cell.id, node); return node; + }); + for (const cell of nodeCells) if (records.has(cell.attributes.parent)) records.get(cell.id).parentId = records.get(cell.attributes.parent).id; + const edges = edgeCells.map((cell) => { + const metadata = getWire(cell, 'edge', native), style = getStyle(cell.attributes.style); + let sourceId = records.get(cell.attributes.source)?.id, targetId = records.get(cell.attributes.target)?.id; + if (sourceId === undefined || targetId === undefined) throw new Error('draw.io: dangling or non-vertex edge terminals have no binary projection'); + const edgeMode = options.mode === 'infer-arrows' ? getMode(style) : mode; + if (options.mode === 'infer-arrows' && style.startArrow !== undefined && style.startArrow !== 'none' && style.endArrow === 'none') [sourceId, targetId] = [targetId, sourceId]; + const mapped: any = { type: 'edge', id: cell.id, sourceId, targetId, mode: edgeMode, label: cell.user.label ?? cell.attributes.value ?? null, data: getMappedData(cell), style, + ...(style.strokeColor !== undefined && { color: style.strokeColor }) }; + const geometryIndex = cell.cell.children.find((index) => page.elements[index].name === 'mxGeometry' && page.elements[index].uri === ''); + if (geometryIndex !== undefined) { + const geometry = page.elements[geometryIndex]; + mapped.data.labelGeometry = getAttributes(page.source, geometry); + const arrays = geometry.children.filter((index) => page.elements[index].name === 'Array' && getAttribute(page.source, page.elements[index], 'as') === 'points'); + if (arrays.length > 1) throw new Error('draw.io: ambiguous waypoint arrays'); + if (arrays.length) mapped.points = page.elements[arrays[0]].children.map((index) => { const point = page.elements[index]; if (point.name !== 'mxPoint' || point.uri !== '') throw new Error('draw.io: unsupported waypoint'); return { x: getNumber(getAttribute(page.source, point, 'x')) ?? 0, y: getNumber(getAttribute(page.source, point, 'y')) ?? 0 }; }); + } + return getRecord(mapped, metadata); + }); + const rootCell = byId.get('0')!, rootMetadata = getWire(rootCell, 'graph', native); + const root = getRecord({ id: page.id, mode, initialNodeId: null, data: getMappedData(rootCell) }, rootMetadata); + const graph: Graph = { ...root, nodes, edges }; + getValidatedGraph(graph, FORMAT); graph.data = createNativeCarrier(graph.data, FORMAT, getInput(source, options), graph); + return graph; +} +function getStyleString(entity: any, edge: boolean, graph: Graph): string { + const style = { ...(entity.style ?? {}), html: 0 }; + if (entity.color !== undefined) style[edge ? 'strokeColor' : 'fillColor'] = entity.color; + if (!edge && entity.shape !== undefined) style.shape = entity.shape; + if (edge) { const mode = entity.mode ?? graph.mode; style.startArrow = mode === 'bidirectional' ? 'classic' : 'none'; style.endArrow = mode === 'undirected' ? 'none' : 'classic'; } + return Object.entries(style).map(([key, value]) => { + if (/[;=]/.test(key) || /;/.test(String(value))) throw new Error('draw.io: native style keys/values cannot contain separators'); + return `${key}=${typeof value === 'boolean' ? Number(value) : value};`; + }).join(''); +} +function getAttributeText(attributes: Record): string { + return Object.entries(attributes).filter(([, value]) => value !== undefined).map(([key, value]) => ` ${key}="${getEncodedXML(String(value), '"')}"`).join(''); +} +function toRawDrawio(graph: Graph, options: DrawioExportOptions): string { + getValidatedGraph(graph, FORMAT); + const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`])); + const root: any = { id: '0' }, { nodes: _nodes, edges: _edges, ...rootEntity } = graph; + const cells: Array<{ kind: 'graph' | 'node' | 'edge'; entity: any; wire: string }> = [{ kind: 'graph', entity: rootEntity, wire: '' }]; + for (const node of graph.nodes) { + if (node.parentId != null && !ids.has(node.parentId)) throw new Error('draw.io: missing parent'); + const geometry: Record = { as: 'geometry' }; + for (const key of ['x', 'y', 'width', 'height']) if ((node as any)[key] !== undefined) { const value = (node as any)[key]; if (!Number.isFinite(value)) throw new Error('draw.io: nonfinite Graph geometry'); geometry[key] = value; } + cells.push({ kind: 'node', entity: node, wire: `` }); + } + graph.edges.forEach((edge, index) => { + const points = edge.points?.map((point) => { if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) throw new Error('draw.io: nonfinite waypoint'); return ``; }).join(''); + cells.push({ kind: 'edge', entity: edge, wire: `${points === undefined ? '' : `${points}`}` }); + }); + const pageId = options.pageId ?? 'stately', model = `${cells[0].wire}${cells.slice(1).map((cell) => cell.wire).join('')}`; + const mapped = fromDrawio(model, { metadata: 'native' }); + const mappedRoot = { ...mapped }; delete (mappedRoot as any).nodes; delete (mappedRoot as any).edges; mappedRoot.data = getCarrierData(mapped.data, FORMAT); + mappedRoot.id = pageId; + const rootWire = ``; + let nodeIndex = 0, edgeIndex = 0; + const wires = cells.slice(1).map((cell) => { + const before = cell.kind === 'node' ? mapped.nodes[nodeIndex++] : mapped.edges[edgeIndex++]; + const encoded = getEncodedXML(JSON.stringify(createMetadata(`${FORMAT}.${cell.kind}`, cell.entity, before)), '"'); + return cell.wire.replace('${rootWire}${wires.join('')}`; + return `${options.compressed ? getCompressed(result) : result}`; +} +function getPageOutput(outerSource: string, page: DrawioPage, inner: string): string { + if (!page.outer) return inner; + if (page.compressed) return getEditedSource(outerSource, [{ start: page.outer.openEnd, end: page.outer.closeStart, text: getCompressed(inner) }]); + return inner; // Uncompressed span positions already address the complete source. +} +function getDrawioPatch(context: GraphDocumentPatchContext, options: DrawioImportOptions): GraphDocumentPatchResult { + const invalid = getLabelChangeError(context.changes); if (invalid) return invalid; + const page = getPage(context.source, options), cells = getCells(page), imported = fromDrawio(context.source, options); + const nodeCells = cells.filter((cell) => cell.attributes.vertex === '1'), edgeCells = cells.filter((cell) => cell.attributes.edge === '1'); + const importedNodes = new Map(imported.nodes.map((node, index) => [node.id, nodeCells[index]])); + const importedEdges = new Map(imported.edges.map((edge, index) => [edge.id, edgeCells[index]])); + const edits: TextEdit[] = []; + for (const change of context.changes) { + const cell = (change.entity === 'node' ? importedNodes : importedEdges).get(change.id!); + if (!cell) return getUnsupported(change, 'draw.io: explicit unique cell identity required'); + const style = getStyle(cell.attributes.style); + if (style.html === '1' || cell.user.placeholders === '1' || cell.attributes.placeholders === '1') return getUnsupported(change, 'draw.io: HTML/placeholder label edits are ambiguous'); + const target = cell.wrapper === cell.cell ? cell.cell : cell.wrapper, name = cell.wrapper === cell.cell ? 'value' : 'label'; + const attribute = getXMLAttribute(target, name), value = getEncodedXML(change.after as string, attribute?.quote ?? '"'); + edits.push(attribute ? { start: attribute.start, end: attribute.end, text: value } : { start: target.openEnd - (target.empty ? 2 : 1), end: target.openEnd - (target.empty ? 2 : 1), text: ` ${name}="${value}"` }); + } + return { ok: true, source: getPageOutput(context.source, page, getEditedSource(page.source, edits)) }; +} +/** Fresh XML uses collision-safe structural IDs; imported sources support safe labels only. */ +export function toDrawio(graph: Graph, options: DrawioExportOptions = {}): string { + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, FORMAT); + if (!carrier) return toRawDrawio(graph, options); + const current = { ...graph, data: getCarrierData(graph.data, FORMAT) }, native = carrier.native as ReturnType; + const document = createDrawioDocumentConverter(native.options).from(native.source); + document.graph = current; + return createDrawioDocumentConverter(native.options).to(document); +} +export function createDrawioDocumentConverter(options: DrawioDocumentOptions = {}) { + return createGraphDocumentConverter({ format: FORMAT, codec: { from: (source: string) => fromDrawio(source, options), to: (graph: Graph) => toDrawio(graph, { nativeCarrier: false }) }, + project: (source: string) => ({ graph: getNativeDocumentGraph(fromDrawio(source, options), FORMAT, getInput(source, options)), diagnostics: [{ code: 'projection-partial' as const, severity: 'warning' as const, message: 'Selected draw.io page projection; layers, native HTML and relative edge-label geometry remain source state' }] }), + patch: (context) => getDrawioPatch(context, options) }); +} +export const drawioConverter = createFormatConverter(toDrawio, fromDrawio); +export const drawioDocumentConverter = createDrawioDocumentConverter(); diff --git a/src/formats/edge-list/README.md b/src/formats/edge-list/README.md index cc335ed..c5732a8 100644 --- a/src/formats/edge-list/README.md +++ b/src/formats/edge-list/README.md @@ -2,6 +2,16 @@ Converter for the edge list format — a simple `[source, target][]` array of tuples. +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **full**. +Source document preservation: **none**. These are separate guarantees; +selected-field round-trip tests do not prove unrestricted losslessness. + +The minimal tuple representation preserves endpoint tuples and order, while omitting isolated nodes, edge IDs, labels, metadata and geometry. Import validates endpoint tuples; this format cannot provide a full Graph round-trip. + ## Resources - [Edge list (Wikipedia)](https://en.wikipedia.org/wiki/Edge_list) @@ -11,7 +21,7 @@ Converter for the edge list format — a simple `[source, target][]` array of tu ```ts -import { toEdgeList, fromEdgeList, edgeListConverter } from '@statelyai/graph/edge-list'; +import { toEdgeList, fromEdgeList } from '@statelyai/graph/edge-list'; import { createGraph } from '@statelyai/graph'; // Export @@ -64,6 +74,21 @@ Nodes are auto-created from edge endpoints. Node/edge `data` and `label` are not Pre-built `GraphFormatConverter<[string, string][]>`: ```ts +import { edgeListConverter } from '@statelyai/graph/converter'; + const edges = edgeListConverter.to(graph); const graph = edgeListConverter.from(edges); ``` + + +## Native documents + + + +`edgeListDocumentConverter.from(source)` creates a plain JSON document with the +original source and a separate Graph projection. `.to(document)` preserves exact +source when unchanged, including after JSON serialization. Supported edits patch +verified native fields; unsupported edits throw with diagnostics. `.getUpdate(document)` +returns a diagnostic-bearing result. Native semantic projection remains partial. + +Original tuple order and duplicate entries stay intact. Graph edits reject because endpoint tuples cannot encode Graph labels, data, geometry or stable entity IDs. diff --git a/src/formats/edge-list/index.ts b/src/formats/edge-list/index.ts index 7a31e22..e1f01db 100644 --- a/src/formats/edge-list/index.ts +++ b/src/formats/edge-list/index.ts @@ -1,3 +1,5 @@ +import { createGraphDocumentConverter } from '../document'; +import { getStructuredDocumentProjectionGraph, getStructuredDocumentPatch } from '../preservation'; import type { Graph } from '../../types'; /** @@ -40,6 +42,10 @@ export function fromEdgeList( edges: [string, string][], options?: { directed?: boolean; id?: string }, ): Graph { + if (!Array.isArray(edges) || edges.some((edge) => !Array.isArray(edge) || edge.length !== 2 || + edge.some((id) => typeof id !== 'string' || !id))) { + throw new Error('Edge list: expected pairs of non-empty node ids'); + } const directed = options?.directed ?? true; const nodeIds = new Set(); @@ -75,3 +81,12 @@ export function fromEdgeList( data: undefined as any, }; } + + +/** Exact native source preservation, with strict verified field edits. */ +export const edgeListDocumentConverter = createGraphDocumentConverter({ + format: 'edge-list', + codec: { from: (source: Array<[string, string]>) => fromEdgeList(source), to: toEdgeList }, + project: (source: Array<[string, string]>) => ({ graph: getStructuredDocumentProjectionGraph(fromEdgeList(source), source, 'edge-list') }), + patch: (context) => getStructuredDocumentPatch(context, 'edge-list'), +}); diff --git a/src/formats/elk/README.md b/src/formats/elk/README.md index dd80367..ed17090 100644 --- a/src/formats/elk/README.md +++ b/src/formats/elk/README.md @@ -2,6 +2,24 @@ Converter for [ELK](https://eclipse.dev/elk/) (Eclipse Layout Kernel) JSON — the layout format consumed by [`elkjs`](https://github.com/kieler/elkjs). Works with `VisualGraph` since ELK expects/produces positions and sizes. +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **partial**. +Source document preservation: **none**. These are separate guarantees; +selected-field round-trip tests do not prove unrestricted losslessness. + +Imports extended and primitive edges, ports, labels and sections. Primitive edges normalize to extended output, with new routing-section IDs qualified against existing native elements. Export allocates globally unique node, root, port and edge IDs, retaining ordinary port IDs when available. Hyperedges are rejected explicitly. Versioned metadata preserves Graph field presence and authored order; native snapshots retain additional labels, sections and options. The imported first section retains its native ID and additional keyed sections when geometry changes. Editing arrays without stable entity IDs can replace their native details; this is not a document editing API. + + + + +Native snapshots use a private JSON convention on Graph data. Import options +`{ metadata: 'native' }` disable adapter marker interpretation. A foreign value +that deliberately matches the marker schema remains ambiguous in automatic mode. +Comments, lexical formatting and source locations are not preserved. + ## Resources - [ELK documentation](https://eclipse.dev/elk/documentation.html) @@ -36,7 +54,7 @@ Parent/child hierarchy maps to ELK's nested `children`. Edges whose source and t ### Ports -`VisualPort.name` maps to ELK `port.id`. `direction` maps to `org.eclipse.elk.port.side`: +`VisualPort.name` is qualified as `nodeId__portName` for ELK `port.id`; the original name is stored in metadata. This qualification can collide. `direction` maps to `org.eclipse.elk.port.side`: | `direction` | ELK `port.side` | | --- | --- | @@ -66,3 +84,21 @@ const roundTripped = elkConverter.from(elk); | `ElkNode`, `ElkExtendedEdge`, `ElkPort`, `ElkLabel`, `LayoutOptions`, … | Re-exported from `elkjs/lib/elk-api` for convenience | > **Peer dependency:** `elkjs` (optional) — install it to actually run layouts; the converter itself only depends on the types. + + +## Native documents + + + +`elkDocumentConverter.from(source)` creates a plain JSON document with the +original source and a separate Graph projection. `.to(document)` preserves exact +source when unchanged, including after JSON serialization. Supported edits patch +verified native fields; unsupported edits throw with diagnostics. `.getUpdate(document)` +returns a diagnostic-bearing result. Native semantic projection remains partial. + +Primitive edges retain their original representation. Labels, node/port geometry and the imported first routing section can be edited while retaining additional labels/sections/options. Hyperedge sources are retained with a null Graph projection. Structural edits and unsupported Graph data/style fields reject. + +Bare exports interpret recognized import carriers by default. Use +`{ nativeCarrier: false }` when Graph data is user-authored and could match the +private carrier schema. Document projection pins importer state to its external +source before unwrapping it, preserving foreign lookalikes. diff --git a/src/formats/elk/index.ts b/src/formats/elk/index.ts index ecca36a..447c4ab 100644 --- a/src/formats/elk/index.ts +++ b/src/formats/elk/index.ts @@ -1,505 +1,328 @@ -import type { - ElkNode, - ElkExtendedEdge, - ElkLabel, - ElkPort, -} from 'elkjs/lib/elk-api'; -import type { - EdgeRouting, - Graph, - VisualGraph, - VisualNode, - VisualEdge, - VisualPort, - VisualGraphFormatConverter, -} from '../../types'; -import { getChildren } from '../../queries'; - -const STATELYAI_METADATA_KEY = 'statelyai.metadata'; - -interface ElkMetadata { - graph?: Partial; - node?: Partial; - port?: Partial; - edge?: Partial; +import type { FormatExportOptions } from '../preservation'; +import { createGraphDocumentConverter } from '../document'; +import { getStructuredDocumentProjectionGraph, getStructuredDocumentPatch } from '../preservation'; +import type { ElkNode, ElkExtendedEdge, ElkPrimitiveEdge, ElkPort } from 'elkjs/lib/elk-api'; +import type { VisualGraph, VisualNode, VisualEdge, VisualPort, VisualGraphFormatConverter, EdgeRouting } from '../../types'; +import { createMetadata, createNativeCarrier, getCarrierData, getClonedJSON, getMetadata, + getNativeCarrier, getRestoredRecord, getMergedNative, isJSONEqual, type FormatMetadata, type FormatImportOptions } from '../preservation'; +export type { ElkNode, ElkExtendedEdge, ElkEdge, ElkEdgeSection, ElkLabel, ElkPort, ElkPoint, ElkShape, + ElkGraphElement, ElkPrimitiveEdge, LayoutOptions } from 'elkjs/lib/elk-api'; +export interface ELKImportOptions extends FormatImportOptions {} +/** Native ELK input also permits the legacy primitive edge representation. */ +export interface ELKInputNode extends Omit { + edges?: Array; + children?: ELKInputNode[]; } - -function addMetadata }>( - target: T, - metadata: ElkMetadata, -): T { - target.layoutOptions = { - ...(target.layoutOptions ?? {}), - [STATELYAI_METADATA_KEY]: JSON.stringify(metadata), - }; - return target; +const METADATA_KEY = 'statelyai.metadata'; +const TO_DIRECTION: Record = { down: 'DOWN', up: 'UP', right: 'RIGHT', left: 'LEFT' }; +const FROM_DIRECTION: Record = { DOWN: 'down', UP: 'up', RIGHT: 'right', LEFT: 'left' }; +function getFields(value: any, fields: string[]): any { + return Object.fromEntries(fields.filter((key) => value && Object.hasOwn(value, key)).map((key) => [key, value[key]])); } - -function readMetadata(value: { - layoutOptions?: Record; -}): ElkMetadata | undefined { - const raw = value.layoutOptions?.[STATELYAI_METADATA_KEY]; - if (typeof raw !== 'string') return undefined; - try { - return JSON.parse(raw) as ElkMetadata; - } catch { - return undefined; - } +function getWire(value: { layoutOptions?: Record }, kind: string, options: ELKImportOptions): { metadata?: FormatMetadata; legacy?: any; raw?: any } { + if (options.metadata === 'native') return {}; + const encoded = value.layoutOptions?.[METADATA_KEY]; + if (typeof encoded !== 'string') return {}; + let raw: any; + try { raw = JSON.parse(encoded); } catch { return {}; } + const metadata = getMetadata(raw, `elk.${kind}`); + if (metadata) return { metadata, raw }; + const legacy = !raw.kind && raw[kind] && typeof raw[kind] === 'object' && Object.keys(raw).every((key) => key === kind) ? raw[kind] : undefined; + return { legacy, raw }; } - -export type { - ElkNode, - ElkExtendedEdge, - ElkEdge, - ElkEdgeSection, - ElkLabel, - ElkPort, - ElkPoint, - ElkShape, - ElkGraphElement, - ElkPrimitiveEdge, - LayoutOptions, -} from 'elkjs/lib/elk-api'; - -// --- Direction mapping --- - -const DIRECTION_TO_ELK: Record = { - down: 'DOWN', - up: 'UP', - right: 'RIGHT', - left: 'LEFT', -}; - -const ELK_TO_DIRECTION: Record = { - DOWN: 'down', - UP: 'up', - RIGHT: 'right', - LEFT: 'left', -}; - -// --- toELK --- - -function convertEdge(edge: VisualEdge): ElkExtendedEdge { - const elkEdge: ElkExtendedEdge = { - id: edge.id, - sources: [ - edge.sourcePort != null - ? `${edge.sourceId}__${edge.sourcePort}` - : edge.sourceId, - ], - targets: [ - edge.targetPort != null - ? `${edge.targetId}__${edge.targetPort}` - : edge.targetId, - ], - }; - if (edge.label) { - // Edge width/height are canonically the label rect — pass them to ELK as - // label dimensions so the engine can place the label - elkEdge.labels = [ - { - text: edge.label, - ...(edge.width > 0 && { width: edge.width }), - ...(edge.height > 0 && { height: edge.height }), - }, - ]; - } - return addMetadata(elkEdge, { - edge: { - sourceId: edge.sourceId, - targetId: edge.targetId, - sourcePort: edge.sourcePort, - targetPort: edge.targetPort, - label: edge.label, - mode: edge.mode, - data: edge.data, - weight: edge.weight, - color: edge.color, - style: edge.style, - x: edge.x, - y: edge.y, - width: edge.width, - height: edge.height, - points: edge.points, - routing: edge.routing, - }, - }); +function addWire(value: { layoutOptions?: Record }, kind: string, original: object, mapped: object, extra = {}): void { + value.layoutOptions = { ...value.layoutOptions, [METADATA_KEY]: JSON.stringify({ ...createMetadata(`elk.${kind}`, original, mapped), ...extra }) }; } - -function convertPort(nodeId: string, port: VisualPort): ElkPort { - // ELK requires document-unique ids; bare port names are only unique per - // node, so qualify with the owning node id. The original name round-trips - // through metadata. - const elkPort: ElkPort = { - id: `${nodeId}__${port.name}`, - x: port.x, - y: port.y, - width: port.width, - height: port.height, - }; - if (port.label) { - elkPort.labels = [{ text: port.label }]; - } - if (port.direction !== 'inout') { - elkPort.layoutOptions = { - 'org.eclipse.elk.port.side': - port.direction === 'in' ? 'WEST' : 'EAST', - }; - } - return addMetadata(elkPort, { - port: { - name: port.name, - data: port.data, - style: port.style, - }, - }); +function getElkRoot(root: Pick, preserved: any = {}): Omit { + const direction = String(root.layoutOptions?.['elk.direction'] ?? root.layoutOptions?.['org.eclipse.elk.direction'] ?? '').toUpperCase(); + return { id: root.id, mode: preserved.mode ?? 'directed', initialNodeId: preserved.initialNodeId ?? null, + data: preserved.data, direction: FROM_DIRECTION[direction] ?? preserved.direction ?? 'down', ...getFields(preserved, ['style']) }; } - -function convertNode(graph: VisualGraph, node: VisualNode): ElkNode { - const elkNode: ElkNode = { - id: node.id, - x: node.x, - y: node.y, - width: node.width, - height: node.height, +function getElkPort(port: ElkPort, preserved: any = {}): VisualPort { + const side = port.layoutOptions?.['org.eclipse.elk.port.side'] ?? port.layoutOptions?.['elk.port.side']; + return { name: preserved.name ?? port.id, direction: side === 'WEST' ? 'in' : side === 'EAST' ? 'out' : 'inout', + ...(port.labels?.[0]?.text !== undefined && { label: port.labels[0].text }), + data: preserved.data, x: port.x ?? 0, y: port.y ?? 0, width: port.width ?? 0, height: port.height ?? 0, + ...getFields(preserved, ['style']) }; +} +function getElkNode(node: ELKInputNode, parentId: string | null, preserved: any = {}): VisualNode { + return { type: 'node', id: node.id, parentId, initialNodeId: preserved.initialNodeId ?? null, + label: node.labels?.[0]?.text ?? '', data: preserved.data, + x: node.x ?? 0, y: node.y ?? 0, width: node.width ?? 0, height: node.height ?? 0, + ...getFields(preserved, ['shape', 'color', 'style']) }; +} +function getRouting(value: any, fallback: EdgeRouting = 'orthogonal'): EdgeRouting { + const option = String(value?.layoutOptions?.['elk.edgeRouting'] ?? value?.layoutOptions?.['org.eclipse.elk.edgeRouting'] ?? '').toUpperCase(); + return option === 'SPLINES' ? 'splines' : option === 'POLYLINE' ? 'polyline' : option === 'ORTHOGONAL' ? 'orthogonal' : fallback; +} +function getElkEdge(edge: ElkExtendedEdge | ElkPrimitiveEdge, index: number, portOwners: Map, nodeIds: Map, routing: EdgeRouting, preserved: any = {}, legacy = false): VisualEdge { + const extended = edge as ElkExtendedEdge; + const primitive = edge as ElkPrimitiveEdge; + if (extended.sources?.length > 1 || extended.targets?.length > 1) throw new Error('ELK: hyperedge cannot be represented by Graph; import a document instead'); + const source = extended.sources ? extended.sources[0] : primitive.source; + const target = extended.targets ? extended.targets[0] : primitive.target; + if (typeof source !== 'string' || typeof target !== 'string') throw new Error('ELK: edge requires one source and one target'); + const sourcePort = portOwners.get(primitive.sourcePort ?? source); + const targetPort = portOwners.get(primitive.targetPort ?? target); + const label = edge.labels?.[0]; + const section = extended.sections?.[0]; + const start = section?.startPoint ?? primitive.sourcePoint; + const end = section?.endPoint ?? primitive.targetPoint; + const bends = section?.bendPoints ?? primitive.bendPoints; + return { type: 'edge', id: edge.id ?? `e${index}`, + sourceId: legacy && preserved.sourceId !== undefined ? preserved.sourceId : sourcePort?.nodeId ?? nodeIds.get(source) ?? source, + targetId: legacy && preserved.targetId !== undefined ? preserved.targetId : targetPort?.nodeId ?? nodeIds.get(target) ?? target, + label: label?.text ?? '', data: preserved.data, + x: label?.x ?? preserved.x ?? 0, y: label?.y ?? preserved.y ?? 0, + width: label?.width ?? preserved.width ?? 0, height: label?.height ?? preserved.height ?? 0, + ...getFields(preserved, ['mode', 'weight', 'color', 'style', 'routing']), + ...(start && end ? { points: [start, ...(bends ?? []), end].map((point) => ({ x: point.x, y: point.y })), routing: getRouting(edge, routing) } + : getFields(preserved, ['points'])), + ...(sourcePort ? { sourcePort: sourcePort.portName } : getFields(preserved, ['sourcePort'])), + ...(targetPort ? { targetPort: targetPort.portName } : getFields(preserved, ['targetPort'])), }; - if (node.label) { - elkNode.labels = [{ text: node.label }]; - } - if (node.ports && node.ports.length > 0) { - elkNode.ports = node.ports.map((port) => convertPort(node.id, port)); - } - addMetadata(elkNode, { - node: { - initialNodeId: node.initialNodeId, - data: node.data, - shape: node.shape, - color: node.color, - style: node.style, - }, - }); - - const children = getChildren(graph as Graph, node.id) as VisualNode[]; - if (children.length > 0) { - elkNode.children = children.map((child) => convertNode(graph, child)); - - // Edges where both source and target are within this compound node - const descendantIds = new Set(); - collectDescendants(graph, node.id, descendantIds); - const innerEdges = graph.edges.filter( - (e) => descendantIds.has(e.sourceId) && descendantIds.has(e.targetId), - ); - if (innerEdges.length > 0) { - elkNode.edges = innerEdges.map(convertEdge); - } - } - - return elkNode; } -function collectDescendants( - graph: VisualGraph, - nodeId: string, - set: Set, -): void { - const children = getChildren(graph as Graph, nodeId); - for (const child of children) { - set.add(child.id); - collectDescendants(graph, child.id, set); +/** Export unique document-wide IDs and one copy of each edge at its containing ancestor. */ +export interface ELKExportOptions extends FormatExportOptions {} + +export function toELK(graph: VisualGraph, options: ELKExportOptions = {}): ElkNode { + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, 'elk'); + if (carrier) { + const before = toRawELK(carrier.baseline); + const after = toRawELK({ ...graph, data: getCarrierData(graph.data, 'elk') }); + const nativeNodes = new Map(); + const queue: ELKInputNode[] = [carrier.native]; + for (let i = 0; i < queue.length; i++) { + nativeNodes.set(queue[i].id, queue[i]); queue.push(...(queue[i].children ?? [])); + } + const ports = new Map(); + const firstSections = new Map(); + for (const node of nativeNodes.values()) for (const edge of node.edges ?? []) { + const id = (edge as ElkExtendedEdge).sections?.[0]?.id; + if (id !== undefined) firstSections.set(edge.id, id); + } + const projected: ElkNode[] = [before]; + for (let i = 0; i < projected.length; i++) { + const node = projected[i]; + const original = nativeNodes.get(node.id); + for (const port of node.ports ?? []) { + const wire = getWire(port, 'port', {}).metadata; + const nativePort = original?.ports?.find((value) => value.id === wire?.original.name); + if (nativePort) ports.set(port.id, nativePort.id); + } + projected.push(...(node.children ?? [])); + } + for (const root of [before, after]) { + root.id = carrier.native.id; + const nodes: ElkNode[] = [root]; + for (let i = 0; i < nodes.length; i++) { + for (const port of nodes[i].ports ?? []) { + const id = ports.get(port.id); + if (id) { + port.id = id; + const wire = JSON.parse(port.layoutOptions![METADATA_KEY]); + wire.nativeId = id; port.layoutOptions![METADATA_KEY] = JSON.stringify(wire); + } + } + for (const edge of nodes[i].edges ?? []) { + if (edge.sections?.[0] && firstSections.has(edge.id)) edge.sections[0].id = firstSections.get(edge.id)!; + edge.sources = edge.sources.map((id) => ports.get(id) ?? id); + edge.targets = edge.targets.map((id) => ports.get(id) ?? id); + } + nodes.push(...(nodes[i].children ?? [])); + } + } + const merged = getMergedNative(carrier.native, before, after) as ELKInputNode; + const usedIds = new Set(); + const elements: any[] = [merged]; + for (let i = 0; i < elements.length; i++) { + if (typeof elements[i].id === 'string') usedIds.add(elements[i].id); + for (const collection of ['children', 'ports', 'edges', 'sections', 'labels']) elements.push(...(elements[i][collection] ?? [])); + } + function createSectionId(preferred: string): string { + let id = preferred; + for (let suffix = 1; usedIds.has(id); suffix++) id = `${preferred}__statelyai${suffix}`; + usedIds.add(id); return id; + } + const normalized: ELKInputNode[] = [merged]; + for (let i = 0; i < normalized.length; i++) { + normalized[i].edges = normalized[i].edges?.map((edge) => { + if ('sources' in edge) return edge; + const { source, target, sourcePort, targetPort, sourcePoint, targetPoint, bendPoints, ...rest } = edge; + return { ...rest, sources: [sourcePort ?? source], targets: [targetPort ?? target], + ...(sourcePoint && targetPoint && !(rest as ElkExtendedEdge).sections && { sections: [{ id: createSectionId(`${edge.id}__section`), + startPoint: sourcePoint, endPoint: targetPoint, ...(bendPoints && { bendPoints }) }] }) } as ElkExtendedEdge; + }); + normalized.push(...(normalized[i].children ?? [])); + } + return merged as ElkNode; } + return toRawELK(graph); } -/** - * Converts a visual graph to ELK JSON format. - * - * @example - * ```ts - * import { createVisualGraph } from '@statelyai/graph'; - * import { toELK } from '@statelyai/graph/elk'; - * - * const graph = createVisualGraph({ - * nodes: [ - * { id: 'a', x: 0, y: 0, width: 100, height: 50 }, - * { id: 'b', x: 200, y: 0, width: 100, height: 50 }, - * ], - * edges: [{ id: 'e1', sourceId: 'a', targetId: 'b' }], - * }); - * - * const elk = toELK(graph); - * // { id: '', children: [...], edges: [...] } - * ``` - */ -export function toELK(graph: VisualGraph): ElkNode { - const root: ElkNode = { id: graph.id }; - - const elkDir = DIRECTION_TO_ELK[graph.direction]; - if (elkDir) { - root.layoutOptions = { 'elk.direction': elkDir }; +function toRawELK(graph: VisualGraph): ElkNode { + const used = new Set(graph.nodes.map((node) => node.id)); + if (used.size !== graph.nodes.length) throw new Error('ELK: duplicate node ids'); + function createId(preferred: string): string { + let id = preferred; + for (let suffix = 1; used.has(id); suffix++) id = `${preferred}__statelyai${suffix}`; + used.add(id); + return id; } - addMetadata(root, { - graph: { - id: graph.id, - mode: graph.mode, - initialNodeId: graph.initialNodeId, - data: graph.data, - direction: graph.direction, - style: graph.style, - }, - }); - - // Root-level nodes (no parent) - const roots = getChildren(graph as Graph, null) as VisualNode[]; - if (roots.length > 0) { - root.children = roots.map((node) => convertNode(graph, node)); + const root: ElkNode = { id: createId(graph.id), layoutOptions: { 'elk.direction': TO_DIRECTION[graph.direction] ?? 'DOWN' } }; + const edgeIds = graph.edges.map((edge) => createId(edge.id)); + const nativeNodes = new Map(); + const parentIds = new Map(graph.nodes.map((node) => [node.id, node.parentId ?? null])); + const portIds = new Map(); + const owners = new Map(); + for (const node of graph.nodes) { + const native: ElkNode = { id: node.id, ...getFields(node, ['x', 'y', 'width', 'height']), + ...(typeof node.label === 'string' && { labels: [{ text: node.label }] }) }; + if (Object.hasOwn(node, 'ports')) native.ports = (node.ports ?? []).map((port) => { + const id = createId(`${node.id}__${port.name}`); + portIds.set(JSON.stringify([node.id, port.name]), id); + owners.set(id, { nodeId: node.id, portName: port.name }); + const nativePort: ElkPort = { id, ...getFields(port, ['x', 'y', 'width', 'height']), + ...(port.label !== undefined && { labels: [{ text: port.label }] }), + ...(port.direction !== 'inout' && { layoutOptions: { 'org.eclipse.elk.port.side': port.direction === 'in' ? 'WEST' : 'EAST' } }) }; + addWire(nativePort, 'port', port, getElkPort(nativePort, port), { nativeId: id }); + return nativePort; + }); + const mapped = getElkNode(native, node.parentId ?? null, node); + if (native.ports) mapped.ports = native.ports.map((port, index) => getElkPort(port, node.ports![index])); + addWire(native, 'node', node, mapped); + nativeNodes.set(node.id, native); } - - // Root-level edges: edges not fully contained within any compound node - const allInnerEdgeIds = new Set(); for (const node of graph.nodes) { - const children = getChildren(graph as Graph, node.id); - if (children.length > 0) { - const descendantIds = new Set(); - collectDescendants(graph, node.id, descendantIds); - for (const edge of graph.edges) { - if (descendantIds.has(edge.sourceId) && descendantIds.has(edge.targetId)) { - allInnerEdgeIds.add(edge.id); - } - } - } + const parentId = node.parentId ?? null; + const parent = parentId === null ? root : nativeNodes.get(parentId); + if (!parent) throw new Error(`ELK: missing parent node ${parentId}`); + (parent.children ??= []).push(nativeNodes.get(node.id)!); } - - const rootEdges = graph.edges.filter((e) => !allInnerEdgeIds.has(e.id)); - if (rootEdges.length > 0) { - root.edges = rootEdges.map(convertEdge); + // Validate hierarchy iteratively, rather than silently omitting cyclic nodes. + const reachable = new Set(); + const pending = [...(root.children ?? [])]; + while (pending.length) { + const node = pending.pop()!; + if (reachable.has(node.id)) throw new Error('ELK: cyclic hierarchy'); + reachable.add(node.id); pending.push(...(node.children ?? [])); } - - return root; + if (reachable.size !== graph.nodes.length) throw new Error('ELK: cyclic hierarchy'); + const idMap = new Map(graph.nodes.map((node) => [node.id, node.id])); + graph.edges.forEach((edge, index) => { + const getEndpoint = (nodeId: string, portName: string | undefined) => { + if (portName === undefined) return nodeId; + const id = portIds.get(JSON.stringify([nodeId, portName])); + if (!id) throw new Error(`ELK: missing port ${nodeId}:${portName}`); + return id; + }; + const native: ElkExtendedEdge = { id: edgeIds[index], sources: [getEndpoint(edge.sourceId, edge.sourcePort)], targets: [getEndpoint(edge.targetId, edge.targetPort)], + ...(typeof edge.label === 'string' && { labels: [{ text: edge.label, ...getFields(edge, ['x', 'y', 'width', 'height']) }] }), + ...(edge.routing !== undefined && { layoutOptions: { 'elk.edgeRouting': edge.routing.toUpperCase() } }), + }; + if (edge.points && edge.points.length >= 2) native.sections = [{ id: createId(`${native.id}__section`), + startPoint: edge.points[0], endPoint: edge.points[edge.points.length - 1], bendPoints: edge.points.slice(1, -1) }]; + addWire(native, 'edge', edge, getElkEdge(native, index, owners, idMap, edge.routing ?? 'orthogonal', edge)); + const ancestors = new Set([null]); + for (let parent = parentIds.get(edge.sourceId) ?? null; parent !== null; parent = parentIds.get(parent) ?? null) ancestors.add(parent); + let containerId = parentIds.get(edge.targetId) ?? null; + while (!ancestors.has(containerId)) containerId = parentIds.get(containerId!) ?? null; + const container = containerId === null ? root : nativeNodes.get(containerId)!; + (container.edges ??= []).push(native); + }); + const { nodes: _nodes, edges: _edges, ...original } = graph; + const traversalNodes: string[] = [], traversalEdges: string[] = []; + const traversal = [root]; + while (traversal.length) { + const node = traversal.pop()!; + if (node !== root) traversalNodes.push(node.id); + traversalEdges.push(...(node.edges ?? []).map((edge) => edge.id)); + traversal.push(...[...(node.children ?? [])].reverse()); + } + addWire(root, 'graph', original, getElkRoot(root, graph), { nodeOrder: graph.nodes.map((node) => node.id), + edgeOrder: graph.edges.map((edge) => edge.id), nativeNodeOrder: traversalNodes, nativeEdgeOrder: traversalEdges }); + return getClonedJSON(root); } -// --- fromELK --- - -function flattenElkNodes( - elkNode: ElkNode, - parentId: string | null, - nodes: VisualNode[], - edges: VisualEdge[], - edgeIdx: { value: number }, - portOwner: Map, - edgeRouting: EdgeRouting, -): void { - if (elkNode.children) { - for (const child of elkNode.children) { - const label = - (child.labels as ElkLabel[] | undefined)?.[0]?.text ?? ''; - const metadata = readMetadata(child)?.node; - const node: VisualNode = { - type: 'node', - id: child.id, - parentId, - initialNodeId: - metadata && 'initialNodeId' in metadata - ? (metadata.initialNodeId as string | null) - : null, - label, - data: - metadata && 'data' in metadata - ? metadata.data - : (undefined as any), - x: child.x ?? 0, - y: child.y ?? 0, - width: child.width ?? 0, - height: child.height ?? 0, - ...(metadata?.shape !== undefined && { shape: metadata.shape }), - ...(metadata?.color !== undefined && { color: metadata.color }), - ...(metadata?.style !== undefined && { style: metadata.style }), - }; - // Parse ELK ports - if (child.ports && child.ports.length > 0) { - node.ports = (child.ports as ElkPort[]).map((elkPort): VisualPort => { - const metadata = readMetadata(elkPort)?.port; - // Metadata carries the original port name; external ELK input - // (no metadata) uses the raw port id as the name. - const portName = metadata?.name ?? elkPort.id; - portOwner.set(elkPort.id, { nodeId: child.id, portName }); - const sideOpt = - elkPort.layoutOptions?.['org.eclipse.elk.port.side']; - let direction: 'in' | 'out' | 'inout' = 'inout'; - if (sideOpt === 'WEST') direction = 'in'; - else if (sideOpt === 'EAST') direction = 'out'; - return { - name: portName, - direction, - label: - (elkPort.labels as ElkLabel[] | undefined)?.[0]?.text, - data: - metadata && 'data' in metadata - ? metadata.data - : (undefined as any), - x: elkPort.x ?? 0, - y: elkPort.y ?? 0, - width: elkPort.width ?? 0, - height: elkPort.height ?? 0, - ...(metadata?.style !== undefined && { style: metadata.style }), - }; - }); - } - nodes.push(node); - flattenElkNodes(child, child.id, nodes, edges, edgeIdx, portOwner, edgeRouting); - } +/** Import extended/primitive edges; hyperedges are explicitly unsupported by Graph. */ +export function fromELK(root: ELKInputNode, options: ELKImportOptions = {}): VisualGraph { + if (!root || typeof root !== 'object') throw new Error('ELK: expected a root node'); + const rootWire = getWire(root, 'graph', options); + const rootPreserved = rootWire.metadata?.original ?? rootWire.legacy ?? {}; + const nodeIds = new Map(); + const owners = new Map(); + const nodes: VisualNode[] = [], edges: VisualEdge[] = []; + const rawNodeIds: string[] = [], rawEdgeIds: string[] = []; + const edgeEntries: Array<{ edge: ElkExtendedEdge | ElkPrimitiveEdge; routing: EdgeRouting }> = []; + const pending = [{ native: root, parentId: null as string | null, isRoot: true, routing: getRouting(root) }]; + const seenNativeIds = new Set(); + function addNativeId(id: string, legacy = false): void { + if (typeof id !== 'string') throw new Error('ELK: element id must be a string'); + if (seenNativeIds.has(id) && !legacy) throw new Error(`ELK: duplicate element id ${id}`); + seenNativeIds.add(id); } - - if (elkNode.edges) { - for (const elkEdge of elkNode.edges as ElkExtendedEdge[]) { - for (const source of elkEdge.sources) { - for (const target of elkEdge.targets) { - // Resolve: if source/target is a port ID, map to node + port - const metadata = readMetadata(elkEdge)?.edge; - const sourceOwner = portOwner.get(source); - const targetOwner = portOwner.get(target); - // Fresh layout output (computed label geometry, routed sections) - // takes precedence over round-tripped metadata - const elkLabel = (elkEdge.labels as ElkLabel[] | undefined)?.[0]; - const hasComputedLabel = - elkLabel?.x !== undefined && elkLabel?.y !== undefined; - const section = elkEdge.sections?.[0]; - const edge: VisualEdge = { - type: 'edge', - id: elkEdge.id ?? `e${edgeIdx.value++}`, - sourceId: metadata?.sourceId ?? sourceOwner?.nodeId ?? source, - targetId: metadata?.targetId ?? targetOwner?.nodeId ?? target, - label: - metadata && 'label' in metadata - ? (metadata.label as string | null) - : (elkLabel?.text ?? ''), - data: - metadata && 'data' in metadata - ? metadata.data - : (undefined as any), - // Edge x/y/width/height are canonically the label rect - x: hasComputedLabel ? elkLabel.x! : (metadata?.x ?? 0), - y: hasComputedLabel ? elkLabel.y! : (metadata?.y ?? 0), - width: hasComputedLabel - ? (elkLabel.width ?? metadata?.width ?? 0) - : (metadata?.width ?? 0), - height: hasComputedLabel - ? (elkLabel.height ?? metadata?.height ?? 0) - : (metadata?.height ?? 0), - ...(metadata?.mode !== undefined && { mode: metadata.mode }), - ...(metadata?.weight !== undefined && { weight: metadata.weight }), - ...(metadata?.color !== undefined && { color: metadata.color }), - ...(metadata?.style !== undefined && { style: metadata.style }), - }; - if (section?.startPoint && section.endPoint) { - edge.points = [ - { x: section.startPoint.x, y: section.startPoint.y }, - ...(section.bendPoints ?? []).map((p) => ({ x: p.x, y: p.y })), - { x: section.endPoint.x, y: section.endPoint.y }, - ]; - edge.routing = edgeRouting; - } else if (metadata?.points !== undefined) { - edge.points = metadata.points; - if (metadata.routing !== undefined) edge.routing = metadata.routing; - } - if (metadata && 'sourcePort' in metadata) { - edge.sourcePort = metadata.sourcePort; - } else if (sourceOwner) edge.sourcePort = sourceOwner.portName; - if (metadata && 'targetPort' in metadata) { - edge.targetPort = metadata.targetPort; - } else if (targetOwner) edge.targetPort = targetOwner.portName; - edges.push(edge); - } - } + addNativeId(root.id); + for (const port of root.ports ?? []) addNativeId(port.id, !!getWire(port, 'port', options).legacy); + while (pending.length) { + const { native, parentId, isRoot, routing } = pending.pop()!; + const wire = getWire(native, 'node', options); + const preserved = wire.metadata?.original ?? wire.legacy ?? {}; + let currentParent = parentId; + if (!isRoot) { + addNativeId(native.id, !!wire.legacy); + const mapped = getElkNode(native, parentId, preserved); + const ownerNodeId = getRestoredRecord(mapped, wire.metadata).id; + if (Object.hasOwn(native, 'ports')) mapped.ports = (native.ports ?? []).map((port) => { + const portWire = getWire(port, 'port', options); + addNativeId(port.id, !!portWire.legacy); + const original = { ...(portWire.metadata?.original ?? portWire.legacy ?? {}) }; + if (portWire.metadata && portWire.raw.nativeId !== undefined && port.id !== portWire.raw.nativeId) original.name = port.id; + const restored = getRestoredRecord(getElkPort(port, original), portWire.metadata); + owners.set(port.id, { nodeId: ownerNodeId, portName: restored.name }); + return restored; + }); + const restored = getRestoredRecord(mapped, wire.metadata); + nodes.push(restored); rawNodeIds.push(native.id); + nodeIds.set(native.id, restored.id); + currentParent = restored.id; } - } -} - -/** - * Parses an ELK JSON node into a visual graph. - * - * @example - * ```ts - * import { fromELK } from '@statelyai/graph/elk'; - * - * const graph = fromELK({ - * id: 'root', - * children: [ - * { id: 'a', x: 0, y: 0, width: 100, height: 50 }, - * { id: 'b', x: 200, y: 0, width: 100, height: 50 }, - * ], - * edges: [{ id: 'e1', sources: ['a'], targets: ['b'] }], - * }); - * - * graph.nodes; // [{id: 'a', x: 0, y: 0, ...}, {id: 'b', x: 200, ...}] - * graph.edges; // [{sourceId: 'a', targetId: 'b', ...}] - * ``` - */ -export function fromELK(elkRoot: ElkNode): VisualGraph { - const nodes: VisualNode[] = []; - const edges: VisualEdge[] = []; - const edgeIdx = { value: 0 }; - const portOwner = new Map(); - - // How routed sections should be interpreted, from the root edgeRouting - // option (ELK layered default is orthogonal) - const elkEdgeRouting = String( - elkRoot.layoutOptions?.["elk.edgeRouting"] ?? - elkRoot.layoutOptions?.["org.eclipse.elk.edgeRouting"] ?? - "", - ).toUpperCase(); - const edgeRouting: EdgeRouting = - elkEdgeRouting === "SPLINES" - ? "splines" - : elkEdgeRouting === "POLYLINE" - ? "polyline" - : "orthogonal"; - - flattenElkNodes(elkRoot, null, nodes, edges, edgeIdx, portOwner, edgeRouting); - - // Deduplicate edges by id (same edge may appear at compound and root level) - const seenEdges = new Map(); - for (const edge of edges) { - if (!seenEdges.has(edge.id)) { - seenEdges.set(edge.id, edge); + for (const edge of native.edges ?? []) { + addNativeId(edge.id, !!getWire(edge, 'edge', options).legacy); + edgeEntries.push({ edge: edge as ElkExtendedEdge | ElkPrimitiveEdge, routing }); } + pending.push(...[...(native.children ?? [])].reverse().map((child) => ({ native: child, parentId: isRoot ? null : currentParent, isRoot: false, routing: getRouting(child, routing) }))); + } + edgeEntries.forEach(({ edge, routing }, index) => { + const wire = getWire(edge, 'edge', options); + const preserved = wire.metadata?.original ?? wire.legacy ?? {}; + const restored = getRestoredRecord(getElkEdge(edge, index, owners, nodeIds, routing, preserved, !!wire.legacy), wire.metadata); + // Old adapters duplicated edges at several ancestors. Only legacy blobs + // permit deduplication; native duplicate IDs are not silently conflated. + if (wire.legacy && edges.some((existing) => existing.id === restored.id)) return; + edges.push(restored); rawEdgeIds.push(edge.id); + }); + const graph: VisualGraph = { ...getRestoredRecord(getElkRoot(root, rootPreserved), rootWire.metadata), nodes, edges }; + const order = rootWire.raw; + if (rootWire.metadata && Array.isArray(order.nodeOrder) && isJSONEqual(rawNodeIds, order.nativeNodeOrder)) { + const positions = new Map(order.nodeOrder.map((id: string, index: number) => [id, index])); + graph.nodes.sort((a, b) => Number(positions.get(a.id)) - Number(positions.get(b.id))); } + if (rootWire.metadata && Array.isArray(order.edgeOrder) && isJSONEqual(rawEdgeIds, order.nativeEdgeOrder)) { + const positions = new Map(order.edgeOrder.map((id: string, index: number) => [id, index])); + graph.edges.sort((a, b) => Number(positions.get(a.id)) - Number(positions.get(b.id))); + } + if (!rootWire.metadata) graph.data = createNativeCarrier(graph.data, 'elk', root, graph); + return graph; +} - const elkDir = elkRoot.layoutOptions?.['elk.direction']; - const graphMetadata = readMetadata(elkRoot)?.graph; - const direction: VisualGraph['direction'] = - (graphMetadata?.direction as VisualGraph['direction'] | undefined) ?? - (elkDir ? ELK_TO_DIRECTION[elkDir] : undefined) ?? - 'down'; +export const elkConverter: VisualGraphFormatConverter = { to: toELK, from: fromELK }; - return { - id: graphMetadata?.id ?? elkRoot.id, - mode: graphMetadata?.mode ?? 'directed', - initialNodeId: - graphMetadata && 'initialNodeId' in graphMetadata - ? (graphMetadata.initialNodeId as string | null) - : null, - nodes, - edges: [...seenEdges.values()], - data: - graphMetadata && 'data' in graphMetadata - ? graphMetadata.data - : (undefined as any), - direction, - ...(graphMetadata?.style !== undefined && { style: graphMetadata.style }), - }; -} -/** - * Bidirectional converter for ELK JSON format. - * - * @example - * ```ts - * import { elkConverter } from '@statelyai/graph/elk'; - * - * const elk = elkConverter.to(graph); - * const roundTripped = elkConverter.from(elk); - * ``` - */ -export const elkConverter: VisualGraphFormatConverter = { - to: toELK, - from: fromELK, -}; +/** Exact native source preservation, with strict verified field edits. */ +export const elkDocumentConverter = createGraphDocumentConverter({ + format: 'elk', + codec: { from: (source: ELKInputNode) => fromELK(source, { metadata: 'native' }), to: (graph: VisualGraph) => toELK(graph, { nativeCarrier: false }) }, + project: (source: ELKInputNode) => ({ graph: getStructuredDocumentProjectionGraph(fromELK(source, { metadata: 'native' }), source, 'elk') }), + patch: (context) => getStructuredDocumentPatch(context, 'elk'), +}); diff --git a/src/formats/gexf/README.md b/src/formats/gexf/README.md index 4e89e82..e1278b0 100644 --- a/src/formats/gexf/README.md +++ b/src/formats/gexf/README.md @@ -8,6 +8,18 @@ Converter for [GEXF](https://gexf.net/) (Graph Exchange XML Format) — the XML npm install fast-xml-parser ``` +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **partial**. +Bare Graph APIs preserve no source document. `gexfDocumentConverter` retains +exact native source, including comments and unknown syntax, after JSON persistence. +Its Graph projection and supported edits remain partial; selected-field tests do +not prove unrestricted semantic losslessness. + +Reads native weights, mutual edges and the undirected default. Attribute IDs are scoped by node/edge class; custom declarations/defaults, creator metadata and selected temporal/visual state use versioned `_gexf` metadata. Temporal state is preserved opaquely rather than executed. Custom graph attributes remain a backward-compatible dialect and are not claimed XSD-valid; source comments and complete source syntax are not retained. + ## Resources - [GEXF specification](https://gexf.net/schema.html) @@ -18,7 +30,7 @@ npm install fast-xml-parser ```ts -import { toGEXF, fromGEXF, gexfConverter } from '@statelyai/graph/gexf'; +import { toGEXF, fromGEXF, gexfConverter, gexfDocumentConverter } from '@statelyai/graph/gexf'; import { createGraph } from '@statelyai/graph'; // Export @@ -40,7 +52,7 @@ const xml = toGEXF(graph); // Import const imported = fromGEXF(` - + @@ -59,27 +71,30 @@ const imported = fromGEXF(` // edges: - e0 (api -> users) - e1 (api -> orders) - e2 (orders -> users) ``` -### `toGEXF(graph): string` +### `toGEXF(graph, options?: GEXFExportOptions): string` Produces GEXF 1.3 XML with: - Node/edge attributes via `` - Hierarchy via `pid` attribute -- Graph `mode`, `initialNodeId`, `direction`, and `data` stored as XML or custom `` attributes +- Graph directedness via `defaultedgetype`; `initialNodeId`, `direction`, and `data` via custom `` attributes - Graph `style` stored as a custom `` attribute - Visual properties via `viz:color`, `viz:position`, `viz:size` - Custom node attributes for `width`, `height`, `style`, and `ports` -- Custom edge attributes for `mode`, `weight`, `x`, `y`, `width`, `height`, `style`, `sourcePort`, and `targetPort` +- Native edge `weight`, `type="mutual"` and graph `defaultedgetype="mutual"`; custom geometry/style/port attributes +- Private null-label/geometry presence and original color strings preserve selected Graph distinctions without overriding changed native labels/colors - JSON-serialized graph/node/edge style and data fields where needed -### `fromGEXF(xml): Graph` +### `fromGEXF(xml, options?: GEXFImportOptions): Graph` Parses: -- `` declarations and `` on nodes/edges +- Well-formed XML in 1.3 and published 1.2draft namespaces, including prefix aliases +- Class-scoped `` declarations/defaults and `` on nodes/edges +- Native edge weights, mutual directedness and the standard undirected graph default - `@pid` and nested `` for hierarchy -- Custom graph attributes for `mode`, `initialNodeId`, `direction`, `data`, and `style` -- `viz:color`, `viz:position`, `viz:size` +- Custom graph attributes for `initialNodeId`, `direction`, `data`, and `style` +- RGB/hex `viz:color`, `viz:position`, `viz:size`, and native shape; alpha, z, selected dynamics and unknown custom values are retained as metadata - JSON-encoded `data`, `style`, and `ports` fields ### `gexfConverter` @@ -90,3 +105,58 @@ Pre-built `GraphFormatConverter`: const xml = gexfConverter.to(graph); const graph = gexfConverter.from(xml); ``` + +### `gexfDocumentConverter` + + + +```ts +const document = gexfDocumentConverter.from(xml); +const persisted = JSON.parse(JSON.stringify(document)); +const unchanged = gexfDocumentConverter.to(persisted); // exact original text +if (document.graph) { + document.graph.nodes[0].label = 'Updated'; + const edited = gexfDocumentConverter.to(document); +} +``` + +`from` retains source when projection is unavailable (`graph: null`) and reports +its diagnostics; `{ strict: true }` rejects unavailable projections. `to` patches +verified literal node/edge labels and proves the resulting Graph projection by +reparsing. Independent source edits survive; conflicting native and Graph field +edits throw an ordinary error carrying diagnostics. Unedited bytes stay intact. + +Label patches target native `label` attributes on unique explicit IDs, including nested nodes. Missing attributes are inserted locally; XML characters and escaping are checked. Published 1.2draft and 1.3 namespaces are recognized. Multiple graphs retain the source with an unavailable single-graph projection. + +Nullable labels, structure, data, geometry and style edits currently require explicit +regeneration: `gexfDocumentConverter.getUpdate(document, { mode: 'regenerate' })` returns a +result with loss diagnostics. Strict `to` never silently regenerates or replays +stale source after unsupported Graph edits. Native explicit labels, including empty strings, +are authoritative over private null metadata; null exports omit native labels. + +### Standards export profile + + + +```ts +const standard = toGEXF(graph, { standard: true }); +const restored = fromGEXF(standard, { standard: true }); +``` + +The opt-in 1.3 profile moves Graph-only root ID, initial state, data, direction and +style into a JSON `` carrier identified by +`format: "@statelyai/graph/gexf-root", version: 1`. Original descriptions remain +separate description elements. Reserved-carrier collisions and multiple carriers +reject; malformed marker-shaped user descriptions remain native text. Carrier +interpretation requires the explicit import option. Explicit native root fields +and directedness remain authoritative; the carrier contains no node/edge snapshots. +`GEXFExportOptions` and `GEXFImportOptions` are exported. + +Visual elements use the core namespace required by the published 1.3 XSD. The +[public visualization example](https://gexf.net/viz.html) uses a `/viz` namespace +that this XSD rejects; default export retains that existing dialect. Import accepts +both forms. Standard export preserves attribute whitespace with numeric references +and rejects invalid XML characters/nonfinite native numbers. The native suite pins +and validates official 1.2draft and 1.3 schemas through libxml2. This is a tested +export profile, not runtime XSD validation of arbitrary foreign extensions or full +execution of dynamic graph semantics. diff --git a/src/formats/gexf/index.ts b/src/formats/gexf/index.ts index 2b1f98b..8ed08c3 100644 --- a/src/formats/gexf/index.ts +++ b/src/formats/gexf/index.ts @@ -1,11 +1,24 @@ -import { XMLBuilder, XMLParser } from 'fast-xml-parser'; +import { getStandardXML, getValidatedXMLValue } from '../xml-standard'; +import { createGraphDocumentConverter } from '../document'; +import { getXMLDocumentPatch, getXMLDocumentProjection } from '../xml-document'; +import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser'; import type { Graph, GraphNode, GraphEdge, GraphFormatConverter } from '../../types'; import { createFormatConverter } from '../converter'; import { getEdgeMode } from '../../mode'; +import { createFormatData, getFormatMetadata, getNormalizedXML, getUserData } from '../xml-preservation'; // --- GEXF (Graph Exchange XML Format, https://gexf.net/) --- -export function toGEXF(graph: Graph): string { +export interface GEXFExportOptions { + /** Published 1.3 XSD profile: core-namespace viz and an explicit root metadata carrier. */ + standard?: boolean; +} +export interface GEXFImportOptions { + /** Interpret the standard export profile's discriminated root description carrier. */ + standard?: boolean; +} + +export function toGEXF(graph: Graph, options: GEXFExportOptions = {}): string { // Attribute declarations const nodeAttrs = [ { '@_id': 'a_parentId', '@_title': 'parentId', '@_type': 'string' }, @@ -16,6 +29,8 @@ export function toGEXF(graph: Graph): string { { '@_id': 'a_height', '@_title': 'height', '@_type': 'double' }, { '@_id': 'a_style', '@_title': 'style', '@_type': 'string' }, { '@_id': 'a_ports', '@_title': 'ports', '@_type': 'string' }, + { '@_id': 'a_private', '@_title': '_stately', '@_type': 'string' }, + { '@_id': 'a_color', '@_title': '_statelyColor', '@_type': 'string' }, ]; const edgeAttrs = [ @@ -30,23 +45,38 @@ export function toGEXF(graph: Graph): string { { '@_id': 'a_edgeRouting', '@_title': 'routing', '@_type': 'string' }, { '@_id': 'a_sourcePort', '@_title': 'sourcePort', '@_type': 'string' }, { '@_id': 'a_targetPort', '@_title': 'targetPort', '@_type': 'string' }, + { '@_id': 'a_edgePrivate', '@_title': '_stately', '@_type': 'string' }, + { '@_id': 'a_edgeColor', '@_title': '_statelyColor', '@_type': 'string' }, ]; + const native = getFormatMetadata(graph.data, 'gexf'); + const nativeAttrIds = new Map(); + for (const block of native?.attributes ?? []) { + const declarations = block['@_class'] === 'edge' ? edgeAttrs : nodeAttrs; + for (const declaration of getArray(block.attribute)) { + let id = String(declaration['@_id']); + while (declarations.some((known) => known['@_id'] === id)) id = `native_${id}`; + nativeAttrIds.set(`${block['@_class']}:${declaration['@_id']}`, id); + declarations.push({ ...declaration, '@_id': id }); + } + } + const getNativeValues = (data: unknown, kind: string): any[] => (getFormatMetadata(data, 'gexf')?.values ?? []) + .map((value: any) => ({ ...value, '@_for': nativeAttrIds.get(`${kind}:${value['@_for']}`) ?? value['@_for'] })); const nodes = graph.nodes.map((n) => { const attvalues: any[] = []; - if (n.parentId) + if (n.parentId != null) attvalues.push({ '@_for': 'a_parentId', '@_value': n.parentId }); - if (n.initialNodeId) + if (n.initialNodeId != null) attvalues.push({ '@_for': 'a_initialNodeId', '@_value': n.initialNodeId, }); - if (n.data !== undefined) + if (getUserData(n.data, 'gexf') !== undefined) attvalues.push({ '@_for': 'a_data', - '@_value': JSON.stringify(n.data), + '@_value': JSON.stringify(getUserData(n.data, 'gexf')), }); - if (n.shape) + if (n.shape !== undefined) attvalues.push({ '@_for': 'a_shape', '@_value': n.shape }); if (n.width !== undefined) { attvalues.push({ '@_for': 'a_width', '@_value': n.width }); @@ -67,23 +97,30 @@ export function toGEXF(graph: Graph): string { }); } + const presence = { format: 'stately', version: 1, ...(n.label === null && { nullLabel: true }), + absentGeometry: ['x', 'y', 'width', 'height'].filter((key) => (n as any)[key] === undefined) }; + attvalues.push({ '@_for': 'a_private', '@_value': JSON.stringify(presence) }); + if (n.color !== undefined) attvalues.push({ '@_for': 'a_color', '@_value': JSON.stringify(n.color) }); + attvalues.push(...getNativeValues(n.data, 'node')); + const extra = getFormatMetadata(n.data,'gexf')?.native ?? {}; const node: any = { + ...Object.fromEntries(Object.entries(extra).filter(([key]) => key !== 'z' && key !== 'alpha')), '@_id': n.id, - // GEXF requires a label attribute; emit empty string so empty labels - // round-trip instead of being replaced by the node id. - '@_label': n.label ?? '', + // Omit null so an explicit native empty label remains authoritative. + ...(n.label !== null && { '@_label': n.label ?? '' }), }; - if (n.parentId) node['@_pid'] = n.parentId; + if (n.parentId != null) node['@_pid'] = n.parentId; if (attvalues.length > 0) node.attvalues = { attvalue: attvalues }; // Viz module if (n.color) { - const hex = n.color.replace('#', ''); - if (hex.length === 6) { + const getHex = n.color.replace('#', ''); + if (/^[0-9a-fA-F]{6}$/.test(getHex)) { node['viz:color'] = { - '@_r': parseInt(hex.slice(0, 2), 16), - '@_g': parseInt(hex.slice(2, 4), 16), - '@_b': parseInt(hex.slice(4, 6), 16), + '@_r': parseInt(getHex.slice(0, 2), 16), + '@_g': parseInt(getHex.slice(2, 4), 16), + '@_b': parseInt(getHex.slice(4, 6), 16), + ...(extra.alpha !== undefined && { '@_a': extra.alpha }), }; } } @@ -91,6 +128,7 @@ export function toGEXF(graph: Graph): string { node['viz:position'] = { '@_x': n.x ?? 0, '@_y': n.y ?? 0, + ...(extra.z !== undefined && { '@_z': extra.z }), }; } if (n.width !== undefined || n.height !== undefined) { @@ -100,29 +138,31 @@ export function toGEXF(graph: Graph): string { return node; }); - const graphDirected = graph.mode !== 'undirected'; const edges = graph.edges.map((e) => { + const extra = getFormatMetadata(e.data,'gexf')?.native ?? {}; const edge: any = { + ...Object.fromEntries(Object.entries(extra).filter(([key]) => key !== 'z' && key !== 'alpha')), '@_id': e.id, '@_source': e.sourceId, '@_target': e.targetId, }; // Per-edge directedness override. GEXF edges carry an optional `type` // attribute; emit it only when the edge differs from the graph default. - // Bidirectional has no GEXF representation and maps to directed. - const edgeDirected = getEdgeMode(graph, e) !== 'undirected'; - if (edgeDirected !== graphDirected) { - edge['@_type'] = edgeDirected ? 'directed' : 'undirected'; - } - if (e.label) edge['@_label'] = e.label; - if (e.data !== undefined) { + // GEXF mutual represents bidirectional edges natively. + const mode = getEdgeMode(graph, e); + if (e.mode !== undefined || mode !== graph.mode) edge['@_type'] = mode === 'bidirectional' ? 'mutual' : mode; + if (e.label != null) edge['@_label'] = e.label; + if (getUserData(e.data, 'gexf') !== undefined) { edge.attvalues = { - attvalue: [{ '@_for': 'a_edgeData', '@_value': JSON.stringify(e.data) }], + attvalue: [{ '@_for': 'a_edgeData', '@_value': JSON.stringify(getUserData(e.data, 'gexf')) }], }; } const edgeAttvalues = edge.attvalues?.attvalue ?? []; + if (e.label === null) edgeAttvalues.push({ '@_for': 'a_edgePrivate', '@_value': JSON.stringify({ format: 'stately', version: 1, nullLabel: true }) }); + if (e.color !== undefined) edgeAttvalues.push({ '@_for': 'a_edgeColor', '@_value': JSON.stringify(e.color) }); + edgeAttvalues.push(...getNativeValues(e.data, 'edge')); if (e.weight !== undefined) { - edgeAttvalues.push({ '@_for': 'a_edgeWeight', '@_value': e.weight }); + edge['@_weight'] = e.weight; } if (e.points !== undefined) { edgeAttvalues.push({ @@ -167,33 +207,48 @@ export function toGEXF(graph: Graph): string { edge.attvalues = { attvalue: edgeAttvalues }; } if (e.color) { - const hex = e.color.replace('#', ''); - if (hex.length === 6) { + const getHex = e.color.replace('#', ''); + if (/^[0-9a-fA-F]{6}$/.test(getHex)) { edge['viz:color'] = { - '@_r': parseInt(hex.slice(0, 2), 16), - '@_g': parseInt(hex.slice(2, 4), 16), - '@_b': parseInt(hex.slice(4, 6), 16), + '@_r': parseInt(getHex.slice(0, 2), 16), + '@_g': parseInt(getHex.slice(2, 4), 16), + '@_b': parseInt(getHex.slice(4, 6), 16), + ...(extra.alpha !== undefined && { '@_a': extra.alpha }), }; } } return edge; }); + if (options.standard) for (const entity of [...nodes, ...edges]) { + for (const key of Object.keys(entity)) if (key.startsWith('viz:')) { entity[key.slice(4)] = entity[key]; delete entity[key]; } + } + const nativeMeta = native?.meta; + let meta = nativeMeta; + if (options.standard) { + const descriptions = getArray(nativeMeta?.description); + if (descriptions.some((description) => getStandardRoot(description))) throw new Error('GEXF standard profile: reserved root description carrier collision'); + const root = { id: graph.id, initialNodeId: graph.initialNodeId, data: getUserData(graph.data, 'gexf'), + ...(graph.direction !== undefined && {direction:graph.direction}), ...(graph.style !== undefined && {style:graph.style}) }; + meta = { ...nativeMeta, description: [...descriptions, JSON.stringify({format:'@statelyai/graph/gexf-root',version:1,root})] }; + } const obj: any = { '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, gexf: { '@_xmlns': 'http://gexf.net/1.3', - '@_xmlns:viz': 'http://gexf.net/1.3/viz', + ...(!options.standard && {'@_xmlns:viz': 'http://gexf.net/1.3/viz'}), '@_version': '1.3', + ...(meta && { meta }), graph: { - '@_defaultedgetype': graph.mode === 'undirected' ? 'undirected' : 'directed', - ...(graph.id && { '@_id': graph.id }), - ...(graph.initialNodeId && { '@_initialNodeId': graph.initialNodeId }), - ...(graph.direction && { '@_direction': graph.direction }), - ...(graph.data !== undefined && { - '@_data': JSON.stringify(graph.data), + ...native?.graphAttributes, + '@_defaultedgetype': graph.mode === 'bidirectional' ? 'mutual' : graph.mode, + ...(!options.standard && graph.id && { '@_id': graph.id }), + ...(!options.standard && graph.initialNodeId != null && { '@_initialNodeId': graph.initialNodeId }), + ...(!options.standard && graph.direction && { '@_direction': graph.direction }), + ...(!options.standard && getUserData(graph.data, 'gexf') !== undefined && { + '@_data': JSON.stringify(getUserData(graph.data, 'gexf')), }), - ...(graph.style !== undefined && { + ...(!options.standard && graph.style !== undefined && { '@_style': JSON.stringify(graph.style), }), attributes: [ @@ -208,21 +263,29 @@ export function toGEXF(graph: Graph): string { const builder = new XMLBuilder({ ignoreAttributes: false, - format: true, + // Formatting inserts whitespace into unknown mixed-content XML. + format: false, suppressEmptyNode: true, }); - return builder.build(obj); + if (options.standard) getValidatedXMLValue(obj); + const output = builder.build(obj); + return options.standard ? getStandardXML(output) : output; } -export function fromGEXF(xml: string): Graph { +export function fromGEXF(xml: string, options: GEXFImportOptions = {}): Graph { if (typeof xml !== 'string') { throw new Error('GEXF: expected a string'); } + const validation = XMLValidator.validate(xml); + if (validation !== true) throw new Error(`GEXF: invalid XML — ${validation.err.msg}`); const parser = new XMLParser({ ignoreAttributes: false, + // Required by this peer to decode standard XML numeric character references. + htmlEntities: true, isArray: (name) => - ['node', 'edge', 'attribute', 'attvalue', 'attributes'].includes(name), + ['node', 'edge', 'attribute', 'attvalue', 'attributes'].includes(name.split(':').pop()!), + parseTagValue: false, // Keep attribute text verbatim: labels like " hi " must not be trimmed. // Numeric fields are parsed explicitly. trimValues: false, @@ -230,10 +293,12 @@ export function fromGEXF(xml: string): Graph { let parsed: any; try { - parsed = parser.parse(xml); + parsed = getNormalizedXML(parser.parse(xml), 'http://gexf.net/1.3'); } catch (e: any) { throw new Error(`GEXF: invalid XML — ${e.message}`); } + if (!parsed?.gexf) parsed = getNormalizedXML(parser.parse(xml), 'http://gexf.net/1.2draft'); + if (!parsed?.gexf) parsed = getNormalizedXML(parser.parse(xml), 'http://www.gexf.net/1.2draft'); const gexf = parsed?.gexf; if (!gexf) { throw new Error('GEXF: missing root element'); @@ -243,24 +308,45 @@ export function fromGEXF(xml: string): Graph { throw new Error('GEXF: missing element'); } - const graphType: 'directed' | 'undirected' = - graphEl['@_defaultedgetype'] === 'undirected' ? 'undirected' : 'directed'; + let nativeMeta = gexf.meta; + let root: any; + if (options.standard && nativeMeta) { + const descriptions = getArray(nativeMeta.description); + const roots = descriptions.map(getStandardRoot).filter(Boolean); + if (roots.length > 1) throw new Error('GEXF standard profile: ambiguous root description carriers'); + root = roots[0]; + if (root) { + const rest = descriptions.filter((description) => !getStandardRoot(description)); + nativeMeta = { ...nativeMeta }; + if (rest.length) nativeMeta.description = rest.length === 1 ? rest[0] : rest; + else delete nativeMeta.description; + if (!Object.keys(nativeMeta).length) nativeMeta = undefined; + } + } + const graphType: Graph['mode'] = graphEl['@_defaultedgetype'] === 'mutual' ? 'bidirectional' + : graphEl['@_defaultedgetype'] === 'directed' ? 'directed' : 'undirected'; // Build attribute ID → title map - const attrMap = new Map(); - for (const block of asArray(graphEl.attributes)) { - for (const attr of asArray(block?.attribute)) { + const nodeAttrMap = new Map(); + const edgeAttrMap = new Map(); + const nativeBlocks: any[] = []; + for (const block of getArray(graphEl.attributes)) { + for (const attr of getArray(block?.attribute)) { if (attr?.['@_id'] && attr?.['@_title']) { - attrMap.set(attr['@_id'], attr['@_title']); + (block['@_class'] === 'edge' ? edgeAttrMap : nodeAttrMap).set(String(attr['@_id']), String(attr['@_title'])); } } } + for (const block of getArray(graphEl.attributes)) { + const attribute = getArray(block.attribute).filter((attr) => !getKnownAttributes(block['@_class'] === 'edge' ? 'edge' : 'node').has(String(attr['@_title']))); + if (attribute.length) nativeBlocks.push({ ...block, attribute }); + } // Parse nodes const nodes: GraphNode[] = []; - function parseNodes(nodeEls: any, parentId: string | null) { - for (const n of asArray(nodeEls)) { - const attvals = getAttValues(n, attrMap); + function addNodes(nodeEls: any, parentId: string | null) { + for (const n of getArray(nodeEls)) { + const attvals = getAttValues(n, nodeAttrMap, getArray(graphEl.attributes).filter((block) => block['@_class'] === 'node')); const id = String(n['@_id']); // pid attribute takes precedence, then attvalue, then structural parent @@ -271,101 +357,113 @@ export function fromGEXF(xml: string): Graph { id, parentId: pid ?? attvals['parentId'] ?? parentId, initialNodeId: attvals['initialNodeId'] ?? null, - label: n['@_label'] ?? '', + label: getLabel(n['@_label'], attvals['_stately']), data: attvals['data'] !== undefined - ? tryParseJSON(attvals['data']) + ? getParsedJSON(attvals['data']) : undefined, }; - if (attvals['shape']) (node as any).shape = attvals['shape']; + if (attvals['shape'] !== undefined) (node as any).shape = attvals['shape']; if (attvals['style'] !== undefined) { - node.style = tryParseJSON(attvals['style']); + node.style = getParsedJSON(attvals['style']); } if (attvals['ports'] !== undefined) { - node.ports = tryParseJSON(attvals['ports']); + node.ports = getParsedJSON(attvals['ports']); } // Viz properties - const pos = n['viz:position']; + const pos = (n['viz:position'] ?? n.position); if (pos) { - node.x = parseNumber(pos['@_x'] ?? 0, ' x', 'node', id); - node.y = parseNumber(pos['@_y'] ?? 0, ' y', 'node', id); + node.x = getNumber(pos['@_x'] ?? 0, ' x', 'node', id); + node.y = getNumber(pos['@_y'] ?? 0, ' y', 'node', id); } - const size = n['viz:size']; + const size = (n['viz:size'] ?? n.size); if (size) { - node.width = parseNumber(size['@_value'] ?? 0, '', 'node', id); + node.width = getNumber(size['@_value'] ?? 0, '', 'node', id); node.height = node.width; } if (attvals['width'] !== undefined) { - node.width = parseNumber(attvals['width'], 'width attribute', 'node', id); + node.width = getNumber(attvals['width'], 'width attribute', 'node', id); } if (attvals['height'] !== undefined) { - node.height = parseNumber( + node.height = getNumber( attvals['height'], 'height attribute', 'node', id, ); } - const color = n['viz:color']; + const color = (n['viz:color'] ?? n.color); if (color) { const r = Number(color['@_r'] ?? 0); const g = Number(color['@_g'] ?? 0); const b = Number(color['@_b'] ?? 0); - node.color = `#${hex(r)}${hex(g)}${hex(b)}`; + node.color = color['@_hex'] ?? `#${getHex(r)}${getHex(g)}${getHex(b)}`; } + const privateData = getPrivateData(attvals['_stately']); + for (const key of privateData?.absentGeometry ?? []) { + // Only suppress the values fabricated by our own exporter, never edits. + const fallback = key === 'height' ? node.width : key === 'width' ? node.height : 0; + if ((node as any)[key] === fallback) delete (node as any)[key]; + } + if (attvals['_statelyColor'] !== undefined) { + const original = getParsedJSON(attvals['_statelyColor']); + if (!color || getColor(original) === node.color?.toLowerCase()) node.color = original; + } + if ((n['viz:shape'] ?? n.shape)?.['@_value'] !== undefined && !attvals['shape']) node.shape = (n['viz:shape'] ?? n.shape)['@_value']; + node.data = createFormatData(node.data, 'gexf', getNativeMetadata(n, nodeAttrMap,'node')); nodes.push(node); // Handle nested hierarchy (GEXF 1.2+ style) const nested = n.nodes?.node ?? n.node; if (nested) { - parseNodes(nested, id); + addNodes(nested, id); } } } const nodeEls = graphEl.nodes?.node ?? graphEl.node; - parseNodes(nodeEls, null); + addNodes(nodeEls, null); // Parse edges const edgeEls = graphEl.edges?.edge ?? graphEl.edge; - const edges: GraphEdge[] = asArray(edgeEls).map((e: any, i: number) => { - const attvals = getAttValues(e, attrMap); + const edges: GraphEdge[] = getArray(edgeEls).map((e: any, i: number) => { + const attvals = getAttValues(e, edgeAttrMap, getArray(graphEl.attributes).filter((block) => block['@_class'] === 'edge')); const id = String(e['@_id'] ?? `e${i}`); const edge: GraphEdge = { type: 'edge', id, sourceId: String(e['@_source']), targetId: String(e['@_target']), - label: e['@_label'] ?? '', + label: getLabel(e['@_label'], attvals['_stately']), data: attvals['data'] !== undefined - ? tryParseJSON(attvals['data']) + ? getParsedJSON(attvals['data']) : undefined, - ...(attvals['weight'] !== undefined && { - weight: parseNumber(attvals['weight'], 'weight attribute', 'edge', id), + ...((e['@_weight'] !== undefined || attvals['weight'] !== undefined) && { + weight: getNumber(e['@_weight'] ?? attvals['weight'], 'weight attribute', 'edge', id), }), ...(attvals['points'] !== undefined && { - points: tryParseJSON(attvals['points']), + points: getParsedJSON(attvals['points']), }), ...(attvals['routing'] !== undefined && { routing: attvals['routing'] as GraphEdge['routing'], }), ...(attvals['x'] !== undefined && { - x: parseNumber(attvals['x'], 'x attribute', 'edge', id), + x: getNumber(attvals['x'], 'x attribute', 'edge', id), }), ...(attvals['y'] !== undefined && { - y: parseNumber(attvals['y'], 'y attribute', 'edge', id), + y: getNumber(attvals['y'], 'y attribute', 'edge', id), }), ...(attvals['width'] !== undefined && { - width: parseNumber(attvals['width'], 'width attribute', 'edge', id), + width: getNumber(attvals['width'], 'width attribute', 'edge', id), }), ...(attvals['height'] !== undefined && { - height: parseNumber(attvals['height'], 'height attribute', 'edge', id), + height: getNumber(attvals['height'], 'height attribute', 'edge', id), }), ...(attvals['style'] !== undefined && { - style: tryParseJSON(attvals['style']), + style: getParsedJSON(attvals['style']), }), ...(attvals['sourcePort'] !== undefined && { sourcePort: attvals['sourcePort'], @@ -374,41 +472,43 @@ export function fromGEXF(xml: string): Graph { targetPort: attvals['targetPort'], }), }; - const color = e['viz:color']; + const color = (e['viz:color'] ?? e.color); if (color) { const r = Number(color['@_r'] ?? 0); const g = Number(color['@_g'] ?? 0); const b = Number(color['@_b'] ?? 0); - edge.color = `#${hex(r)}${hex(g)}${hex(b)}`; + edge.color = color['@_hex'] ?? `#${getHex(r)}${getHex(g)}${getHex(b)}`; } // Per-edge directedness override from the GEXF `type` attribute. const typeAttr = e['@_type']; if (typeAttr !== undefined) { - edge.mode = String(typeAttr) === 'undirected' ? 'undirected' : 'directed'; + edge.mode = typeAttr === 'mutual' ? 'bidirectional' : typeAttr === 'undirected' ? 'undirected' : 'directed'; + } + if (attvals['_statelyColor'] !== undefined) { + const original = getParsedJSON(attvals['_statelyColor']); + if (!color || getColor(original) === edge.color?.toLowerCase()) edge.color = original; } + edge.data = createFormatData(edge.data, 'gexf', getNativeMetadata(e, edgeAttrMap,'edge')); return edge; }); return { - id: String(graphEl['@_id'] ?? ''), + id: String(graphEl['@_id'] ?? root?.id ?? ''), mode: graphType, - initialNodeId: graphEl['@_initialNodeId'] ?? null, + initialNodeId: graphEl['@_initialNodeId'] ?? root?.initialNodeId ?? null, nodes, edges, - data: - graphEl['@_data'] !== undefined - ? tryParseJSON(String(graphEl['@_data'])) - : (undefined as any), - ...(graphEl['@_direction'] && { direction: graphEl['@_direction'] }), - ...(graphEl['@_style'] !== undefined && { - style: tryParseJSON(String(graphEl['@_style'])), + data: createFormatData(graphEl['@_data'] !== undefined ? getParsedJSON(String(graphEl['@_data'])) : root?.data, 'gexf', { ...(nativeMeta && { meta: nativeMeta }), ...(nativeBlocks.length && { attributes: nativeBlocks }), ...(Object.keys(graphEl).some((key) => ['@_mode','@_timeformat','@_start','@_end','@_timestamp','@_timezone','@_timerepresentation','@_idtype'].includes(key)) && {graphAttributes:Object.fromEntries(Object.entries(graphEl).filter(([key]) => ['@_mode','@_timeformat','@_start','@_end','@_timestamp','@_timezone','@_timerepresentation','@_idtype'].includes(key)))}) }), + ...((graphEl['@_direction'] ?? root?.direction) !== undefined && { direction: graphEl['@_direction'] ?? root?.direction }), + ...((graphEl['@_style'] !== undefined || root?.style !== undefined) && { + style: graphEl['@_style'] !== undefined ? getParsedJSON(String(graphEl['@_style'])) : root.style, }), }; } // --- Helpers --- -function asArray(val: T | T[] | undefined): T[] { +function getArray(val: T | T[] | undefined): T[] { if (val === undefined) return []; return Array.isArray(val) ? val : [val]; } @@ -416,19 +516,23 @@ function asArray(val: T | T[] | undefined): T[] { function getAttValues( el: any, attrMap: Map, + blocks: any[] = [], ): Record { - const result: Record = {}; + const result: Record = Object.create(null); + for (const block of blocks) for (const attr of getArray(block.attribute)) { + if (attr.default !== undefined) result[String(attr['@_title'])] = String(typeof attr.default === 'object' ? attr.default['#text'] ?? '' : attr.default); + } const attvalues = el?.attvalues?.attvalue; - for (const av of asArray(attvalues)) { + for (const av of getArray(attvalues)) { if (av?.['@_for'] != null) { - const title = attrMap.get(av['@_for']) ?? av['@_for']; + const title = attrMap.get(String(av['@_for'])) ?? av['@_for']; result[title] = String(av['@_value'] ?? ''); } } return result; } -function tryParseJSON(str: string): any { +function getParsedJSON(str: string): any { try { return JSON.parse(str); } catch { @@ -436,7 +540,7 @@ function tryParseJSON(str: string): any { } } -function parseNumber( +function getNumber( value: any, field: string, kind: 'node' | 'edge', @@ -451,10 +555,56 @@ function parseNumber( return parsed; } -function hex(n: number): string { +function getHex(n: number): string { return n.toString(16).padStart(2, '0'); } /** Bidirectional converter for GEXF (Graph Exchange XML Format). */ export const gexfConverter: GraphFormatConverter = createFormatConverter(toGEXF, fromGEXF); + +function getKnownAttributes(kind: 'node' | 'edge'): Set { + return new Set(kind === 'node' ? ['parentId','initialNodeId','data','shape','width','height','style','ports','_stately','_statelyColor'] : ['data','weight','x','y','width','height','style','points','routing','sourcePort','targetPort','_stately','_statelyColor']); +} + +function getNativeMetadata(el: any, declarations: Map, kind: 'node' | 'edge'): Record { + const values = getArray(el.attvalues?.attvalue).filter((value) => !getKnownAttributes(kind).has(declarations.get(String(value['@_for'])) ?? String(value['@_for']))); + const extra: Record = {}; + for (const [key, value] of Object.entries(el)) { + if (['@_start', '@_end', '@_timestamp', '@_timestamps', '@_intervals', '@_kind', 'spells', 'parents', 'viz:thickness', 'viz:shape'].includes(key)) extra[key] = value; + } + const position = el['viz:position'] ?? el.position, color = el['viz:color'] ?? el.color; + if (position?.['@_z'] !== undefined) extra.z = position['@_z']; + if (color?.['@_a'] !== undefined) extra.alpha = color['@_a']; + if (el.shape !== undefined) extra['viz:shape'] = el.shape; + if (el.thickness !== undefined) extra['viz:thickness'] = el.thickness; + return { ...(values.length && { values }), ...(Object.keys(extra).length && { native: extra }) }; +} + +function getPrivateData(value: string | undefined): any { + const parsed = value === undefined ? undefined : getParsedJSON(value); + return parsed?.format === 'stately' && parsed.version === 1 ? parsed : undefined; +} + +function getLabel(value: string | undefined, metadata: string | undefined): string | null { + return (value === undefined && getPrivateData(metadata)?.nullLabel) ? null : value ?? ''; +} + +function getColor(value: unknown): string | undefined { + return typeof value === 'string' && /^#?[0-9a-fA-F]{6}$/.test(value) ? `#${value.replace('#','').toLowerCase()}` : undefined; +} + +/** Exact native source with a partial Graph projection and verified literal-label patches. */ +export const gexfDocumentConverter = createGraphDocumentConverter({ + format: 'gexf', + codec: { from: fromGEXF, to: toGEXF }, + project: (source) => getXMLDocumentProjection(source, 'gexf', fromGEXF), + patch: (context) => getXMLDocumentPatch(context, 'gexf'), +}); + +function getStandardRoot(value: unknown): any { + const text = typeof value === 'string' ? value : value && typeof value === 'object' ? (value as any)['#text'] : undefined; + if (typeof text !== 'string') return undefined; + const parsed = getParsedJSON(text); + return parsed?.format === '@statelyai/graph/gexf-root' && parsed.version === 1 && parsed.root && typeof parsed.root === 'object' && !Array.isArray(parsed.root) && typeof parsed.root.id === 'string' ? parsed.root : undefined; +} diff --git a/src/formats/gml/README.md b/src/formats/gml/README.md index b69fd51..e2cee9c 100644 --- a/src/formats/gml/README.md +++ b/src/formats/gml/README.md @@ -2,6 +2,18 @@ Converter for [GML](https://en.wikipedia.org/wiki/Graph_Modelling_Language) (Graph Modelling Language) — a hierarchical text format widely supported by graph tools. +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **partial**. +Bare Graph APIs preserve no source document. `gmlDocumentConverter` retains +exact native source, including comments and unknown syntax, after JSON persistence. +Its Graph projection and supported edits remain partial; selected-field tests do +not prove unrestricted semantic losslessness. + +Preserves selected Graph fields, empty labels, null-label presence and node order through versioned extensions. Unknown repeated attributes, graphics and imported missing-label presence use discriminated `_gml` metadata. Strings use entity escapes. Legacy export keeps quoted IDs; opt-in standard mode writes integer references and flat native nodes. Comments and complete source spelling/order are not preserved. + ## Resources - [GML specification (Graphlet)](https://web.archive.org/web/20190309090728/http://www.fim.uni-passau.de/index.php?id=17297&L=1) @@ -12,7 +24,7 @@ Converter for [GML](https://en.wikipedia.org/wiki/Graph_Modelling_Language) (Gra ```ts -import { toGML, fromGML, gmlConverter } from '@statelyai/graph/gml'; +import { toGML, fromGML, gmlConverter, gmlDocumentConverter } from '@statelyai/graph/gml'; import { createGraph } from '@statelyai/graph'; // Export @@ -63,19 +75,20 @@ const imported = fromGML(`graph [ // edges: - (web -> api) - (api -> db) ``` -### `toGML(graph): string` +### `toGML(graph, options?: GMLExportOptions): string` -Preserves graph `mode`, `initialNodeId`, `data`, `direction`, `style`, plus node and edge `label`, `data`, `ports`, `sourcePort`, `targetPort`, `weight`, `shape`, `color`, `style`, and visual properties in `graphics [x, y, w, h]` blocks. Compound graphs use nested `node [ ... node [ ] ]` blocks. +Maps selected graph `mode`, `initialNodeId`, `data`, `direction`, `style`, plus node and edge `label`, `data`, `ports`, `sourcePort`, `targetPort`, `weight`, `shape`, `color`, `style`, and visual properties in `graphics [x, y, w, h]` blocks. Legacy compound output uses nested node blocks. `toGML(graph, { standard: true })` writes integer IDs/endpoints and flat native nodes, retaining original Graph IDs/hierarchy in a versioned `stately` extension. `GMLExportOptions` and `GMLImportOptions` are exported types. Reserved discriminated metadata is not an unrestricted foreign-data container. -### `fromGML(gml): string` +### `fromGML(gml, options?: GMLImportOptions): Graph` -Includes a full tokenizer and recursive descent parser. Handles: +Uses an iterative block parser, rejects unclosed strings/blocks and decodes numeric and Latin-1 named entities. Default import accepts legacy backslash escapes; `{ standard: true }` treats native backslashes literally. Handles: - Nested node blocks for hierarchy - `graphics` blocks for positions/dimensions - JSON-encoded graph/node/edge metadata fields -- Multiple same-key entries (e.g., multiple `node` blocks) +- Multiple same-key entries and unknown ordered nested attributes, including unmapped graphics +- Native explicit labels, including empty strings, take precedence over private null markers ### `gmlConverter` @@ -85,3 +98,49 @@ Pre-built `GraphFormatConverter`: const gml = gmlConverter.to(graph); const graph = gmlConverter.from(gml); ``` + +### `gmlDocumentConverter` + + + +```ts +const document = gmlDocumentConverter.from(gml); +const persisted = JSON.parse(JSON.stringify(document)); +const unchanged = gmlDocumentConverter.to(persisted); // exact original text +if (document.graph) { + document.graph.nodes[0].label = 'Updated'; + const edited = gmlDocumentConverter.to(document); +} +``` + +`from` retains source when projection is unavailable (`graph: null`) and reports +its diagnostics; `{ strict: true }` rejects unavailable projections. `to` patches +verified literal node/edge labels and proves the resulting Graph projection by +reparsing. Independent source edits survive; conflicting native and Graph field +edits throw an ordinary error carrying diagnostics. Unedited bytes stay intact. + +Label patches require one quoted label and a unique explicit native ID or versioned `originalId`. Missing labels can be inserted locally. Numeric IDs and nested nodes are supported; synthesized edge IDs reject safe patching. The document codec uses legacy import escaping, matching `gmlConverter`. Multiple graphs retain source with an unavailable projection. + +Nullable labels, structure, data, geometry and style edits currently require explicit +regeneration: `gmlDocumentConverter.getUpdate(document, { mode: 'regenerate' })` returns a +result with loss diagnostics. Strict `to` never silently regenerates or replays +stale source after unsupported Graph edits. Native explicit labels, including empty strings, +are authoritative over private null metadata; null exports omit native labels. +The document projection uses `null` for native missing labels, distinguishing them +from explicit empty strings. Bare import retains its historical empty-string view +with private missing-label provenance for faithful regeneration. + + + +Standard output adds `multigraph 1` when parallel endpoint pairs exist, supporting +readers that require that hint. A conflicting preserved native hint rejects rather +than overwriting it. Integer-ID profile and legacy quoted-ID support remain unchanged; +fresh empty node/edge labels now emit explicit `label ""` in both profiles. +Quoted output and document label patches encode control whitespace as numeric +entities, preserving CR/LF/TAB through native line readers. Standard export rejects +lone surrogates in native strings and Graph identity fields; JSON-escaped user data +can retain them. Strict document label edits also require Unicode scalar values. +Portable native-validation CI uses pinned NetworkX 3.7 `parse_gml(..., label=None)` +for numeric identity, Unicode/entity labels, missing/empty label presence, isolated +nodes, numeric weights, parallel edges and malformed blocks. This validates the +tested NetworkX profile, without claiming complete GML semantics. diff --git a/src/formats/gml/document.ts b/src/formats/gml/document.ts new file mode 100644 index 0000000..1ee246a --- /dev/null +++ b/src/formats/gml/document.ts @@ -0,0 +1,100 @@ +import type { Graph } from '../../types'; +import type { GraphDocumentPatchContext, GraphDocumentPatchResult, GraphDocumentProjection } from '../document'; +import { getEditedSource, getLabelChangeError, getUnsupported, type TextEdit } from '../xml-document'; + +interface Token { text: string; value: string; start: number; end: number; quoted: boolean } +interface Entry { key: string; value?: Token; block?: number } +interface Block { key: string; parent: number | null; entries: Entry[]; close: number } + +/** Iterative lexical blocks retain quote positions and ignore comment contents. */ +function getBlocks(source: string, decode: (value: string) => string): Block[] { + const tokens: Token[] = []; + let cursor = 0; + while (cursor < source.length) { + if (/\s/.test(source[cursor])) { cursor++; continue; } + if (source[cursor] === '#') { const end = source.indexOf('\n', cursor); cursor = end < 0 ? source.length : end; continue; } + const start = cursor; + if (source[cursor] === '"') { + cursor++; + let value = ''; + while (cursor < source.length && source[cursor] !== '"') { + if (source[cursor] === '\\' && ['\\', '"'].includes(source[cursor + 1])) cursor++; + value += source[cursor++]; + } + if (cursor === source.length) throw new Error('unsupported-native-edit: unterminated GML string'); + cursor++; + tokens.push({ text: source.slice(start, cursor), value: decode(value), start, end: cursor, quoted: true }); continue; + } + if ('[]'.includes(source[cursor])) cursor++; + else while (cursor < source.length && !/[\s\[\]"#]/.test(source[cursor])) cursor++; + if (cursor === start) throw new Error('unsupported-native-edit: unknown GML token'); + const text = source.slice(start, cursor); + tokens.push({ text, value: text, start, end: cursor, quoted: false }); + } + const blocks: Block[] = [{ key: '', parent: null, entries: [], close: source.length }], stack = [0]; + cursor = 0; + while (cursor < tokens.length) { + const key = tokens[cursor++]; + if (key.text === ']') { + if (stack.length === 1) throw new Error('unsupported-native-edit: unexpected GML close'); + blocks[stack.pop()!].close = key.start; continue; + } + if (key.quoted || !/^[A-Za-z][A-Za-z0-9]*$/.test(key.text)) throw new Error('unsupported-native-edit: malformed GML key'); + const value = tokens[cursor++]; + if (!value || value.text === ']') throw new Error('unsupported-native-edit: missing GML value'); + const parent = stack.at(-1)!; + if (value.text === '[') { + const index = blocks.length; + blocks.push({ key: key.text, parent, entries: [], close: source.length }); + blocks[parent].entries.push({ key: key.text, block: index }); stack.push(index); + } else blocks[parent].entries.push({ key: key.text, value }); + } + if (stack.length !== 1) throw new Error('unsupported-native-edit: unclosed GML block'); + return blocks; +} + +function getValues(block: Block, key: string): Token[] { + return block.entries.filter((entry) => entry.key === key && entry.value).map((entry) => entry.value!); +} + +function getId(block: Block): string | undefined { + for (const token of getValues(block, 'stately')) { + try { + const value = JSON.parse(token.value); + if (value?.format === 'stately' && value.version === 1 && typeof value.originalId === 'string') return value.originalId; + } catch { /* Opaque native values never become an identity carrier. */ } + } + const ids = getValues(block, 'id'); + return ids.length === 1 ? ids[0].value : undefined; +} + +export function getGMLDocumentProjection(source: string, from: (source: string) => Graph, decode: (value: string) => string): GraphDocumentProjection { + const blocks = getBlocks(source, decode); + const graphs = blocks[0].entries.filter((entry) => entry.key === 'graph' && entry.block !== undefined); + if (graphs.length !== 1) return { graph: null, diagnostics: [{ code: 'projection-unavailable', message: 'GML document requires exactly one graph for a binary projection; all source graphs remain preserved', severity: 'warning' }] }; + return { graph: from(source), diagnostics: [{ code: 'projection-partial', message: 'The Graph view is partial; comments, ordering and extensions remain in the native source', severity: 'warning' }] }; +} + +/** Update literal labels within a unique native entity block; reject richer edits. */ +export function getGMLDocumentPatch(context: GraphDocumentPatchContext, decode: (value: string) => string, encode: (value: string) => string): GraphDocumentPatchResult { + const invalid = getLabelChangeError(context.changes); + if (invalid) return invalid; + const blocks = getBlocks(context.source, decode); + const graphIndex = blocks[0].entries.find((entry) => entry.key === 'graph' && entry.block !== undefined)?.block; + if (graphIndex === undefined) return getUnsupported(context.changes[0], 'Cannot locate the GML graph'); + const selected = new Set([graphIndex]); + for (let index = graphIndex + 1; index < blocks.length; index++) if (blocks[index].parent !== null && selected.has(blocks[index].parent!) && blocks[index].key === 'node') selected.add(index); + const edits: TextEdit[] = []; + for (const change of context.changes) { + const candidates = blocks.filter((block, index) => block.key === change.entity && getId(block) === change.id && (change.entity === 'node' ? selected.has(index) : block.parent === graphIndex)); + if (candidates.length !== 1) return getUnsupported(change, 'GML label editing requires a unique explicit native entity ID (or versioned originalId)'); + const block = candidates[0], labels = getValues(block, 'label'); + if (labels.length > 1 || (labels.length === 1 && !labels[0].quoted)) return getUnsupported(change, 'GML label must be a single quoted string'); + const label = change.after as string; + if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(label)) return { ok: false, diagnostics: [{ code: 'invalid-native-label', message: 'GML label contains unsupported control characters', severity: 'error', path: change.path }] }; + const text = encode(label); + edits.push(labels.length ? { start: labels[0].start, end: labels[0].end, text } + : { start: block.close, end: block.close, text: ` label ${text} ` }); + } + return { ok: true, source: getEditedSource(context.source, edits) }; +} diff --git a/src/formats/gml/index.ts b/src/formats/gml/index.ts index 54475b8..11ee0a2 100644 --- a/src/formats/gml/index.ts +++ b/src/formats/gml/index.ts @@ -1,5 +1,20 @@ +import { createGraphDocumentConverter } from '../document'; +import { getGMLDocumentPatch, getGMLDocumentProjection } from './document'; import type { Graph, GraphNode, GraphFormatConverter } from '../../types'; import { createFormatConverter } from '../converter'; +import { createFormatData, getFormatMetadata, getUserData } from '../xml-preservation'; + +export interface GMLImportOptions { + /** Disable legacy backslash escapes and read native GML strings literally. */ + standard?: boolean; +} + +export interface GMLExportOptions { + /** Use integer identity references required by standard GML; retain Graph IDs in a versioned extension. */ + standard?: boolean; +} + +const blockEntries = new WeakMap>(); // --- GML serializer --- @@ -25,24 +40,51 @@ import { createFormatConverter } from '../converter'; * // ] * ``` */ -export function toGML(graph: Graph): string { +export function toGML(graph: Graph, options: GMLExportOptions = {}): string { const lines: string[] = []; + const getString = options.standard ? getValidatedGMLString : getGMLString; + if (options.standard) { + for (const entity of [graph, ...graph.nodes, ...graph.edges]) { + for (const key of ['id', 'parentId', 'initialNodeId', 'sourceId', 'targetId']) { + const value = (entity as unknown as Record)[key]; + if (typeof value === 'string') getValidatedGMLString(value); + } + } + } + const nodeIds = new Map(graph.nodes.map((node,index) => [node.id,index])); + const getNodeId = (id: string): string => options.standard ? String(nodeIds.get(id) ?? (() => { throw new Error(`GML: unknown endpoint ${id}`); })()) : getString(id); + getNativeLines(lines, getFormatMetadata(graph.data,'gml')?.topLevel ?? [], '', options.standard); lines.push('graph ['); lines.push(` directed ${graph.mode === 'undirected' ? 0 : 1}`); + if (options.standard) { + const pairs = new Set(); + let parallel = false; + for (const edge of graph.edges) { + const pair = graph.mode === 'undirected' ? [edge.sourceId, edge.targetId].sort() : [edge.sourceId, edge.targetId]; + const key = JSON.stringify(pair); + if (pairs.has(key)) parallel = true; + pairs.add(key); + } + const hints = (getFormatMetadata(graph.data, 'gml')?.entries ?? []).filter((entry: any[]) => entry[0] === 'multigraph'); + if (parallel && hints.length && (hints.length !== 1 || hints[0][1] !== 1)) throw new Error('GML standard profile: native multigraph hint conflicts with parallel edges'); + if (parallel && !hints.length) lines.push(' multigraph 1'); + } // GML `directed` is binary; preserve bidirectional via a dialect key. if (graph.mode === 'bidirectional') { - lines.push(` mode ${gmlString(graph.mode)}`); + lines.push(` mode ${getString(graph.mode)}`); } - if (graph.id) lines.push(` id ${gmlString(graph.id)}`); - if (graph.initialNodeId) { - lines.push(` initialNodeId ${gmlString(graph.initialNodeId)}`); + if (graph.id !== '') lines.push(` id ${options.standard ? '0' : getString(graph.id)}`); + if (options.standard) lines.push(` stately ${getString(JSON.stringify({ format:'stately',version:1,originalId:graph.id }))}`); + getNativeLines(lines,getFormatMetadata(graph.data,'gml')?.entries ?? [],' ', options.standard); + if (graph.initialNodeId != null) { + lines.push(` initialNodeId ${getString(graph.initialNodeId)}`); } - if (graph.data !== undefined) { - lines.push(` data ${gmlString(JSON.stringify(graph.data))}`); + if (getUserData(graph.data,'gml') !== undefined) { + lines.push(` data ${getString(JSON.stringify(getUserData(graph.data,'gml')))}`); } - if (graph.direction) lines.push(` direction ${gmlString(graph.direction)}`); + if (graph.direction) lines.push(` direction ${getString(graph.direction)}`); if (graph.style !== undefined) { - lines.push(` style ${gmlString(JSON.stringify(graph.style))}`); + lines.push(` style ${getString(JSON.stringify(graph.style))}`); } // Build children map for hierarchical nesting @@ -54,29 +96,32 @@ export function toGML(graph: Graph): string { } // Recursive node writer - function writeNode(node: GraphNode, indent: string) { + function addNodeLines(node: GraphNode, indent: string) { lines.push(`${indent}node [`); - lines.push(`${indent} id ${gmlString(node.id)}`); - if (node.label) lines.push(`${indent} label ${gmlString(node.label)}`); - if (node.initialNodeId) - lines.push(`${indent} initialNodeId ${gmlString(node.initialNodeId)}`); - if (node.data !== undefined) - lines.push(`${indent} data ${gmlString(JSON.stringify(node.data))}`); + lines.push(`${indent} id ${getNodeId(node.id)}`); + lines.push(`${indent} stately ${getString(JSON.stringify({ format:'stately',version:1,index:nodeIds.get(node.id),...(options.standard && {originalId:node.id,parentId:node.parentId}),...(node.label === null && {nullLabel:true}) }))}`); + getNativeLines(lines,getFormatMetadata(node.data,'gml')?.entries ?? [],indent + ' ', options.standard); + if (node.label != null && !(node.label === '' && getFormatMetadata(node.data, 'gml')?.implicitLabel)) lines.push(`${indent} label ${getString(node.label)}`); + if (node.initialNodeId != null) + lines.push(`${indent} initialNodeId ${getString(node.initialNodeId)}`); + if (getUserData(node.data,'gml') !== undefined) + lines.push(`${indent} data ${getString(JSON.stringify(getUserData(node.data,'gml')))}`); if (node.ports !== undefined) { - lines.push(`${indent} ports ${gmlString(JSON.stringify(node.ports))}`); + lines.push(`${indent} ports ${getString(JSON.stringify(node.ports))}`); } - if (node.shape) lines.push(`${indent} shape ${gmlString(node.shape)}`); - if (node.color) lines.push(`${indent} color ${gmlString(node.color)}`); + if (node.shape !== undefined) lines.push(`${indent} shape ${getString(node.shape)}`); + if (node.color !== undefined) lines.push(`${indent} color ${getString(node.color)}`); if (node.style !== undefined) { - lines.push(`${indent} style ${gmlString(JSON.stringify(node.style))}`); + lines.push(`${indent} style ${getString(JSON.stringify(node.style))}`); } if ( node.x !== undefined || node.y !== undefined || node.width !== undefined || - node.height !== undefined + node.height !== undefined || getFormatMetadata(node.data,'gml')?.graphics?.length ) { lines.push(`${indent} graphics [`); + getNativeLines(lines,getFormatMetadata(node.data,'gml')?.graphics ?? [],indent+' ', options.standard); if (node.x !== undefined) lines.push(`${indent} x ${node.x}`); if (node.y !== undefined) lines.push(`${indent} y ${node.y}`); if (node.width !== undefined) lines.push(`${indent} w ${node.width}`); @@ -86,53 +131,56 @@ export function toGML(graph: Graph): string { } // Nested children const children = childrenMap.get(node.id); - if (children) { + if (children && !options.standard) { for (const child of children) { - writeNode(child, indent + ' '); + addNodeLines(child, indent + ' '); } } lines.push(`${indent}]`); } // Write root nodes - const roots = childrenMap.get(null) ?? []; + const roots = options.standard ? graph.nodes : childrenMap.get(null) ?? []; for (const node of roots) { - writeNode(node, ' '); + addNodeLines(node, ' '); } - for (const edge of graph.edges) { + for (const [edgeIndex,edge] of graph.edges.entries()) { lines.push(' edge ['); - lines.push(` id ${gmlString(edge.id)}`); - lines.push(` source ${gmlString(edge.sourceId)}`); - lines.push(` target ${gmlString(edge.targetId)}`); - if (edge.mode) lines.push(` mode ${gmlString(edge.mode)}`); - if (edge.label) lines.push(` label ${gmlString(edge.label)}`); - if (edge.data !== undefined) - lines.push(` data ${gmlString(JSON.stringify(edge.data))}`); + lines.push(` id ${options.standard ? edgeIndex : getString(edge.id)}`); + if (options.standard || edge.label === null) lines.push(` stately ${getString(JSON.stringify({format:'stately',version:1,...(options.standard && {originalId:edge.id}),...(edge.label === null && {nullLabel:true})}))}`); + getNativeLines(lines,getFormatMetadata(edge.data,'gml')?.entries ?? [],' ', options.standard); + lines.push(` source ${getNodeId(edge.sourceId)}`); + lines.push(` target ${getNodeId(edge.targetId)}`); + if (edge.mode) lines.push(` mode ${getString(edge.mode)}`); + if (edge.label != null && !(edge.label === '' && getFormatMetadata(edge.data, 'gml')?.implicitLabel)) lines.push(` label ${getString(edge.label)}`); + if (getUserData(edge.data,'gml') !== undefined) + lines.push(` data ${getString(JSON.stringify(getUserData(edge.data,'gml')))}`); if (edge.weight !== undefined) lines.push(` weight ${edge.weight}`); if (edge.points !== undefined) { - lines.push(` points ${gmlString(JSON.stringify(edge.points))}`); + lines.push(` points ${getString(JSON.stringify(edge.points))}`); } if (edge.routing !== undefined) { - lines.push(` routing ${gmlString(edge.routing)}`); + lines.push(` routing ${getString(edge.routing)}`); } if (edge.sourcePort !== undefined) { - lines.push(` sourcePort ${gmlString(edge.sourcePort)}`); + lines.push(` sourcePort ${getString(edge.sourcePort)}`); } if (edge.targetPort !== undefined) { - lines.push(` targetPort ${gmlString(edge.targetPort)}`); + lines.push(` targetPort ${getString(edge.targetPort)}`); } - if (edge.color) lines.push(` color ${gmlString(edge.color)}`); + if (edge.color !== undefined) lines.push(` color ${getString(edge.color)}`); if (edge.style !== undefined) { - lines.push(` style ${gmlString(JSON.stringify(edge.style))}`); + lines.push(` style ${getString(JSON.stringify(edge.style))}`); } if ( edge.x !== undefined || edge.y !== undefined || edge.width !== undefined || - edge.height !== undefined + edge.height !== undefined || getFormatMetadata(edge.data,'gml')?.graphics?.length ) { lines.push(' graphics ['); + getNativeLines(lines,getFormatMetadata(edge.data,'gml')?.graphics ?? [],' ', options.standard); if (edge.x !== undefined) lines.push(` x ${edge.x}`); if (edge.y !== undefined) lines.push(` y ${edge.y}`); if (edge.width !== undefined) lines.push(` w ${edge.width}`); @@ -146,8 +194,62 @@ export function toGML(graph: Graph): string { return lines.join('\n'); } -function gmlString(s: string): string { - return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +function getGMLString(value: string): string { + return `"${[...value].map((ch) => ch === '&' ? '&' : ch === '"' ? '"' : ch === '\\' || ch.codePointAt(0)! < 32 || ch.codePointAt(0)! >= 127 ? `&#${ch.codePointAt(0)};` : ch).join('')}"`; +} + +function getValidatedGMLString(value: string): string { + for (const character of value) { + const code = character.codePointAt(0)!; + if (code >= 0xd800 && code <= 0xdfff) throw new Error('GML standard profile: native strings must contain Unicode scalar values, not lone surrogates'); + } + return getGMLString(value); +} + +function getDecodedString(value: string): string { + const names: Record = {"AElig": "Æ", "Aacute": "Á", "Acirc": "Â", "Agrave": "À", "Aring": "Å", "Atilde": "Ã", "Auml": "Ä", "Ccedil": "Ç", "ETH": "Ð", "Eacute": "É", "Ecirc": "Ê", "Egrave": "È", "Euml": "Ë", "Iacute": "Í", "Icirc": "Î", "Igrave": "Ì", "Iuml": "Ï", "Ntilde": "Ñ", "Oacute": "Ó", "Ocirc": "Ô", "Ograve": "Ò", "Oslash": "Ø", "Otilde": "Õ", "Ouml": "Ö", "THORN": "Þ", "Uacute": "Ú", "Ucirc": "Û", "Ugrave": "Ù", "Uuml": "Ü", "Yacute": "Ý", "aacute": "á", "acirc": "â", "acute": "´", "aelig": "æ", "agrave": "à", "amp": "&", "aring": "å", "atilde": "ã", "auml": "ä", "brvbar": "¦", "ccedil": "ç", "cedil": "¸", "cent": "¢", "copy": "©", "curren": "¤", "deg": "°", "divide": "÷", "eacute": "é", "ecirc": "ê", "egrave": "è", "eth": "ð", "euml": "ë", "frac12": "½", "frac14": "¼", "frac34": "¾", "gt": ">", "iacute": "í", "icirc": "î", "iexcl": "¡", "igrave": "ì", "iquest": "¿", "iuml": "ï", "laquo": "«", "lt": "<", "macr": "¯", "micro": "µ", "middot": "·", "nbsp": " ", "not": "¬", "ntilde": "ñ", "oacute": "ó", "ocirc": "ô", "ograve": "ò", "ordf": "ª", "ordm": "º", "oslash": "ø", "otilde": "õ", "ouml": "ö", "para": "¶", "plusmn": "±", "pound": "£", "quot": "\"", "raquo": "»", "reg": "®", "sect": "§", "shy": "­", "sup1": "¹", "sup2": "²", "sup3": "³", "szlig": "ß", "thorn": "þ", "times": "×", "uacute": "ú", "ucirc": "û", "ugrave": "ù", "uml": "¨", "uuml": "ü", "yacute": "ý", "yen": "¥", "yuml": "ÿ", "apos": "'"}; + return value.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g,(full,name: string) => { + if (name[0] !== '#') return names[name] ?? full; + const code = name[1] === 'x' ? Number.parseInt(name.slice(2),16) : Number(name.slice(1)); + if (!Number.isInteger(code) || code < 0 || code > 0x10ffff) throw new Error('GML: invalid character entity'); + return String.fromCodePoint(code); + }); +} + +function getPrivateData(value: unknown): any { + const parsed = Array.isArray(value) ? value.map(getParsedJSON).find((entry) => entry?.format === 'stately' && entry.version === 1) : getParsedJSON(value); + return parsed?.format === 'stately' && parsed.version === 1 ? parsed : undefined; +} + +function getNativeEntries(block: any, known: Set): Array<[string,any]> { + const entries: Array<[string,any]> = []; + const pending = [{ source:blockEntries.get(block) ?? [], output:entries, filter:known }]; + while (pending.length) { + const current = pending.pop()!; + for (const [key,value] of current.source) { + if (current.filter.has(key) && !(key === 'stately' && !getPrivateData(value))) continue; + if (value && typeof value === 'object' && !Array.isArray(value)) { + const list: Array<[string,any]> = []; + current.output.push([key,{list}]); + pending.push({source:blockEntries.get(value) ?? [],output:list,filter:new Set()}); + } else current.output.push([key,value]); + } + } + return entries; +} + +function getNativeLines(lines: string[], entries: Array<[string,any]>, indent: string, standard = false): void { + const tasks: Array<{ entry?: [string,any]; indent:string; close?: boolean }> = [...entries].reverse().map((entry) => ({entry,indent})); + while (tasks.length) { + const task = tasks.pop()!; + if (task.close) { lines.push(`${task.indent}]`); continue; } + const [key,value] = task.entry!; + if (value && typeof value === 'object' && Array.isArray(value.list)) { + lines.push(`${task.indent}${key} [`); + tasks.push({indent:task.indent,close:true}); + for (const entry of [...value.list].reverse()) tasks.push({entry,indent:task.indent+' '}); + } else lines.push(`${task.indent}${key} ${typeof value === 'number' ? value : (standard ? getValidatedGMLString : getGMLString)(String(value))}`); + } } // --- GML parser --- @@ -169,81 +271,89 @@ function gmlString(s: string): string { * `); * ``` */ -export function fromGML(gml: string): Graph { +export function fromGML(gml: string, options: GMLImportOptions = {}): Graph { if (typeof gml !== 'string') { throw new Error('GML: expected a string'); } if (!gml.trim()) { throw new Error('GML: input is empty'); } - const tokens = tokenize(gml); + const tokens = getTokens(gml, !options.standard); if (tokens.length === 0) { throw new Error('GML: no tokens found'); } - const obj = parseBlock(tokens, 0); + const obj = getParsedBlock(tokens, 0); const graphBlock = obj.value['graph']; if (!graphBlock) throw new Error('GML: missing top-level "graph" block'); const directed = graphBlock['directed'] === 1; - const graphId = String(graphBlock['id'] ?? ''); + const graphId = getPrivateData(graphBlock['stately'])?.originalId ?? String(graphBlock['id'] ?? ''); + const nativeNodeIds = new Map(); + const nodeOrder = new Map(); const nodes: Graph['nodes'] = []; const edges: Graph['edges'] = []; // Parse nodes (with recursive hierarchy) - function parseNodes(block: any, parentId: string | null) { - const nodeEntries = asArray(block['node']); + function addNodes(block: any, parentId: string | null) { + const nodeEntries = getArray(block['node']); for (const n of nodeEntries) { - const id = String(n['id'] ?? ''); + const nativeId = String(n['id'] ?? ''); + const privateData = getPrivateData(n['stately']); + const id = privateData?.originalId ?? nativeId; + nativeNodeIds.set(nativeId,id); + if (privateData?.index !== undefined) nodeOrder.set(id,privateData.index); const gfx = n['graphics']; nodes.push({ type: 'node', id, - parentId, + parentId: privateData?.parentId ?? parentId, initialNodeId: n['initialNodeId'] ?? null, - label: n['label'] ?? '', - data: n['data'] !== undefined ? tryParseJSON(n['data']) : undefined, - ...(n['ports'] !== undefined && { ports: tryParseJSON(n['ports']) }), - ...(n['shape'] && { shape: n['shape'] }), - ...(n['color'] && { color: n['color'] }), - ...(n['style'] !== undefined && { style: tryParseJSON(n['style']) }), + label: (n['label'] === undefined && privateData?.nullLabel) ? null : n['label'] ?? '', + data: createFormatData(n['data'] !== undefined ? getParsedJSON(n['data']) : undefined,'gml',getNativeMetadata(n,'node')), + ...(n['ports'] !== undefined && { ports: getParsedJSON(n['ports']) }), + ...(n['shape'] !== undefined && { shape: n['shape'] }), + ...(n['color'] !== undefined && { color: n['color'] }), + ...(n['style'] !== undefined && { style: getParsedJSON(n['style']) }), ...(gfx?.x !== undefined && { - x: parseNumber(gfx.x, 'graphics x', 'node', id), + x: getNumber(gfx.x, 'graphics x', 'node', id), }), ...(gfx?.y !== undefined && { - y: parseNumber(gfx.y, 'graphics y', 'node', id), + y: getNumber(gfx.y, 'graphics y', 'node', id), }), ...(gfx?.w !== undefined && { - width: parseNumber(gfx.w, 'graphics w', 'node', id), + width: getNumber(gfx.w, 'graphics w', 'node', id), }), ...(gfx?.h !== undefined && { - height: parseNumber(gfx.h, 'graphics h', 'node', id), + height: getNumber(gfx.h, 'graphics h', 'node', id), }), }); // Recurse into nested child nodes if (n['node'] !== undefined) { - parseNodes(n, id); + addNodes(n, id); } } } - parseNodes(graphBlock, null); + addNodes(graphBlock, null); - const edgeEntries = asArray(graphBlock['edge']); + if (nodeOrder.size === nodes.length) nodes.sort((left,right) => nodeOrder.get(left.id)! - nodeOrder.get(right.id)!); + const edgeEntries = getArray(graphBlock['edge']); for (const e of edgeEntries) { const gfx = e['graphics']; - const id = String(e['id'] ?? `e${edges.length}`); + const privateData = getPrivateData(e['stately']); + const id = privateData?.originalId ?? String(e['id'] ?? `e${edges.length}`); edges.push({ type: 'edge', id, - sourceId: String(e['source'] ?? ''), - targetId: String(e['target'] ?? ''), - label: e['label'] ?? '', + sourceId: nativeNodeIds.get(String(e['source'] ?? '')) ?? String(e['source'] ?? ''), + targetId: nativeNodeIds.get(String(e['target'] ?? '')) ?? String(e['target'] ?? ''), + label: (e['label'] === undefined && privateData?.nullLabel) ? null : e['label'] ?? '', ...(e['mode'] && { mode: String(e['mode']) as Graph['mode'] }), - data: e['data'] !== undefined ? tryParseJSON(e['data']) : undefined, + data: createFormatData(e['data'] !== undefined ? getParsedJSON(e['data']) : undefined,'gml',getNativeMetadata(e,'edge')), ...(e['weight'] !== undefined && { - weight: parseNumber(e['weight'], 'weight', 'edge', id), + weight: getNumber(e['weight'], 'weight', 'edge', id), }), - ...(e['points'] !== undefined && { points: tryParseJSON(e['points']) }), + ...(e['points'] !== undefined && { points: getParsedJSON(e['points']) }), ...(e['routing'] !== undefined && { routing: String(e['routing']) as Graph['edges'][number]['routing'], }), @@ -253,19 +363,19 @@ export function fromGML(gml: string): Graph { ...(e['targetPort'] !== undefined && { targetPort: String(e['targetPort']), }), - ...(e['color'] && { color: e['color'] }), - ...(e['style'] !== undefined && { style: tryParseJSON(e['style']) }), + ...(e['color'] !== undefined && { color: e['color'] }), + ...(e['style'] !== undefined && { style: getParsedJSON(e['style']) }), ...(gfx?.x !== undefined && { - x: parseNumber(gfx.x, 'graphics x', 'edge', id), + x: getNumber(gfx.x, 'graphics x', 'edge', id), }), ...(gfx?.y !== undefined && { - y: parseNumber(gfx.y, 'graphics y', 'edge', id), + y: getNumber(gfx.y, 'graphics y', 'edge', id), }), ...(gfx?.w !== undefined && { - width: parseNumber(gfx.w, 'graphics w', 'edge', id), + width: getNumber(gfx.w, 'graphics w', 'edge', id), }), ...(gfx?.h !== undefined && { - height: parseNumber(gfx.h, 'graphics h', 'edge', id), + height: getNumber(gfx.h, 'graphics h', 'edge', id), }), }); } @@ -280,15 +390,12 @@ export function fromGML(gml: string): Graph { initialNodeId: graphBlock['initialNodeId'] ?? null, nodes, edges, - data: - graphBlock['data'] !== undefined - ? tryParseJSON(graphBlock['data']) - : (undefined as any), + data: createFormatData(graphBlock['data'] !== undefined ? getParsedJSON(graphBlock['data']) : undefined,'gml', { ...(getNativeEntries(graphBlock,new Set(['id','directed','mode','initialNodeId','data','direction','style','node','edge','stately'])).length && {entries:getNativeEntries(graphBlock,new Set(['id','directed','mode','initialNodeId','data','direction','style','node','edge','stately']))}), ...(getNativeEntries(obj.value,new Set(['graph'])).length && {topLevel:getNativeEntries(obj.value,new Set(['graph']))}) }), ...(graphBlock['direction'] && { direction: String(graphBlock['direction']) as Graph['direction'], }), ...(graphBlock['style'] !== undefined && { - style: tryParseJSON(graphBlock['style']), + style: getParsedJSON(graphBlock['style']), }), }; } @@ -300,7 +407,7 @@ interface Token { value: any; } -function tokenize(input: string): Token[] { +function getTokens(input: string, legacyEscapes: boolean): Token[] { const tokens: Token[] = []; let i = 0; while (i < input.length) { @@ -331,7 +438,7 @@ function tokenize(input: string): Token[] { i++; let s = ''; while (i < input.length && input[i] !== '"') { - if (input[i] === '\\' && i + 1 < input.length) { + if (legacyEscapes && input[i] === '\\' && i + 1 < input.length && ['\\','"'].includes(input[i + 1])) { i++; s += input[i]; } else { @@ -339,8 +446,9 @@ function tokenize(input: string): Token[] { } i++; } + if (i >= input.length) throw new Error('GML: unterminated string'); i++; // skip closing quote - tokens.push({ type: 'string', value: s }); + tokens.push({ type: 'string', value: getDecodedString(s) }); continue; } // Number or word @@ -368,63 +476,47 @@ function tokenize(input: string): Token[] { tokens.push({ type: 'word', value: word }); continue; } - i++; // skip unknown chars + throw new Error(`GML: unexpected character ${ch} at ${i}`); } return tokens; } // --- Parser --- -function parseBlock( - tokens: Token[], - pos: number, -): { value: Record; pos: number } { - const result: Record = {}; +function getParsedBlock(tokens: Token[], start: number): { value: Record; pos: number } { + const root: Record = Object.create(null); + blockEntries.set(root,[]); + const frames = [root]; + let pos = start; while (pos < tokens.length) { - const tok = tokens[pos]; - if (tok.type === 'close') { - pos++; - break; - } - if (tok.type === 'word') { - const key = tok.value; - pos++; - if (pos >= tokens.length) break; - const next = tokens[pos]; - if (next.type === 'open') { - pos++; // skip '[' - const nested = parseBlock(tokens, pos); - pos = nested.pos; - // Support multiple entries (e.g., multiple "node" blocks) - if (result[key] !== undefined) { - if (!Array.isArray(result[key])) result[key] = [result[key]]; - result[key].push(nested.value); - } else { - result[key] = nested.value; - } - } else { - // Scalar value - if (result[key] !== undefined) { - if (!Array.isArray(result[key])) result[key] = [result[key]]; - result[key].push(next.value); - } else { - result[key] = next.value; - } - pos++; - } - } else { - pos++; + const token = tokens[pos++]; + if (token.type === 'close') { + if (frames.length === 1) throw new Error('GML: unexpected closing bracket'); + frames.pop(); + continue; } + if (token.type !== 'word') throw new Error('GML: expected attribute key'); + const next = tokens[pos++]; + if (!next || next.type === 'close') throw new Error(`GML: missing value for ${token.value}`); + const current = frames[frames.length - 1]; + const value: any = next.type === 'open' ? Object.create(null) : next.value; + blockEntries.get(current)!.push([token.value,value]); + if (current[token.value] !== undefined) { + if (!Array.isArray(current[token.value])) current[token.value] = [current[token.value]]; + current[token.value].push(value); + } else current[token.value] = value; + if (next.type === 'open') { blockEntries.set(value,[]); frames.push(value); } } - return { value: result, pos }; + if (frames.length !== 1) throw new Error('GML: unclosed block'); + return {value:root,pos}; } -function asArray(val: T | T[] | undefined): T[] { +function getArray(val: T | T[] | undefined): T[] { if (val === undefined) return []; return Array.isArray(val) ? val : [val]; } -function tryParseJSON(str: any): any { +function getParsedJSON(str: any): any { if (typeof str !== 'string') return str; try { return JSON.parse(str); @@ -433,7 +525,7 @@ function tryParseJSON(str: any): any { } } -function parseNumber( +function getNumber( value: any, field: string, kind: 'node' | 'edge', @@ -467,3 +559,32 @@ function parseNumber( */ export const gmlConverter: GraphFormatConverter = createFormatConverter(toGML, fromGML); + +function getNativeMetadata(block: any, kind: 'node' | 'edge'): Record { + const known = kind === 'node' ? ['id','label','initialNodeId','data','ports','shape','color','style','graphics','node','stately'] + : ['id','source','target','label','mode','data','weight','points','routing','sourcePort','targetPort','color','style','graphics','stately']; + const entries = getNativeEntries(block,new Set(known)); + const graphics = getNativeEntries(block.graphics,new Set(['x','y','w','h'])); + return {...(entries.length && {entries}),...(graphics.length && {graphics}), + ...(block.label === undefined && !getPrivateData(block.stately)?.nullLabel && { implicitLabel: true })}; +} + +/** Native missing labels have no literal value; provenance is reconstructed from this source. */ +function getGMLDocumentGraph(source: string): Graph { + const graph = fromGML(source); + for (const entity of [...graph.nodes, ...graph.edges]) { + const metadata = getFormatMetadata(entity.data, 'gml'); + if (!metadata?.implicitLabel) continue; + const { format: _format, version: _version, wrapped: _wrapped, userData: _userData, absent: _absent, implicitLabel: _implicit, ...native } = metadata; + entity.data = createFormatData(getUserData(entity.data, 'gml'), 'gml', native); + entity.label = null; + } + return graph; +} + +/** Exact GML source with a partial Graph projection and quoted-label patches. */ +export const gmlDocumentConverter = createGraphDocumentConverter({ + format: 'gml', codec: { from: fromGML, to: toGML }, + project: (source) => getGMLDocumentProjection(source, getGMLDocumentGraph, getDecodedString), + patch: (context) => getGMLDocumentPatch(context, getDecodedString, getValidatedGMLString), +}); diff --git a/src/formats/graphlib/README.md b/src/formats/graphlib/README.md new file mode 100644 index 0000000..4e38b50 --- /dev/null +++ b/src/formats/graphlib/README.md @@ -0,0 +1,30 @@ +# Graphlib JSON + +Dependency-free conversion of `@dagrejs/graphlib` JSON. Pass output to native `json.read(source)`; import native `json.write(instance)` results directly. + + +```ts +import { toGraphlibJSON, fromGraphlibJSON, graphlibDocumentConverter } from '@statelyai/graph/graphlib'; +const source = toGraphlibJSON(graph); +const restored = fromGraphlibJSON(source); +const document = graphlibDocumentConverter.from(source); +const unchanged = graphlibDocumentConverter.to(document); +``` + +`graphlibConverter` exposes `.to(graph)` and `.from(source)`. Native Graphlib has one global edge mode. All effective Graph edges must agree; genuine mixed or bidirectional graphs throw. Fresh output is a named multigraph and enables compound mode when parents exist. Native imports honor omitted option defaults (`directed: true`, `multigraph: false`, `compound: false`) and reject duplicate edge tuples, missing references and cyclic parents. + +Node IDs may be empty strings. Edge identity includes source, target and native name; the same name on different pairs remains distinct. Parent fields map native hierarchy. Compound `__proto__` node IDs are rejected because Graphlib 4.0.1 can fail while assigning those children. + +Native root/node/edge `value` may be any JSON payload, including scalars, arrays, null and absent values. Representable object fields map labels, geometry, styles and weights; incompatible Graph-looking fields remain data. Scalar/array native values retain their exact Graph data and cannot receive separate label/style/geometry edits. Such edits fail explicitly instead of replacing the native value with an object. + + +Fresh Graph output uses object values with a `data` member and versioned, format-discriminated `__statelyai` metadata for Graph-only fields and original presence. Live native fields, parents, data and global direction remain authoritative. Unknown native fields are preserved in imported provenance; Graphlib's own reader/writer may discard fields outside its native schema. + +`fromGraphlibJSON(source, { metadata: 'native' })` disables marker interpretation; `toGraphlibJSON(graph, { nativeCarrier: false })` disables native-import provenance interpretation. Payloads deliberately matching the convention require these overrides. Fresh regeneration wraps arbitrary Graph data in native object values; it does not promise the original native value shape. + +Documents retain exact original JSON, values, ordering, extensions and presence through no-op and JSON persistence. Strict known-field/data edits patch stable native entities, preserve unknown values and verify the Graph projection. Structural, mode, parent, port and incompatible value edits reject; `.getUpdate(document)` returns diagnostics. Native source edits survive, while conflicting edits to the same field reject. Semantic coverage remains partial. + +Bare export preserves an imported simple/multigraph profile. Compatible node/edge insertion retains complete Graph identities; parallel insertion into a simple graph rejects. Retargeting object-valued edges adds identity metadata based on the final native tuple, without wrapping or replacing original attributes. Foreign marker properties survive under a free numbered metadata key. Retargeting opaque scalar/array edge values is unsupported. + + +Native proof: Graphlib 4.0.1 `json.read/write` checks global modes, named parallels, compound parents, self-loops, isolates, empty IDs/names, opaque values, edits and a 2,000-node hierarchy. [Official JSON API](https://github.com/dagrejs/graphlib/wiki/API-Reference#jsonreadjson). diff --git a/src/formats/graphlib/index.ts b/src/formats/graphlib/index.ts new file mode 100644 index 0000000..4fb8c24 --- /dev/null +++ b/src/formats/graphlib/index.ts @@ -0,0 +1,124 @@ +import type { Graph } from '../../types'; +import { createFormatConverter } from '../converter'; +import { createGraphDocumentConverter } from '../document'; +import { createNativeCarrier, getNativeCarrier, getCarrierData, getClonedJSON, getNativeDocumentGraph, isJSONEqual, type FormatImportOptions, type FormatExportOptions } from '../preservation'; +import { getAttributeRecord, getNativeAttributes, getUpdatedAttributes, getNodeBase, getEdgeBase, getRootBase, getValidatedGraph, getUniformMode, getAttributeDocumentPatch, getRecordWithField, getNativeMetadata, getRebasedAttributes } from '../structured-json'; + +export interface GraphlibJSON { + options: { directed?: boolean; multigraph?: boolean; compound?: boolean }; + value?: any; + nodes: Array<{ v: string; value?: any; parent?: string }>; + edges: Array<{ v: string; w: string; name?: string; value?: any }>; +} +export interface GraphlibImportOptions extends FormatImportOptions {} +export interface GraphlibExportOptions extends FormatExportOptions {} + +function getGraphlibRecord(value: any, base: Record, kind: 'graph' | 'node' | 'edge', native: boolean): any { + return value && typeof value === 'object' && !Array.isArray(value) ? getAttributeRecord(value, base, kind, 'graphlib', native) : { ...base, data: getClonedJSON(value) }; +} +function getUpdatedValue(value: any, before: any, current: any, kind: 'graph' | 'node' | 'edge', base: any): any { + if (isJSONEqual(before, current)) return getClonedJSON(value); + if (value && typeof value === 'object' && !Array.isArray(value)) return getUpdatedAttributes(value, before, current, kind, 'graphlib', base); + const { data: _beforeData, ...beforeFields } = before; + const { data: _data, ...fields } = current; + if (isJSONEqual(beforeFields, fields)) return getClonedJSON(current.data); + throw new Error('Graphlib: scalar/array native values cannot receive separate field or identity edits'); +} +function getValidatedSource(source: GraphlibJSON): void { + if (!source || !Array.isArray(source.nodes) || !Array.isArray(source.edges) || !source.options || ['directed', 'multigraph', 'compound'].some((key) => Object.hasOwn(source.options, key) && typeof (source.options as any)[key] !== 'boolean')) throw new Error('Graphlib: expected native options, nodes and edges'); + const nodes = new Map(), edges = new Set(); + for (const node of source.nodes) { + if (typeof node.v !== 'string' || nodes.has(node.v)) throw new Error('Graphlib: node IDs must be unique strings'); + if (source.options.compound && node.v === '__proto__') throw new Error('Graphlib: unsafe native compound ID __proto__'); + if (node.parent !== undefined && (!source.options.compound || typeof node.parent !== 'string')) throw new Error('Graphlib: invalid native parent'); + nodes.set(node.v, node.parent); + } + const completed = new Set(); + for (const id of nodes.keys()) { + const visiting = new Set(); + let current: string | undefined = id; + while (current !== undefined && !completed.has(current)) { + if (!nodes.has(current) || visiting.has(current)) throw new Error('Graphlib: missing parent or cyclic native hierarchy'); + visiting.add(current); current = nodes.get(current); + } + for (const parent of visiting) completed.add(parent); + } + for (const edge of source.edges) { + if (!nodes.has(edge.v) || !nodes.has(edge.w)) throw new Error('Graphlib: missing edge endpoint'); + if (edge.name !== undefined && (typeof edge.name !== 'string' || !source.options.multigraph)) throw new Error('Graphlib: named edges require a multigraph'); + const ends = (source.options.directed ?? true) ? [edge.v, edge.w] : [edge.v, edge.w].sort(); + const identity = JSON.stringify([...ends, edge.name ?? null]); + if (edges.has(identity)) throw new Error('Graphlib: duplicate native edge tuple'); + edges.add(identity); + } +} + +/** Import Graphlib serialization; arbitrary scalar/array/null labels remain Graph data. */ +export function fromGraphlibJSON(source: GraphlibJSON, options: GraphlibImportOptions = {}): Graph { + getValidatedSource(source); + const mode = (source.options.directed ?? true) ? 'directed' : 'undirected'; + const graph: Graph = { ...getGraphlibRecord(source.value, getRootBase(mode), 'graph', options.metadata === 'native'), + nodes: source.nodes.map((node) => { + const record = getGraphlibRecord(node.value, getNodeBase(node.v), 'node', options.metadata === 'native'); + const metadata = options.metadata === 'native' ? undefined : getNativeMetadata(node.value, 'graphlib', 'node'); + if (!metadata || (node.parent ?? null) !== (metadata.mapped.parentId ?? null)) record.parentId = node.parent ?? null; + return record; + }), + edges: source.edges.map((edge) => getGraphlibRecord(edge.value, getEdgeBase(JSON.stringify([edge.v, edge.w, edge.name ?? null]), edge.v, edge.w), 'edge', options.metadata === 'native')) }; + const rootMetadata = options.metadata === 'native' ? undefined : getNativeMetadata(source.value, 'graphlib', 'graph'); + if (rootMetadata && rootMetadata.mapped.mode !== mode) for (const edge of graph.edges) delete edge.mode; + getValidatedGraph(graph, 'Graphlib'); + graph.data = createNativeCarrier(graph.data, 'graphlib', source, graph); + return graph; +} + +function toRawGraphlibJSON(graph: Graph): GraphlibJSON { + getValidatedGraph(graph, 'Graphlib'); + const mode = getUniformMode(graph, 'Graphlib'); + const { nodes, edges, ...root } = graph; + const source: GraphlibJSON = { options: { directed: mode === 'directed', multigraph: true, compound: nodes.some((node) => node.parentId != null) }, + value: getNativeAttributes(root, 'graph', 'graphlib', getRootBase(mode)), + nodes: nodes.map((node) => ({ v: node.id, value: getNativeAttributes(node, 'node', 'graphlib', getNodeBase(node.id)), ...(node.parentId != null && { parent: node.parentId }) })), + edges: edges.map((edge) => ({ v: edge.sourceId, w: edge.targetId, name: edge.id, value: getNativeAttributes(edge, 'edge', 'graphlib', getEdgeBase(JSON.stringify([edge.sourceId, edge.targetId, edge.id]), edge.sourceId, edge.targetId)) })) }; + getValidatedSource(source); return source; +} + +/** Native topology uses one global mode; genuine mixed/bidirectional graphs throw. */ +export function toGraphlibJSON(graph: Graph, options: GraphlibExportOptions = {}): GraphlibJSON { + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, 'graphlib'); + if (!carrier) return toRawGraphlibJSON(graph); + const current = { ...graph, data: getCarrierData(graph.data, 'graphlib') }; + const before = carrier.baseline as Graph; + const source = getClonedJSON(carrier.native) as GraphlibJSON; + const raw = toRawGraphlibJSON(current); + const nodes = new Map(before.nodes.map((node, index) => [node.id, { node, native: source.nodes[index] }])); + const edges = new Map(before.edges.map((edge, index) => [edge.id, { edge, native: source.edges[index] }])); + source.nodes = current.nodes.map((node, index) => { + const old = nodes.get(node.id); + if (!old) return raw.nodes[index]; + const result = getRecordWithField(old.native, 'value', getUpdatedValue(old.native.value, old.node, node, 'node', getNodeBase(node.id))); + if (node.parentId !== old.node.parentId) { if (node.parentId === null) delete result.parent; else result.parent = node.parentId; } + return result; + }); + source.edges = current.edges.map((edge, index) => { + const old = edges.get(edge.id); + const record = old ? { ...getRecordWithField(old.native, 'value', getUpdatedValue(old.native.value, old.edge, edge, 'edge', getEdgeBase(edge.id, edge.sourceId, edge.targetId))), v: edge.sourceId, w: edge.targetId } : getClonedJSON(raw.edges[index]); + if (!source.options.multigraph) delete record.name; + if (record.value && typeof record.value === 'object' && !Array.isArray(record.value)) record.value = getRebasedAttributes(record.value, edge, 'edge', 'graphlib', getEdgeBase(JSON.stringify([record.v, record.w, record.name ?? null]), edge.sourceId, edge.targetId)); + return record; + }); + const { nodes: _beforeNodes, edges: _beforeEdges, ...beforeRoot } = before; + const { nodes: _nodes, edges: _edges, ...root } = current; + const result = getRecordWithField(source, 'value', getUpdatedValue(source.value, beforeRoot, root, 'graph', getRootBase(getUniformMode(current, 'Graphlib')))); + if (getUniformMode(current, 'Graphlib') !== getUniformMode(before, 'Graphlib')) result.options.directed = getUniformMode(current, 'Graphlib') === 'directed'; + if (current.nodes.some((node) => node.parentId != null)) result.options.compound = true; + getValidatedSource(result); return result; +} + +export const graphlibConverter = createFormatConverter(toGraphlibJSON, fromGraphlibJSON); +/** Exact original values/source shape, with bounded edits that never replace opaque values. */ +export const graphlibDocumentConverter = createGraphDocumentConverter({ + format: 'graphlib', codec: { from: fromGraphlibJSON, to: (graph) => toGraphlibJSON(graph, { nativeCarrier: false }) }, + project: (source: GraphlibJSON) => ({ graph: getNativeDocumentGraph(fromGraphlibJSON(source), 'graphlib', source) }), + patch: (context) => getAttributeDocumentPatch(context, 'graphlib', (graph) => toGraphlibJSON({ ...graph, data: createNativeCarrier(graph.data, 'graphlib', context.source, context.sourceGraph) })), +}); diff --git a/src/formats/graphml/README.md b/src/formats/graphml/README.md index 54f1daf..fa29c1f 100644 --- a/src/formats/graphml/README.md +++ b/src/formats/graphml/README.md @@ -8,6 +8,18 @@ Converter for [GraphML](http://graphml.graphdrawing.org/) — the XML-based grap npm install fast-xml-parser ``` +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **partial**. +Bare Graph APIs preserve no source document. `graphMLDocumentConverter` retains +exact native source, including comments and unknown syntax, after JSON persistence. +Its Graph projection and supported edits remain partial; selected-field tests do +not prove unrestricted semantic losslessness. + +Resolves scoped key names/defaults, nested directedness, namespace aliases and native ports. Unknown data/declarations use versioned `_graphml` metadata, wrapping primitive user data when needed. Imports the first top-level graph only; hyperedges throw. Comments, complete document structure and vendor semantics remain outside this Graph converter. + ## Resources - [GraphML specification](http://graphml.graphdrawing.org/specification.html) @@ -18,7 +30,7 @@ npm install fast-xml-parser ```ts -import { toGraphML, fromGraphML, graphmlConverter } from '@statelyai/graph/graphml'; +import { toGraphML, fromGraphML, graphmlConverter, graphMLDocumentConverter } from '@statelyai/graph/graphml'; import { createGraph } from '@statelyai/graph'; // Export @@ -58,18 +70,20 @@ const imported = fromGraphML(` // edges: - (fetch -> parse) - (parse -> validate) - (validate -> store) ``` -### `toGraphML(graph): string` +### `toGraphML(graph, options?: GraphMLExportOptions): string` Produces GraphML XML with: -- `` declarations for label, parentId, initialNodeId, data, style, x, y, width, height, shape, color, weight, ports, sourcePort, targetPort, and edge mode +- `` declarations for label, parentId, initialNodeId, data, style, x, y, width, height, shape, color, weight, ports, sourcePort, and targetPort - `` elements on nodes, edges, and the graph -- JSON-serialized `data`, `style`, and `ports` fields +- JSON-serialized `data`, `style`, and portable `ports` fields; native `` elements and endpoint port attributes +- Versioned private presence/mode data preserves Graph null labels and bidirectional edges; changed native labels/direction take precedence +- Unknown data subtrees retain local namespace declarations; output is compact XML to avoid injected whitespace ### `fromGraphML(xml): Graph` -Parses `` declarations and `` elements. JSON-encoded `data`, `style`, and `ports` fields are auto-parsed. +Checks XML well-formedness, resolves key IDs through scoped declarations, applies defaults and recognizes `directed="0"`/`"1"`. Nested graphs supply their own edge defaults. JSON-encoded fields remain backward-readable. Native port descriptions, data and nesting are retained; new exporter metadata permits live native port edits to supersede its portable extension. Legacy portable-only payloads keep their existing precedence. This convenience importer does not validate GraphML against its XSD or interpret arbitrary vendor XML. ### `graphmlConverter` @@ -79,3 +93,50 @@ Pre-built `GraphFormatConverter`: const xml = graphmlConverter.to(graph); const graph = graphmlConverter.from(xml); ``` + +### `graphMLDocumentConverter` + + + +```ts +const document = graphMLDocumentConverter.from(xml); +const persisted = JSON.parse(JSON.stringify(document)); +const unchanged = graphMLDocumentConverter.to(persisted); // exact original text +if (document.graph) { + document.graph.nodes[0].label = 'Updated'; + const edited = graphMLDocumentConverter.to(document); +} +``` + +`from` retains source when projection is unavailable (`graph: null`) and reports +its diagnostics; `{ strict: true }` rejects unavailable projections. `to` patches +verified literal node/edge labels and proves the resulting Graph projection by +reparsing. Independent source edits survive; conflicting native and Graph field +edits throw an ordinary error carrying diagnostics. Unedited bytes stay intact. + +Label patches resolve namespaces and scoped key IDs through `attr.name="label"`. They require unique explicit entity IDs, a string label key and plain-text data. A missing label can be inserted through one existing compatible key; CDATA/mixed content, duplicate labels and synthesized edge IDs reject. The first top-level graph is projected with an explicit multiple-graph diagnostic; projected hyperedges retain the source with an unavailable binary projection. + +Nullable labels, structure, data, geometry and style edits currently require explicit +regeneration: `graphMLDocumentConverter.getUpdate(document, { mode: 'regenerate' })` returns a +result with loss diagnostics. Strict `to` never silently regenerates or replays +stale source after unsupported Graph edits. Native explicit labels, including empty strings, +are authoritative over private null metadata; null exports omit native labels. + +### Standards export profile + + + +`toGraphML(graph, { standard: true })` omits the optional empty graph ID and +rejects identifiers/port names outside the tested XSD NMTOKEN domain, duplicate +entity IDs, unresolved endpoints, invalid XML characters and nonfinite native +numbers. It preserves carriage returns through numeric references; Unicode +labels and JSON data remain supported. BMP Unicode identifiers are supported; +supplementary-plane identifiers reject because the pinned libxml2 validator +rejects them. IDs are never silently renamed. `GraphMLExportOptions` is exported. + +The native suite validates this profile against the official GraphML 1.0 XSD, +including its redefine and XLink dependencies. Arbitrary vendor extensions need +their own schema; this option is not a runtime XSD validator. A nested keyref +fixture exposes ambiguity in the published descendant identity constraints under +libxml2. Source documents retain the original nested structure; the bare exporter +uses flat nodes plus parent metadata, matching its existing wire behavior. diff --git a/src/formats/graphml/index.ts b/src/formats/graphml/index.ts index 0ebd989..01e0a72 100644 --- a/src/formats/graphml/index.ts +++ b/src/formats/graphml/index.ts @@ -1,4 +1,7 @@ -import { XMLBuilder, XMLParser } from 'fast-xml-parser'; +import { getStandardXML, getValidatedXMLValue, isXMLNMTOKEN } from '../xml-standard'; +import { createGraphDocumentConverter } from '../document'; +import { getXMLDocumentPatch, getXMLDocumentProjection } from '../xml-document'; +import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser'; import type { Graph, GraphEdge, @@ -7,10 +10,23 @@ import type { } from '../../types'; import { createFormatConverter } from '../converter'; import { getEdgeMode } from '../../mode'; +import { createFormatData, getFormatMetadata, getNormalizedXML, getNativeXML, getUserData } from '../xml-preservation'; const GRAPHML_NS = 'http://graphml.graphdrawing.org/xmlns'; -export function toGraphML(graph: Graph): string { +export interface GraphMLExportOptions { + /** Validate native identifiers/characters and omit the empty optional graph ID. */ + standard?: boolean; +} + +export function toGraphML(graph: Graph, options: GraphMLExportOptions = {}): string { + if (options.standard) { + const ids = [...(graph.id === '' ? [] : [graph.id]), ...graph.nodes.flatMap((node) => [node.id, ...(node.ports ?? []).map((port) => port.name)]), ...graph.edges.flatMap((edge) => [edge.id, edge.sourceId, edge.targetId, ...(edge.sourcePort !== undefined ? [edge.sourcePort] : []), ...(edge.targetPort !== undefined ? [edge.targetPort] : [])])]; + for (const id of ids) if (typeof id !== 'string' || !isXMLNMTOKEN(id)) throw new Error(`GraphML standard profile: native ID/port name is not an XML NMTOKEN: ${JSON.stringify(id)}`); + for (const group of [graph.nodes, graph.edges]) if (new Set(group.map((entity) => entity.id)).size !== group.length) throw new Error('GraphML standard profile: duplicate native entity IDs'); + const nodeIds = new Set(graph.nodes.map((node) => node.id)); + for (const edge of graph.edges) if (!nodeIds.has(edge.sourceId) || !nodeIds.has(edge.targetId)) throw new Error('GraphML standard profile: unresolved edge endpoint'); + } const keys = [ { '@_id': 'label', @@ -140,16 +156,30 @@ export function toGraphML(graph: Graph): string { }, ]; + // Dedicated wire metadata records presence without overriding native edits. + keys.push({ '@_id': '_stately', '@_for': 'all', '@_attr.name': '_stately', '@_attr.type': 'string' }); + const nativeKeys = getFormatMetadata(graph.data, 'graphml')?.keys ?? []; + const nativeIdMap = new Map(); + for (const key of nativeKeys) { + const id = String(key['@_id']); + let emittedId = id; + while (keys.some((existing) => existing['@_id'] === emittedId)) emittedId = `native_${emittedId}`; + nativeIdMap.set(id, emittedId); + keys.push({ ...getNativeXML(key), '@_id': emittedId }); + } + const getNativeData = (data: unknown): any[] => (getFormatMetadata(data, 'graphml')?.values ?? []) + .map((value: any) => ({ ...getNativeXML(value), '@_key': nativeIdMap.get(String(value['@_key'])) ?? value['@_key'] })); const nodes = graph.nodes.map((node) => { const data: Array<{ '@_key': string; '#text': string | number }> = []; - if (node.label) data.push({ '@_key': 'label', '#text': node.label }); - if (node.parentId) + if (node.label != null) data.push({ '@_key': 'label', '#text': node.label }); + if (node.label === null || node.ports !== undefined) data.push({ '@_key': '_stately', '#text': JSON.stringify({ format: 'stately', version: 1, ...(node.label === null && {nullLabel:true}), ...(node.ports !== undefined && {nativePorts:true}) }) }); + if (node.parentId != null) data.push({ '@_key': 'parentId', '#text': node.parentId }); - if (node.initialNodeId) { + if (node.initialNodeId != null) { data.push({ '@_key': 'initialNodeId', '#text': node.initialNodeId }); } - if (node.data !== undefined) { - data.push({ '@_key': 'data', '#text': JSON.stringify(node.data) }); + if (getUserData(node.data, 'graphml') !== undefined) { + data.push({ '@_key': 'data', '#text': JSON.stringify(getUserData(node.data, 'graphml')) }); } if (node.style !== undefined) { data.push({ '@_key': 'style', '#text': JSON.stringify(node.style) }); @@ -170,7 +200,8 @@ export function toGraphML(graph: Graph): string { return { '@_id': node.id, - ...(data.length > 0 && { data }), + ...((data.length > 0 || getNativeData(node.data).length > 0) && { data: [...data, ...getNativeData(node.data)] }), + ...(node.ports && { port: getNativePorts(node.ports, getNativeData) }), }; }); @@ -178,12 +209,13 @@ export function toGraphML(graph: Graph): string { const edges = graph.edges.map((edge) => { // Per-edge directedness override. GraphML edges carry an optional boolean // `directed` attribute; emit it only when the edge differs from the graph - // default. Bidirectional has no GraphML representation and maps to directed. + // default. Bidirectional uses directed native syntax plus private mode metadata. const edgeDirected = getEdgeMode(graph, edge) !== 'undirected'; const data: Array<{ '@_key': string; '#text': string | number }> = []; - if (edge.label) data.push({ '@_key': 'label', '#text': edge.label }); - if (edge.data !== undefined) { - data.push({ '@_key': 'data', '#text': JSON.stringify(edge.data) }); + if (edge.label != null) data.push({ '@_key': 'label', '#text': edge.label }); + if (edge.label === null || edge.mode !== undefined) data.push({ '@_key': '_stately', '#text': JSON.stringify({ format: 'stately', version: 1, ...(edge.label === null && { nullLabel: true }), ...(edge.mode !== undefined && { mode: edge.mode, nativeDirected: edgeDirected !== graphDirected ? String(edgeDirected) : null }) }) }); + if (getUserData(edge.data, 'graphml') !== undefined) { + data.push({ '@_key': 'data', '#text': JSON.stringify(getUserData(edge.data, 'graphml')) }); } if (edge.style !== undefined) { data.push({ '@_key': 'style', '#text': JSON.stringify(edge.style) }); @@ -220,15 +252,17 @@ export function toGraphML(graph: Graph): string { ...(edgeDirected !== graphDirected && { '@_directed': edgeDirected ? 'true' : 'false', }), - ...(data.length > 0 && { data }), + ...((data.length > 0 || getNativeData(edge.data).length > 0) && { data: [...data, ...getNativeData(edge.data)] }), + ...(edge.sourcePort !== undefined && { '@_sourceport': edge.sourcePort }), + ...(edge.targetPort !== undefined && { '@_targetport': edge.targetPort }), }; }); const graphData: Array<{ '@_key': string; '#text': string }> = []; - if (graph.data !== undefined) { - graphData.push({ '@_key': 'graphData', '#text': JSON.stringify(graph.data) }); + if (getUserData(graph.data, 'graphml') !== undefined) { + graphData.push({ '@_key': 'graphData', '#text': JSON.stringify(getUserData(graph.data, 'graphml')) }); } - if (graph.initialNodeId) { + if (graph.initialNodeId != null) { graphData.push({ '@_key': 'graphInitialNodeId', '#text': graph.initialNodeId, @@ -244,16 +278,17 @@ export function toGraphML(graph: Graph): string { }); } + if (graph.mode === 'bidirectional') graphData.push({ '@_key': '_stately', '#text': JSON.stringify({ format: 'stately', version: 1, mode: graph.mode, nativeDefault: 'directed' }) }); const obj = { '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, graphml: { '@_xmlns': GRAPHML_NS, key: keys, graph: { - '@_id': graph.id, + ...((!options.standard || graph.id !== '') && { '@_id': graph.id }), '@_edgedefault': graph.mode === 'undirected' ? 'undirected' : 'directed', - ...(graphData.length > 0 && { data: graphData }), + ...((graphData.length > 0 || getNativeData(graph.data).length > 0) && { data: [...graphData, ...getNativeData(graph.data)] }), node: nodes, edge: edges, }, @@ -262,13 +297,16 @@ export function toGraphML(graph: Graph): string { const builder = new XMLBuilder({ ignoreAttributes: false, - format: true, + // Formatting inserts whitespace into unknown mixed-content XML. + format: false, suppressEmptyNode: true, // Keep `directed="true"` intact instead of collapsing to a bare attribute. suppressBooleanAttributes: false, }); - return builder.build(obj); + if (options.standard) getValidatedXMLValue(obj); + const output = builder.build(obj); + return options.standard ? getStandardXML(output) : output; } export function fromGraphML(xml: string): Graph { @@ -276,10 +314,14 @@ export function fromGraphML(xml: string): Graph { throw new Error('GraphML: expected a string'); } + const validation = XMLValidator.validate(xml); + if (validation !== true) throw new Error(`GraphML: invalid XML — ${validation.err.msg}`); const parser = new XMLParser({ ignoreAttributes: false, + // Required by this peer to decode standard XML numeric character references. + htmlEntities: true, isArray: (name) => - ['node', 'edge', 'data', 'key', 'graph', 'port'].includes(name), + ['node', 'edge', 'data', 'key', 'graph', 'port', 'hyperedge'].includes(name.split(':').pop()!), // Keep text verbatim: "1.50" must not become 1.5 and // " hi " must not be trimmed. Numeric fields are parsed explicitly. parseTagValue: false, @@ -288,7 +330,7 @@ export function fromGraphML(xml: string): Graph { let parsed: any; try { - parsed = parser.parse(xml); + parsed = getNormalizedXML(parser.parse(xml), GRAPHML_NS); } catch (e: any) { throw new Error(`GraphML: invalid XML — ${e.message}`); } @@ -299,38 +341,42 @@ export function fromGraphML(xml: string): Graph { } // Multi-graph documents: import the first only. - const graphEl = asArray(graphml.graph)[0]; + const graphEl = getArray(graphml.graph)[0]; if (!graphEl) { throw new Error('GraphML: missing element'); } + const definitions = getArray(graphml.key); const graphType: 'directed' | 'undirected' = graphEl['@_edgedefault'] === 'undirected' ? 'undirected' : 'directed'; - const graphDataMap = parseDataElements(graphEl.data); + const graphDataMap = getDataElements(graphEl.data, definitions, 'graph'); const graphData = graphDataMap.graphData !== undefined - ? tryParseJSON(graphDataMap.graphData) + ? getParsedJSON(graphDataMap.graphData) : undefined; // Standard GraphML nests subgraphs as … and // allows edges inside nested elements. Collect nodes (with their - // structural parent) and edges recursively; node ids are document-global, + // structural parent) and edges iteratively; node ids are document-global, // so edge endpoint resolution is unaffected by nesting depth. const nodeEntries: Array<{ el: any; structuralParentId: string | null }> = []; - const edgeEls: any[] = []; - function collectGraphContents(gEl: any, parentId: string | null) { - for (const nodeEl of asArray(gEl.node)) { - nodeEntries.push({ el: nodeEl, structuralParentId: parentId }); - for (const subgraphEl of asArray(nodeEl.graph)) { - collectGraphContents(subgraphEl, String(nodeEl['@_id'])); - } + const edgeEntries: Array<{ el: any; mode: Graph['mode'] }> = []; + const pending = [{ el: graphEl, parentId: null as string | null, mode: graphType as Graph['mode'] }]; + while (pending.length) { + const current = pending.pop()!; + if (getArray(current.el.hyperedge).length) throw new Error('GraphML: hyperedges cannot be projected faithfully to Graph'); + const mode = current.el['@_edgedefault'] === 'undirected' ? 'undirected' + : current.el['@_edgedefault'] === 'directed' ? 'directed' : current.mode; + for (const nodeEl of getArray(current.el.node)) { + nodeEntries.push({ el: nodeEl, structuralParentId: current.parentId }); + for (const child of getArray(nodeEl.graph)) pending.push({ el: child, parentId: String(nodeEl['@_id']), mode }); } - edgeEls.push(...asArray(gEl.edge)); + edgeEntries.push(...getArray(current.el.edge).map((el) => ({ el, mode }))); } - collectGraphContents(graphEl, null); + const edgeEls = edgeEntries.map((entry) => entry.el); const nodes: GraphNode[] = nodeEntries.map(({ el: nodeEl, structuralParentId }) => { - const dataMap = parseDataElements(nodeEl.data); + const dataMap = getDataElements(nodeEl.data, definitions, 'node'); const id = String(nodeEl['@_id']); const node: GraphNode = { type: 'node', @@ -339,30 +385,40 @@ export function fromGraphML(xml: string): Graph { // nesting in standard-GraphML subgraphs. parentId: dataMap.parentId ?? structuralParentId, initialNodeId: dataMap.initialNodeId ?? null, - label: dataMap.label ?? '', + label: getLabel(dataMap), data: - dataMap.data !== undefined ? tryParseJSON(dataMap.data) : undefined, + dataMap.data !== undefined ? getParsedJSON(dataMap.data) : undefined, }; - if (dataMap.x !== undefined) node.x = parseNumber(dataMap.x, 'x', 'node', id); - if (dataMap.y !== undefined) node.y = parseNumber(dataMap.y, 'y', 'node', id); + if (dataMap.x !== undefined) node.x = getNumber(dataMap.x, 'x', 'node', id); + if (dataMap.y !== undefined) node.y = getNumber(dataMap.y, 'y', 'node', id); if (dataMap.width !== undefined) { - node.width = parseNumber(dataMap.width, 'width', 'node', id); + node.width = getNumber(dataMap.width, 'width', 'node', id); } if (dataMap.height !== undefined) { - node.height = parseNumber(dataMap.height, 'height', 'node', id); + node.height = getNumber(dataMap.height, 'height', 'node', id); } if (dataMap.shape !== undefined) node.shape = dataMap.shape; if (dataMap.color !== undefined) node.color = dataMap.color; - if (dataMap.style !== undefined) node.style = tryParseJSON(dataMap.style); + if (dataMap.style !== undefined) node.style = getParsedJSON(dataMap.style); if (dataMap.ports !== undefined) { // Own-dialect JSON takes precedence over native // elements. - node.ports = tryParseJSON(dataMap.ports); + node.ports = getParsedJSON(dataMap.ports); + if (getPrivateData(dataMap._stately)?.nativePorts && nodeEl.port !== undefined) { + const original = new Map(node.ports?.map((port) => [port.name,port]) ?? []); + node.ports = getPorts(nodeEl.port,definitions)?.map((native) => { + const port = {...original.get(native.name),...native}; + port.direction = original.get(native.name)?.direction ?? native.direction; + for (const key of ['label','x','y','width','height','style'] as const) if (native[key] === undefined) delete port[key]; + return port; + }); + } } else if (nodeEl.port !== undefined) { - node.ports = collectPorts(nodeEl.port); + node.ports = getPorts(nodeEl.port, definitions); } + node.data = createFormatData(node.data, 'graphml', getNativeMetadata(nodeEl.data, definitions, 'node')); return node; }); // All explicit edge ids, so synthesized ids can never collide with them. @@ -372,8 +428,8 @@ export function fromGraphML(xml: string): Graph { .map((edgeEl: any) => String(edgeEl['@_id'])), ); - const edges: GraphEdge[] = edgeEls.map((edgeEl: any, i: number) => { - const dataMap = parseDataElements(edgeEl.data); + const edges: GraphEdge[] = edgeEntries.map(({ el: edgeEl, mode: containingMode }, i: number) => { + const dataMap = getDataElements(edgeEl.data, definitions, 'edge'); const source = String(edgeEl['@_source']); const target = String(edgeEl['@_target']); // GraphML edge `id` is optional; synthesize a stable, unique one when @@ -393,30 +449,30 @@ export function fromGraphML(xml: string): Graph { id, sourceId: source, targetId: target, - label: dataMap.label ?? '', + label: getLabel(dataMap), data: - dataMap.data !== undefined ? tryParseJSON(dataMap.data) : undefined, + dataMap.data !== undefined ? getParsedJSON(dataMap.data) : undefined, }; if (dataMap.weight !== undefined) { - edge.weight = parseNumber(dataMap.weight, 'weight', 'edge', id); + edge.weight = getNumber(dataMap.weight, 'weight', 'edge', id); } if (dataMap.points !== undefined) { - edge.points = tryParseJSON(dataMap.points); + edge.points = getParsedJSON(dataMap.points); } if (dataMap.routing !== undefined) { edge.routing = dataMap.routing as GraphEdge['routing']; } - if (dataMap.x !== undefined) edge.x = parseNumber(dataMap.x, 'x', 'edge', id); - if (dataMap.y !== undefined) edge.y = parseNumber(dataMap.y, 'y', 'edge', id); + if (dataMap.x !== undefined) edge.x = getNumber(dataMap.x, 'x', 'edge', id); + if (dataMap.y !== undefined) edge.y = getNumber(dataMap.y, 'y', 'edge', id); if (dataMap.width !== undefined) { - edge.width = parseNumber(dataMap.width, 'width', 'edge', id); + edge.width = getNumber(dataMap.width, 'width', 'edge', id); } if (dataMap.height !== undefined) { - edge.height = parseNumber(dataMap.height, 'height', 'edge', id); + edge.height = getNumber(dataMap.height, 'height', 'edge', id); } if (dataMap.color !== undefined) edge.color = dataMap.color; - if (dataMap.style !== undefined) edge.style = tryParseJSON(dataMap.style); + if (dataMap.style !== undefined) edge.style = getParsedJSON(dataMap.style); // Own-dialect takes precedence over // the standard GraphML sourceport/targetport attributes. if (dataMap.sourcePort !== undefined) { @@ -433,47 +489,97 @@ export function fromGraphML(xml: string): Graph { // Per-edge directedness override from the GraphML `directed` attribute. const directedAttr = edgeEl['@_directed']; if (directedAttr !== undefined) { - edge.mode = String(directedAttr) === 'false' ? 'undirected' : 'directed'; + const value = String(directedAttr).trim(); + if (!['false', 'true', '0', '1'].includes(value)) throw new Error(`GraphML: invalid directed boolean ${value}`); + edge.mode = value === 'false' || value === '0' ? 'undirected' : 'directed'; } + if (directedAttr === undefined && containingMode !== graphType) edge.mode = containingMode; + const privateData = getPrivateData(dataMap._stately); + if (privateData?.mode && (directedAttr === undefined ? null : String(directedAttr)) === privateData.nativeDirected) edge.mode = privateData.mode; + edge.data = createFormatData(edge.data, 'graphml', getNativeMetadata(edgeEl.data, definitions, 'edge')); return edge; }); + const nativeKeyIds = new Set([...nodes.flatMap((node) => [...(getFormatMetadata(node.data,'graphml')?.values ?? []), ...(node.ports ?? []).flatMap((port) => getFormatMetadata(port.data,'graphml')?.values ?? [])]), ...edges.flatMap((edge) => getFormatMetadata(edge.data,'graphml')?.values ?? []), ...(getNativeMetadata(graphEl.data,definitions,'graph').values ?? [])].map((value:any) => String(value['@_key']))); + const nativeDefinitions = definitions.filter((key) => nativeKeyIds.has(String(key['@_id'])) || !getKnownDataNames('all').has(key['@_attr.name'] ?? key['@_id'])); const graph: Graph = { id: String(graphEl['@_id'] ?? ''), - mode: graphType, + mode: getPrivateData(graphDataMap._stately)?.mode === 'bidirectional' && graphEl['@_edgedefault'] === getPrivateData(graphDataMap._stately)?.nativeDefault ? 'bidirectional' : graphType, initialNodeId: graphDataMap.graphInitialNodeId ?? null, nodes, edges, - data: graphData, + data: createFormatData(graphData, 'graphml', { ...(nativeDefinitions.length && {keys:nativeDefinitions}), ...getNativeMetadata(graphEl.data, definitions, 'graph') }), }; if (graphDataMap.direction !== undefined) { - graph.direction = parseDirection(graphDataMap.direction); + graph.direction = getDirection(graphDataMap.direction); } if (graphDataMap.graphStyle !== undefined) { - graph.style = tryParseJSON(graphDataMap.graphStyle); + graph.style = getParsedJSON(graphDataMap.graphStyle); } return graph; } -function asArray(val: T | T[] | undefined): T[] { +function getArray(val: T | T[] | undefined): T[] { if (val === undefined) return []; return Array.isArray(val) ? val : [val]; } -function parseDataElements(dataEls: any): Record { - const map: Record = {}; - for (const dataEl of asArray(dataEls)) { - if (dataEl && dataEl['@_key']) { - map[dataEl['@_key']] = String(dataEl['#text'] ?? ''); +function getKnownDataNames(kind: string): Set { + const names = new Set(kind === 'graph' ? ['_stately'] : ['label', 'data', 'style', 'x', 'y', 'width', 'height', 'color', '_stately']); + if (kind === 'port') names.delete('color'); + if (kind === 'node' || kind === 'all') for (const name of ['parentId', 'initialNodeId', 'shape', 'ports']) names.add(name); + if (kind === 'edge' || kind === 'all') for (const name of ['weight', 'points', 'routing', 'sourcePort', 'targetPort']) names.add(name); + if (kind === 'graph' || kind === 'all') for (const name of ['graphData', 'graphInitialNodeId', 'graphStyle', 'direction']) names.add(name); + return names; +} + +function getDataName(id: string, definitions: any[], kind: string): string | undefined { + const definition = definitions.find((key) => String(key['@_id']) === id); + if (definition && !['all', kind].includes(definition['@_for'] ?? 'all')) return undefined; + // Own-dialect graph keys have distinct IDs but intentionally repeated names. + if (kind === 'graph' && ['graphData', 'graphInitialNodeId', 'graphStyle'].includes(id)) return id; + const name = definition?.['@_attr.name'] ?? id; + if (kind === 'graph' && name === 'data') return 'graphData'; + if (kind === 'graph' && name === 'style') return 'graphStyle'; + if (kind === 'graph' && name === 'initialNodeId') return 'graphInitialNodeId'; + return String(name); +} + +function getDataElements(dataEls: any, definitions: any[], kind: string): Record { + const map: Record = Object.create(null); + for (const key of definitions) { + const name = getDataName(String(key['@_id']), definitions, kind); + if (name && key.default !== undefined) map[name] = String(typeof key.default === 'object' ? key.default['#text'] ?? '' : key.default); + } + for (const dataEl of getArray(dataEls)) { + if (dataEl?.['@_key'] != null) { + const name = getDataName(String(dataEl['@_key']), definitions, kind); + if (name) map[name] = String(dataEl['#text'] ?? ''); } } return map; } -function tryParseJSON(str: string): any { +function getNativeMetadata(dataEls: any, definitions: any[], kind: string): Record { + const known = getKnownDataNames(kind); + const values = getArray(dataEls).filter((el) => !known.has(getDataName(String(el['@_key']), definitions, kind) ?? '')); + return values.length ? { values } : {}; +} + +function getPrivateData(value: string | undefined): any { + if (value === undefined) return undefined; + const parsed = getParsedJSON(value); + return parsed?.format === 'stately' && parsed.version === 1 ? parsed : undefined; +} + +function getLabel(map: Record): string | null { + return map.label === undefined && getPrivateData(map._stately)?.nullLabel ? null : map.label ?? ''; +} + +function getParsedJSON(str: string): any { try { return JSON.parse(str); } catch { @@ -486,23 +592,60 @@ function tryParseJSON(str: string): any { * shape. Standard GraphML ports carry only a name; direction is unknowable, * so they import as advisory 'inout' with null data. */ -function collectPorts(portEls: any): GraphNode['ports'] { +function getPorts(portEls: any, definitions: any[]): GraphNode['ports'] { const ports: NonNullable = []; - for (const portEl of asArray(portEls)) { - if (portEl?.['@_name'] == null) continue; - ports.push({ - name: String(portEl['@_name']), - direction: 'inout', - data: null, + const pending = getArray(portEls).reverse().map((el) => ({el,parent:undefined as string | undefined})); + while (pending.length) { + const {el,parent} = pending.pop()!; + if (el?.['@_name'] == null) continue; + const name = String(el['@_name']); + const values = getDataElements(el.data,definitions,'port'); + const fields: Record = {}; + for (const key of ['x','y','width','height']) if (values[key] !== undefined) fields[key] = getNumber(values[key],key,'node',name); + if (values.style !== undefined) fields.style = getParsedJSON(values.style); + ports.push({name,direction:'inout',...fields, + ...(values.label !== undefined && el.desc === undefined && {label:values.label}), + ...(el.desc !== undefined && {label:String(typeof el.desc === 'object' ? el.desc['#text'] ?? '' : el.desc)}), + data:createFormatData(values.data !== undefined ? getParsedJSON(values.data) : null,'graphml',{...getNativeMetadata(el.data,definitions,'port'),...(parent !== undefined && {parentPort:parent})}), }); - if (portEl.port !== undefined) { - ports.push(...(collectPorts(portEl.port) ?? [])); - } + for (const child of getArray(el.port).reverse()) pending.push({el:child,parent:name}); } return ports; } -function parseNumber( +function getNativePorts(ports: NonNullable, getData: (data: unknown) => any[]): any[] { + const entries = ports.map((port) => { + const data = getData(port.data); + const userData = getUserData(port.data,'graphml'); + if (userData !== undefined && userData !== null) data.push({'@_key':'data','#text':JSON.stringify(userData)}); + for (const key of ['x','y','width','height'] as const) if (port[key] !== undefined) data.push({'@_key':key,'#text':port[key]}); + if (port.style !== undefined) data.push({'@_key':'style','#text':JSON.stringify(port.style)}); + return {'@_name':port.name,...(port.label != null && {desc:port.label}),...(data.length && {data})}; + }); + const index = new Map(entries.map((entry) => [entry['@_name'],entry as any])); + const complete = new Set(); + const parents = new Map(ports.map((port) => [port.name,getFormatMetadata(port.data,'graphml')?.parentPort])); + for (const port of ports) { + const path = new Set(); + let current: string | undefined = port.name; + while (current !== undefined && index.has(current) && !complete.has(current)) { + if (path.has(current)) throw new Error('GraphML: cyclic preserved port hierarchy'); + path.add(current); current = parents.get(current); + } + for (const name of path) complete.add(name); + } + const roots: any[] = []; + for (let i=0;i = createFormatConverter(toGraphML, fromGraphML); + +/** Exact native source with a partial Graph projection and verified literal-label patches. */ +export const graphMLDocumentConverter = createGraphDocumentConverter({ + format: 'graphml', + codec: { from: fromGraphML, to: toGraphML }, + project: (source) => getXMLDocumentProjection(source, 'graphml', fromGraphML), + patch: (context) => getXMLDocumentPatch(context, 'graphml'), +}); diff --git a/src/formats/graphology/README.md b/src/formats/graphology/README.md new file mode 100644 index 0000000..d65ad5c --- /dev/null +++ b/src/formats/graphology/README.md @@ -0,0 +1,29 @@ +# Graphology JSON + +Dependency-free conversion of Graphology's serialized JSON. Pass output to `Graph.from(source)` in Graphology; runtime Graphology instances are outside this API. + + +```ts +import { toGraphologyJSON, fromGraphologyJSON, graphologyDocumentConverter } from '@statelyai/graph/graphology'; +const source = toGraphologyJSON(graph); +const restored = fromGraphologyJSON(source); +const document = graphologyDocumentConverter.from(source); +document.graph!.nodes[0].label = 'Changed'; +const updated = graphologyDocumentConverter.to(document); +``` + +`graphologyConverter` exposes `.to(graph)` and `.from(source)`. Export supports directed, undirected and genuinely mixed edges. Bidirectional edges throw. Fresh output enables native multigraphs and self-loops; native imports enforce their declared options. Empty string IDs remain distinct from missing IDs. Omitted native edge keys receive deterministic collision-free Graph IDs; this does not claim identity with Graphology's runtime-generated keys. + +Native node/edge attributes map representable labels, geometry, styles, ports and weights to Graph fields. Other attributes become Graph data. Graph-looking attributes with incompatible types (such as `label: 42` or `style: []`) remain data. Native known-field edits cannot overwrite those payloads silently. + +Graphology 0.26.0 adjacency queries fail for inherited object names, including `constructor`, `__proto__` and `toString`. This profile rejects all `Object.prototype` node names, including isolated nodes, before emitting native output. + + +Fresh output stores Graph data in an attribute named `data`, and Graph-only presence/state in versioned `__statelyai` attributes with a format-specific origin. Native readers observe the actual native fields. Imported source is captured in serializable provenance; surviving entities retain unknown attributes after Graph reorder/insert/delete. Live native data/label edits take precedence over recorded baselines. + +Use `fromGraphologyJSON(source, { metadata: 'native' })` to treat matching private markers as ordinary attributes. Use `toGraphologyJSON(graph, { nativeCarrier: false })` to export user-authored Graph data without interpreting native-import provenance. An arbitrary payload can intentionally match the private convention, so automatic detection is not a collision-free provenance guarantee. + +Documents retain the complete original JSON source, order, unknown fields and presence through no-op and JSON persistence. Strict label/data/style/geometry/weight patches preserve unknown source fields and verify the resulting Graph projection. Structural, parent, port and ambiguous edits reject with diagnostics; `.getUpdate(document)` returns the result without throwing. Scalar replacement of foreign attribute records is unsupported. This is a partial semantic mapping, not a universal lossless Graphology schema implementation. + + +Native proof: Graphology 0.26.0 import/query/export checks mixed modes, parallel edges, self-loops, isolates, empty IDs, attributes, edits and a 2,000-node graph. [Official serialization API](https://graphology.github.io/serialization.html). diff --git a/src/formats/graphology/index.ts b/src/formats/graphology/index.ts new file mode 100644 index 0000000..1e9219a --- /dev/null +++ b/src/formats/graphology/index.ts @@ -0,0 +1,119 @@ +import type { Graph } from '../../types'; +import { createFormatConverter } from '../converter'; +import { createGraphDocumentConverter } from '../document'; +import { createNativeCarrier, getNativeCarrier, getCarrierData, getClonedJSON, getNativeDocumentGraph, type FormatImportOptions, type FormatExportOptions } from '../preservation'; +import { getAttributeRecord, getNativeAttributes, getUpdatedAttributes, getNodeBase, getEdgeBase, getRootBase, getValidatedGraph, getAttributeDocumentPatch, getRecordWithField } from '../structured-json'; + +export interface GraphologyJSON { + options: { type?: 'directed' | 'undirected' | 'mixed'; multi?: boolean; allowSelfLoops?: boolean }; + attributes: Record; + nodes: Array<{ key: string; attributes?: Record }>; + edges: Array<{ key?: string; source: string; target: string; attributes?: Record; undirected?: boolean }>; +} +export interface GraphologyImportOptions extends FormatImportOptions {} +export interface GraphologyExportOptions extends FormatExportOptions {} + +function getValidatedSource(source: GraphologyJSON): void { + if (!source || !Array.isArray(source.nodes) || !Array.isArray(source.edges) || !source.options || !source.attributes || typeof source.attributes !== 'object' || Array.isArray(source.attributes)) throw new Error('Graphology: expected serialized options, attributes, nodes and edges'); + const type = source.options.type ?? 'mixed'; + if (!['directed', 'undirected', 'mixed'].includes(type)) throw new Error('Graphology: invalid type'); + if (['multi', 'allowSelfLoops'].some((key) => Object.hasOwn(source.options, key) && typeof (source.options as any)[key] !== 'boolean')) throw new Error('Graphology: invalid native boolean option'); + const nodes = new Set(), edges = new Set(), pairs = new Set(); + for (const node of source.nodes) { + if (typeof node.key !== 'string' || nodes.has(node.key)) throw new Error('Graphology: node keys must be unique strings'); + if (Object.hasOwn(Object.prototype, node.key)) throw new Error(`Graphology: unsafe native adjacency key ${node.key}`); + if (node.attributes !== undefined && (!node.attributes || typeof node.attributes !== 'object' || Array.isArray(node.attributes))) throw new Error('Graphology: node attributes must be an object'); + nodes.add(node.key); + } + for (const edge of source.edges) { + if (!nodes.has(edge.source) || !nodes.has(edge.target)) throw new Error('Graphology: missing edge endpoint'); + if (edge.key !== undefined && (typeof edge.key !== 'string' || edges.has(edge.key))) throw new Error('Graphology: duplicate or invalid edge key'); + if (edge.key !== undefined) edges.add(edge.key); + if (edge.attributes !== undefined && (!edge.attributes || typeof edge.attributes !== 'object' || Array.isArray(edge.attributes))) throw new Error('Graphology: edge attributes must be an object'); + if (Object.hasOwn(edge, 'undirected') && typeof edge.undirected !== 'boolean') throw new Error('Graphology: invalid edge direction'); + const undirected = edge.undirected ?? type === 'undirected'; + if ((type === 'directed' && undirected) || (type === 'undirected' && !undirected)) throw new Error('Graphology: edge direction conflicts with native type'); + if (source.options.allowSelfLoops === false && edge.source === edge.target) throw new Error('Graphology: native self-loops disabled'); + const ends = undirected ? [edge.source, edge.target].sort() : [edge.source, edge.target]; + const pair = JSON.stringify([undirected, ...ends]); + if (!source.options.multi && pairs.has(pair)) throw new Error('Graphology: duplicate edge in simple graph'); + pairs.add(pair); + } +} + +function getGraphologyProjection(source: GraphologyJSON, native: boolean): Graph { + const type = source.options.type ?? 'mixed'; + const mode = type === 'undirected' ? 'undirected' : 'directed'; + const keys = new Set(source.edges.flatMap((edge) => edge.key === undefined ? [] : [edge.key])); + const ids = source.edges.map((edge, index) => { + if (edge.key !== undefined) return edge.key; + let id = `e${index}`, suffix = 0; + while (keys.has(id)) id = `e${index}__statelyai${++suffix}`; + keys.add(id); return id; + }); + const graph = { ...getAttributeRecord(source.attributes, getRootBase(mode), 'graph', 'graphology', native), + nodes: source.nodes.map((node) => getAttributeRecord(node.attributes, getNodeBase(node.key), 'node', 'graphology', native)), + edges: source.edges.map((edge, index) => getAttributeRecord(edge.attributes, getEdgeBase(ids[index], edge.source, edge.target, (edge.undirected ?? type === 'undirected') ? 'undirected' : 'directed'), 'edge', 'graphology', native)) } as Graph; + getValidatedGraph(graph, 'Graphology'); + return graph; +} + +/** Import native mixed-mode JSON; unsafe native IDs and malformed topology throw. */ +export function fromGraphologyJSON(source: GraphologyJSON, options: GraphologyImportOptions = {}): Graph { + getValidatedSource(source); + const graph = getGraphologyProjection(source, options.metadata === 'native'); + graph.data = createNativeCarrier(graph.data, 'graphology', source, graph); + return graph; +} + +function toRawGraphologyJSON(graph: Graph): GraphologyJSON { + getValidatedGraph(graph, 'Graphology'); + const modes = new Set(graph.edges.map((edge) => edge.mode ?? graph.mode)); + if (!modes.size) modes.add(graph.mode); + if (modes.has('bidirectional')) throw new Error('Graphology: bidirectional edges have no single native representation'); + const type = modes.size > 1 ? 'mixed' : [...modes][0] as 'directed' | 'undirected'; + const { nodes, edges, ...root } = graph; + const source: GraphologyJSON = { options: { type, multi: true, allowSelfLoops: true }, attributes: getNativeAttributes(root, 'graph', 'graphology', getRootBase(type === 'undirected' ? 'undirected' : 'directed')), + nodes: nodes.map((node) => ({ key: node.id, attributes: getNativeAttributes(node, 'node', 'graphology', getNodeBase(node.id)) })), + edges: edges.map((edge) => ({ key: edge.id, source: edge.sourceId, target: edge.targetId, undirected: (edge.mode ?? graph.mode) === 'undirected', attributes: getNativeAttributes(edge, 'edge', 'graphology', getEdgeBase(edge.id, edge.sourceId, edge.targetId, (edge.mode ?? graph.mode) as 'directed' | 'undirected')) })) }; + getValidatedSource(source); + return source; +} + +/** Export native directed/undirected/mixed topology; Graph-only state uses explicit metadata. */ +export function toGraphologyJSON(graph: Graph, options: GraphologyExportOptions = {}): GraphologyJSON { + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, 'graphology'); + if (!carrier) return toRawGraphologyJSON(graph); + const current = { ...graph, data: getCarrierData(graph.data, 'graphology') }; + const before = carrier.baseline as Graph; + const source = getClonedJSON(carrier.native) as GraphologyJSON; + const raw = toRawGraphologyJSON(current); + const nodes = new Map(before.nodes.map((node, index) => [node.id, { node, native: source.nodes[index] }])); + const edges = new Map(before.edges.map((edge, index) => [edge.id, { edge, native: source.edges[index] }])); + source.nodes = current.nodes.map((node, index) => { + const old = nodes.get(node.id); + return old ? getRecordWithField(old.native, 'attributes', getUpdatedAttributes(old.native.attributes, old.node, node, 'node', 'graphology', getNodeBase(node.id))) : raw.nodes[index]; + }); + source.edges = current.edges.map((edge, index) => { + const old = edges.get(edge.id); + return old ? { ...getRecordWithField(old.native, 'attributes', getUpdatedAttributes(old.native.attributes, old.edge, edge, 'edge', 'graphology', getEdgeBase(edge.id, edge.sourceId, edge.targetId, (edge.mode ?? current.mode) as 'directed' | 'undirected'))), source: edge.sourceId, target: edge.targetId, ...((edge.mode ?? current.mode) !== (old.edge.mode ?? before.mode) && { undirected: (edge.mode ?? current.mode) === 'undirected' }) } : raw.edges[index]; + }); + const { nodes: _beforeNodes, edges: _beforeEdges, ...beforeRoot } = before; + const { nodes: _nodes, edges: _edges, ...root } = current; + source.attributes = getUpdatedAttributes(source.attributes, beforeRoot, root, 'graph', 'graphology', getRootBase(current.mode === 'undirected' ? 'undirected' : 'directed')) ?? {}; + if (source.options.type !== 'mixed') { + const nativeModes = new Set(current.edges.map((edge) => edge.mode ?? current.mode)); + const type = nativeModes.size > 1 ? 'mixed' : (nativeModes.size ? [...nativeModes][0] : current.mode); + if ((source.options.type ?? 'mixed') !== type) source.options.type = type as GraphologyJSON['options']['type']; + } + getValidatedSource(source); + return source; +} + +export const graphologyConverter = createFormatConverter(toGraphologyJSON, fromGraphologyJSON); +/** Exact JSON source retention; unsupported structural edits reject. */ +export const graphologyDocumentConverter = createGraphDocumentConverter({ + format: 'graphology', codec: { from: fromGraphologyJSON, to: (graph) => toGraphologyJSON(graph, { nativeCarrier: false }) }, + project: (source: GraphologyJSON) => ({ graph: getNativeDocumentGraph(fromGraphologyJSON(source), 'graphology', source) }), + patch: (context) => getAttributeDocumentPatch(context, 'graphology', (graph) => toGraphologyJSON({ ...graph, data: createNativeCarrier(graph.data, 'graphology', context.source, context.sourceGraph) })), +}); diff --git a/src/formats/graphviz-json/README.md b/src/formats/graphviz-json/README.md new file mode 100644 index 0000000..62ebc19 --- /dev/null +++ b/src/formats/graphviz-json/README.md @@ -0,0 +1,70 @@ +# Graphviz JSON + + + +```ts +import { + fromGraphvizJSON, toGraphvizJSON, graphvizJSONDocumentConverter, +} from '@statelyai/graph/graphviz-json'; + +const graph = fromGraphvizJSON(nativeJSON); // geometry remains native state +const positioned = fromGraphvizJSON(nativeJSON, { geometry: 'points' }); +const output = toGraphvizJSON(graph); // unchanged imported JSON value +const fresh = toGraphvizJSON(graph, { regenerate: true }); +``` + +`graphvizJSONConverter` provides `.from` / `.to`. Public types include +`GraphvizJSON`, `GraphvizJSONObject`, `GraphvizJSONEdge`, +`GraphvizJSONImportOptions` and `GraphvizJSONExportOptions`. +The subpath has no runtime dependencies. + + + +Graphviz JSON is an **output format**, never an input accepted by the Graphviz +layout engine. The adapter accepts `dot_json` and rendered `json` object profiles: +nodes, binary edges, labels, finite numeric weights, isolated/parallel edges, +strict-edge identity and cluster memberships. `_gvid` must match array identity; +references, attribute types, numeric positions/bounds and subgraph cycles are +validated. Unsupported input retains an unavailable document projection. + +Real clusters become Graph nodes. Unique innermost cluster ownership maps to +`parentId`; overlapping memberships and non-cluster/rank subgraphs stay native +state. Edges on non-cluster subgraphs reject. Cluster endpoints are supported by +actual `fdp` producer output. The `dot` engine instead creates ordinary nodes for +DOT edges naming clusters; those distinct native objects retain their identities. + +Geometry conversion is opt-in: native node width/height use inches, positions use +points, and `{ geometry: 'points' }` converts at 72 points/inch, flips Y against the +native bounding box and converts centers to top-left corners. Cluster bounds and +edge splines/drawing instructions remain native state. Unresolved empty layout +attributes stay opaque. `dot_json` space-separated and `json` comma-separated +bounding boxes are supported. + +Fresh export emits a minimal object profile with no active drawing instructions. +Versioned JSON-string attributes retain Graph-only fields, IDs, nullable labels, +ports and user data. These are private extensions, not a promise that arbitrary +external JSON semantics fit Graph. `{ metadata: 'native' }` disables interpreting +private markers on import. Unknown native attributes remain JSON data; primitive +user data is wrapped safely when independent native attributes are added. + + + +`graphvizJSONDocumentConverter` retains the complete JSON source **value**, including +unknown properties/drawing operations, after JSON persistence. JSON object input +has no original text whitespace or comments to preserve. Its Graph projection is +explicitly partial. Safe edits are node/edge labels and edge weights in unlaid +sources. Structure, root fields and geometry require explicit regeneration. +Labels/weights affecting captured positions, bounds or drawing programs reject +until regeneration; stale layout is never silently returned. Source-only edits +survive and same-field source/Graph conflicts throw diagnostics. Strict document +`.to` proves the patched projection by reparsing. Explicit regeneration is +available through `.getUpdate(document, { mode: 'regenerate' })` with loss diagnostics. + + + +Native tests use pinned Graphviz 15.0.0 WASM to generate actual `dot_json`/`json` +output, including `fdp` cluster endpoints, and the byte-pinned official Graphviz +JSON schema. The published schema places `additionalProperties` inside +`properties`, so schema acceptance alone does not validate known attribute types; +independent adapter checks cover that gap. No JSON-to-Graphviz input capability or +complete renderer equivalence is claimed. diff --git a/src/formats/graphviz-json/index.ts b/src/formats/graphviz-json/index.ts new file mode 100644 index 0000000..1f1de94 --- /dev/null +++ b/src/formats/graphviz-json/index.ts @@ -0,0 +1,188 @@ +import { createFormatData } from '../xml-preservation'; +import type { Graph } from '../../types'; +import { createFormatConverter } from '../converter'; +import { createGraphDocumentConverter } from '../document'; +import { createMetadata, getMetadata, getRestoredRecord, createNativeCarrier, getNativeCarrier, getCarrierData, getClonedJSON, getNativeDocumentGraph, isJSONEqual, type FormatImportOptions, type FormatExportOptions } from '../preservation'; +import { getValidatedGraph, getUniformMode } from '../structured-json'; + +export interface GraphvizJSONObject { _gvid: number; name: string; nodes?: number[]; edges?: number[]; subgraphs?: number[]; [attribute: string]: any } +export interface GraphvizJSONEdge { _gvid: number; tail: number; head: number; [attribute: string]: any } +export interface GraphvizJSON { name: string; directed: boolean; strict: boolean; _subgraph_cnt: number; objects?: GraphvizJSONObject[]; edges?: GraphvizJSONEdge[]; [attribute: string]: any } +export interface GraphvizJSONImportOptions extends FormatImportOptions { geometry?: 'none' | 'points' } +export interface GraphvizJSONExportOptions extends FormatExportOptions { regenerate?: boolean } +const FORMAT = 'graphviz-json'; + +function getWireMetadata(record: Record, kind: string, native = false) { + if (native) return undefined; + for (const [key, value] of Object.entries(record)) if (/^_stately(?:_\d+)?$/.test(key) && typeof value === 'string') { + try { const metadata = getMetadata(JSON.parse(value), `${FORMAT}.${kind}`); if (metadata) return { key, metadata }; } catch { /* Opaque native attribute. */ } + } + return undefined; +} +function getAttributes(record: Record, known: string[], metadataKey?: string) { + return Object.fromEntries(Object.entries(record).filter(([key]) => !known.includes(key) && key !== metadataKey)); +} +function getRecord(record: Record, mapped: any, kind: string, known: string[], native: boolean) { + const wire = getWireMetadata(record, kind, native); + mapped.data = getAttributes(record, known, wire?.key); + const restored = getRestoredRecord(mapped, wire?.metadata); + if (wire && !isJSONEqual(mapped.data, wire.metadata.mapped.data)) restored.data = createFormatData(wire.metadata.original.data, FORMAT, { native: mapped.data }); + return restored; +} +function addMetadata(record: Record, entity: any, mapped: any, kind: string) { + let key = '_stately', index = 0; + while (Object.hasOwn(record, key)) key = `_stately_${++index}`; + record[key] = JSON.stringify(createMetadata(`${FORMAT}.${kind}`, entity, mapped)); +} +function getValidatedAttributes(record: Record): void { + if (!record || typeof record !== 'object' || Array.isArray(record)) throw new Error('Graphviz JSON: native attribute records must be objects'); + for (const key of ['name', 'label', 'weight', 'pos', 'width', 'height', 'bb', 'tailport', 'headport', 'style', 'shape', 'color', 'fillcolor']) { + if (Object.hasOwn(record, key) && typeof record[key] !== 'string') throw new Error(`Graphviz JSON: native ${key} must be a string`); + } + for (const key of ['weight', 'width', 'height']) if (record[key] !== undefined && record[key] !== '') getNumber(record[key], key); + if (record.bb !== undefined && record.bb !== '') { + const coordinates = record.bb.trim().split(/[\s,]+/); + if (coordinates.length !== 4) throw new Error('Graphviz JSON: invalid bounding box'); + coordinates.forEach((value: string) => getNumber(value, 'bounding box')); + } + if (record.pos !== undefined && record.pos !== '') { + for (const token of record.pos.trim().split(/[\s;]+/)) { + const coordinates = token.replace(/^[es],/, '').replace(/!$/, '').split(','); + if (coordinates.length < 2 || coordinates.length > 3) throw new Error('Graphviz JSON: invalid native position'); + coordinates.forEach((value: string) => getNumber(value, 'position')); + } + } + for (const [key, value] of Object.entries(record)) if (/draw_$/.test(key) && !Array.isArray(value)) throw new Error('Graphviz JSON: drawing state must be an array'); +} +function getValidatedSource(source: GraphvizJSON): void { + if (!source || typeof source.name !== 'string' || typeof source.directed !== 'boolean' || typeof source.strict !== 'boolean' || !Number.isSafeInteger(source._subgraph_cnt) || source._subgraph_cnt < 0 || (source.objects !== undefined && !Array.isArray(source.objects)) || (source.edges !== undefined && !Array.isArray(source.edges))) throw new Error('Graphviz JSON: invalid output profile'); + getValidatedAttributes(source); + const objects = source.objects ?? [], edges = source.edges ?? []; + if (source._subgraph_cnt > objects.length) throw new Error('Graphviz JSON: invalid subgraph count'); + const names = new Set(); + objects.forEach((object, index) => { + getValidatedAttributes(object); + if (object._gvid !== index || typeof object.name !== 'string') throw new Error('Graphviz JSON: _gvid must match object-array identity'); + if (index >= source._subgraph_cnt) { if (names.has(object.name)) throw new Error('Graphviz JSON: duplicate node name'); names.add(object.name); } + for (const key of ['nodes', 'subgraphs', 'edges'] as const) if (object[key] !== undefined) { + if (index >= source._subgraph_cnt) throw new Error('Graphviz JSON: memberships belong only to subgraphs'); + if (!Array.isArray(object[key]) || new Set(object[key]).size !== object[key].length || object[key].some((id) => !Number.isSafeInteger(id) || id < 0 || id >= (key === 'edges' ? edges.length : objects.length) || (key === 'subgraphs' ? id >= source._subgraph_cnt || id === index : key === 'nodes' ? id < source._subgraph_cnt : false))) throw new Error('Graphviz JSON: invalid subgraph membership reference'); + } + }); + const strictPairs = new Set(); + edges.forEach((edge, index) => { getValidatedAttributes(edge); if (!edge || edge._gvid !== index || !Number.isSafeInteger(edge.tail) || !Number.isSafeInteger(edge.head) || !objects[edge.tail] || !objects[edge.head]) throw new Error('Graphviz JSON: invalid edge _gvid/endpoint reference'); + const pair = JSON.stringify(source.directed ? [edge.tail, edge.head] : [edge.tail, edge.head].sort((a, b) => a - b)); + if (source.strict && strictPairs.has(pair)) throw new Error('Graphviz JSON: duplicate strict edge'); strictPairs.add(pair); + }); + const completed = new Set(); + for (let start = 0; start < source._subgraph_cnt; start++) { + const visiting = new Set(), stack: Array<[number, boolean]> = [[start, false]]; + while (stack.length) { + const [id, exit] = stack.pop()!; + if (exit) { visiting.delete(id); completed.add(id); continue; } + if (completed.has(id)) continue; + if (visiting.has(id)) throw new Error('Graphviz JSON: cyclic subgraphs'); + visiting.add(id); stack.push([id, true]); + for (const child of objects[id].subgraphs ?? []) stack.push([child, false]); + } + } +} +function getNumber(value: unknown, field: string): number { + const number = typeof value === 'number' || typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN; + if (!Number.isFinite(number)) throw new Error(`Graphviz JSON: invalid ${field}`); + return number; +} +function getGeometry(object: GraphvizJSONObject, source: GraphvizJSON) { + if (object.pos === undefined || object.pos === '') return {}; + const bounding = String(source.bb ?? '').trim().split(/[\s,]+/).map((value) => getNumber(value, 'bounding box')); + const position = String(object.pos).replace(/!$/, '').split(',').map((value) => getNumber(value, 'position')); + if (bounding.length !== 4 || position.length < 2) throw new Error('Graphviz JSON: geometry requires native bounding box and position'); + const width = getNumber(object.width, 'width') * 72, height = getNumber(object.height, 'height') * 72; + return { x: position[0] - width / 2, y: bounding[3] - position[1] - height / 2, width, height }; +} + +/** Graphviz output JSON; drawing programs and non-tree memberships remain opaque native state. */ +export function fromGraphvizJSON(source: GraphvizJSON, options: GraphvizJSONImportOptions = {}): Graph { + getValidatedSource(source); + const objects = source.objects ?? [], native = options.metadata === 'native'; + const represented = objects.map((object, index) => index >= source._subgraph_cnt || object.name.startsWith('cluster')); + for (const edge of source.edges ?? []) if (!represented[edge.tail] || !represented[edge.head]) throw new Error('Graphviz JSON: endpoints on non-cluster subgraphs have no binary Graph projection'); + const ids = new Map(), used = new Set(objects.slice(source._subgraph_cnt).map((object) => object.name)); + objects.forEach((object, index) => { + if (!represented[index]) return; + let id = object.name; + if (index < source._subgraph_cnt) { id = `graphviz:cluster:${index}`; while (used.has(id)) id += '_'; } + ids.set(index, id); used.add(id); + }); + const mappedNodes: Array<{ object: GraphvizJSONObject; mapped: any }> = objects.flatMap((object, index) => represented[index] ? [{ object, mapped: { type: 'node', id: ids.get(index)!, label: typeof object.label === 'string' ? object.label : null, parentId: null, initialNodeId: null, ...(options.geometry === 'points' && index >= source._subgraph_cnt ? getGeometry(object, source) : {}) } }] : []); + const mappedById = new Map(mappedNodes.map((entry) => [entry.mapped.id, entry.mapped])); + // Unique innermost cluster ownership only; overlapping memberships stay in native state. + const ownersByMember = new Map(); + for (const object of objects.slice(0, source._subgraph_cnt)) if (object.name.startsWith('cluster')) for (const member of [...object.nodes ?? [], ...object.subgraphs ?? []]) { + const owners = ownersByMember.get(member) ?? []; owners.push(object); ownersByMember.set(member, owners); + } + for (const [index, id] of ids) { + const owners = ownersByMember.get(index) ?? [], ownerIDs = new Set(owners.map((owner) => owner._gvid)); + const deepest = owners.filter((owner) => !owner.subgraphs?.some((child) => ownerIDs.has(child))); + if (deepest.length === 1) mappedById.get(id)!.parentId = ids.get(deepest[0]._gvid)!; + } + const nodes = mappedNodes.map(({ object, mapped }) => getRecord(object, mapped, 'node', ['_gvid', 'name', 'label'], native)); + const records = new Map(mappedNodes.map(({ object }, index) => [object._gvid, nodes[index]])); + const mappedIDs = new Map(mappedNodes.map(({ mapped }, index) => [mapped.id, nodes[index].id])); + nodes.forEach((node, index) => { const parent = mappedNodes[index].mapped.parentId; if (parent != null) node.parentId = mappedIDs.get(parent)!; }); + const edges = (source.edges ?? []).map((edge) => getRecord(edge, { type: 'edge', id: `graphviz:edge:${edge._gvid}`, sourceId: records.get(edge.tail)!.id, targetId: records.get(edge.head)!.id, mode: source.directed ? 'directed' : 'undirected', label: typeof edge.label === 'string' ? edge.label : null, + ...(edge.weight !== undefined && { weight: getNumber(edge.weight, 'weight') }) }, 'edge', ['_gvid', 'tail', 'head', 'label', 'weight'], native)); + const graph: Graph = { ...getRecord(source, { id: source.name, mode: source.directed ? 'directed' : 'undirected', initialNodeId: null }, 'graph', ['name', 'directed', 'strict', '_subgraph_cnt', 'objects', 'edges'], native), nodes, edges }; + getValidatedGraph(graph, FORMAT); + graph.data = createNativeCarrier(graph.data, FORMAT, source, graph); + return graph; +} +function toRawGraphvizJSON(graph: Graph): GraphvizJSON { + getValidatedGraph(graph, FORMAT); + const mode = getUniformMode(graph, FORMAT), parents = new Set(graph.nodes.flatMap((node) => node.parentId == null ? [] : [node.parentId])); + const nodeIDs = new Set(graph.nodes.map((node) => node.id)); + if ([...parents].some((id) => !nodeIDs.has(id))) throw new Error('Graphviz JSON: missing parent'); + const ordered = [...graph.nodes.filter((node) => parents.has(node.id)), ...graph.nodes.filter((node) => !parents.has(node.id))]; + const ids = new Map(ordered.map((node, index) => [node.id, index])); + const children = new Map(); + for (const node of graph.nodes) if (node.parentId != null) { const siblings = children.get(node.parentId) ?? []; siblings.push(node); children.set(node.parentId, siblings); } + const source: GraphvizJSON = { name: graph.id, directed: mode === 'directed', strict: false, _subgraph_cnt: parents.size, objects: ordered.map((node, index) => ({ _gvid: index, name: parents.has(node.id) ? `cluster_stately_${index}` : node.id, ...(node.label != null && { label: node.label }), ...(parents.has(node.id) && { nodes: (children.get(node.id) ?? []).filter((child) => !parents.has(child.id)).map((child) => ids.get(child.id)!), subgraphs: (children.get(node.id) ?? []).filter((child) => parents.has(child.id)).map((child) => ids.get(child.id)!) }) })), edges: graph.edges.map((edge, index) => ({ _gvid: index, tail: ids.get(edge.sourceId)!, head: ids.get(edge.targetId)!, ...(edge.label != null && { label: edge.label }), ...(edge.weight !== undefined && { weight: String(edge.weight) }) })) }; + const mapped = fromGraphvizJSON(source, { metadata: 'native' }); + const mappedRecords = new Map(mapped.nodes.map((node) => [node.id, node])); + const mappedByGvid = new Map((source.objects ?? []).map((object) => [object._gvid, mappedRecords.get(object._gvid < parents.size ? `graphviz:cluster:${object._gvid}` : object.name)!])); + ordered.forEach((node, index) => addMetadata(source.objects![index], node, mappedByGvid.get(index), 'node')); + graph.edges.forEach((edge, index) => addMetadata(source.edges![index], edge, mapped.edges[index], 'edge')); + const { nodes: _nodes, edges: _edges, ...root } = graph; + const { nodes: _mappedNodes, edges: _mappedEdges, ...mappedRoot } = mapped; + addMetadata(source, root, { ...mappedRoot, data: getCarrierData(mapped.data, FORMAT) }, 'graph'); + return source; +} +/** Minimal dot_json profile; explicit regeneration discards captured drawing programs. */ +export function toGraphvizJSON(graph: Graph, options: GraphvizJSONExportOptions = {}): GraphvizJSON { + const carrier = options.nativeCarrier === false || options.regenerate ? undefined : getNativeCarrier(graph.data, FORMAT); + if (!carrier) return toRawGraphvizJSON({ ...graph, data: options.regenerate ? getCarrierData(graph.data, FORMAT) : graph.data }); + const current = { ...graph, data: getCarrierData(graph.data, FORMAT) }; + if (isJSONEqual(current, carrier.baseline)) return getClonedJSON(carrier.native); + const source = getClonedJSON(carrier.native) as GraphvizJSON; + const before = carrier.baseline as Graph; + if (!isJSONEqual(before.nodes.map((node) => [node.id, node.parentId]), current.nodes.map((node) => [node.id, node.parentId])) || !isJSONEqual(before.edges.map((edge) => [edge.id, edge.sourceId, edge.targetId]), current.edges.map((edge) => [edge.id, edge.sourceId, edge.targetId]))) throw new Error('Graphviz JSON: structural edits require explicit regeneration'); + for (const [originals, changed, records] of [[before.nodes, current.nodes, (source.objects ?? []).filter((object, index) => index >= source._subgraph_cnt || object.name.startsWith('cluster'))], [before.edges, current.edges, source.edges ?? []]] as const) { + changed.forEach((entity, index) => { + const old: any = originals[index], record = records[index]; + if (isJSONEqual(old, entity)) return; + for (const field of new Set([...Object.keys(old), ...Object.keys(entity)])) if (!isJSONEqual(old[field], (entity as any)[field])) { + if (field !== 'label' && field !== 'weight') throw new Error(`Graphviz JSON: ${field} requires explicit regeneration`); + if (Object.keys(record).some((key) => /draw_$/.test(key)) || Object.hasOwn(record, 'pos') || source.bb !== undefined && source.bb !== '') throw new Error('Graphviz JSON: edited attributes would leave stale drawing/layout programs; regenerate explicitly'); + if (field === 'weight') { if ((entity as any).weight === undefined) delete record.weight; else record.weight = String((entity as any).weight); } + else if (entity.label == null) delete record.label; else record.label = entity.label; + } + }); + } + const { nodes: _bn, edges: _be, ...oldRoot } = before, { nodes: _cn, edges: _ce, ...root } = current; + if (!isJSONEqual(oldRoot, root)) throw new Error('Graphviz JSON: root edits require explicit regeneration'); + return source; +} +export const graphvizJSONConverter = createFormatConverter(toGraphvizJSON, fromGraphvizJSON); +export const graphvizJSONDocumentConverter = createGraphDocumentConverter({ format: FORMAT, codec: { from: fromGraphvizJSON, to: (graph) => toGraphvizJSON(graph, { regenerate: true }) }, + project: (source: GraphvizJSON) => ({ graph: getNativeDocumentGraph(fromGraphvizJSON(source), FORMAT, source), diagnostics: [{ code: 'projection-partial', severity: 'warning', message: 'Graphviz output drawing programs and non-tree subgraph memberships remain native state' }] }), + patch: (context) => { try { return { ok: true, source: toGraphvizJSON({ ...context.graph, data: createNativeCarrier(context.graph.data, FORMAT, context.source, context.sourceGraph) }) }; } catch (error) { return { ok: false, diagnostics: [{ code: 'unsupported-native-edit', severity: 'error', message: String(error) }] }; } } }); diff --git a/src/formats/jgf/README.md b/src/formats/jgf/README.md index 7a5a942..c03e88a 100644 --- a/src/formats/jgf/README.md +++ b/src/formats/jgf/README.md @@ -2,6 +2,24 @@ Converter for [JSON Graph Format](https://jsongraphformat.info/) — a standardized JSON schema for graph data. +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **partial**. +Source document preservation: **none**. These are separate guarantees; +selected-field round-trip tests do not prove unrestricted losslessness. + +The default preserves the existing hybrid dialect: array nodes and `graph.id` (including the empty string). Request `{ version: 1 }` for standards-valid v1 array output or `{ version: 2 }` for v2 node dictionaries. Imports accept both standards and the existing hybrid. Live native metadata, directedness and relation edits are retained. Versioned metadata preserves Graph-only fields; native snapshots retain extensions. Default output normalizes source schemas to the hybrid shape, rather than reproducing the source document. + + + + +Native snapshots use a private JSON convention on Graph data. Import options +`{ metadata: 'native' }` disable adapter marker interpretation. A foreign value +that deliberately matches the marker schema remains ambiguous in automatic mode. +Comments, lexical formatting and source locations are not preserved. + ## Resources - [JSON Graph Format specification](https://jsongraphformat.info/) @@ -71,11 +89,15 @@ const imported = fromJGF({ -Graph-level properties (`mode`, `initialNodeId`, `data`, `direction`, `style`) are stored in `graph.metadata`. Node properties (`parentId`, `data`, visual props, `style`, `ports`) are stored in `node.metadata`. Edge properties (`mode`, `data`, `weight`, visual props, `style`, `sourcePort`, `targetPort`) are stored in `edge.metadata`. +Graph-only properties and field presence are stored in versioned `metadata.__statelyai` snapshots. Object user data is also exposed in native metadata; current native edits take precedence. + +### `toJGF(graph, { version: 1 | 2 })` -### `fromJGF(jgf): Graph` +Version 1 emits array nodes and stores Graph identity in metadata. Version 2 emits ID-keyed node dictionaries and resolves nullable or omitted direction defaults to booleans, normalizing v1 nullable metadata into v2 objects. The default remains the legacy hybrid shown above. -Restores all metadata back to graph/node/edge properties. +### `fromJGF(jgf, options?): Graph` + +Imports v1 arrays, v2 dictionaries and the legacy hybrid. Native metadata and relation are retained as Graph data; recognized versioned markers restore Graph-only properties. ### `jgfConverter` @@ -90,6 +112,29 @@ const graph = jgfConverter.from(jgf); | Type | Description | |------|-------------| -| `JGFGraph` | `{ graph: { id?, directed?, metadata?, nodes, edges } }` | +| `JGFGraph` | `{ graph: { id, directed?, metadata?, nodes, edges } }` | +| `JGFGraphV1` | Standards-valid v1 output without hybrid graph ID | +| `JGFGraphV2` | Standards-valid v2 output with ID-keyed nodes | +| `JGFInput` | v1, v2 and sparse native document input | +| `JGFExportOptions` | Explicit `version: 1` or `version: 2` | +| `JGFImportOptions` | Reserved metadata interpretation override | | `JGFNode` | `{ id, label?, metadata? }` | | `JGFEdge` | `{ id?, source, target, label?, metadata? }` | + + +## Native documents + + + +`jgfDocumentConverter.from(source)` creates a plain JSON document with the +original source and a separate Graph projection. `.to(document)` preserves exact +source when unchanged, including after JSON serialization. Supported edits patch +verified native fields; unsupported edits throw with diagnostics. `.getUpdate(document)` +returns a diagnostic-bearing result. Native semantic projection remains partial. + +Original v1, v2 and hybrid schemas stay intact. Labels, native metadata, directedness and relations can be edited. Graph-only fields and ambiguous reserved metadata edits reject. Native hyperedges are retained with a null Graph projection; bare `fromJGF` rejects them rather than dropping their semantics. + +Bare exports interpret recognized import carriers by default. Use +`{ nativeCarrier: false }` when Graph data is user-authored and could match the +private carrier schema. Document projection pins importer state to its external +source before unwrapping it, preserving foreign lookalikes. diff --git a/src/formats/jgf/index.ts b/src/formats/jgf/index.ts index 94e228b..1c43b8b 100644 --- a/src/formats/jgf/index.ts +++ b/src/formats/jgf/index.ts @@ -1,208 +1,213 @@ +import type { FormatExportOptions } from '../preservation'; +import { createGraphDocumentConverter } from '../document'; +import { getStructuredDocumentProjectionGraph, getStructuredDocumentPatch } from '../preservation'; import type { Graph, GraphFormatConverter } from '../../types'; -import { createFormatConverter } from '../converter'; +import { createMetadata, createNativeCarrier, getCarrierData, getClonedJSON, getMetadata, + getNativeCarrier, getRestoredRecord, getMergedNative, getEditedData, isJSONEqual, type FormatImportOptions } from '../preservation'; -// --- JGF types (JSON Graph Format, https://jsongraphformat.info/) --- - -export interface JGFNode { - id: string; - label?: string; - metadata?: Record; +/** JGF v1 node; ID-keyed JGF v2 nodes omit the id field. */ +export interface JGFNode { id: string; label?: string; metadata?: Record } +export interface JGFEdge { id?: string; source: string; target: string; label?: string; + directed?: boolean | null; relation?: string; metadata?: Record } +export interface JGFGraph { + graph: { id: string; label?: string; type?: string; directed?: boolean | null; + metadata?: Record; nodes: JGFNode[]; edges: JGFEdge[] }; +} +/** Standards-valid JGF v1 omits the hybrid graph id. */ +export interface JGFGraphV1 { + graph: Omit & { + id?: never; metadata?: Record | null; nodes: JGFNativeNode[]; edges: JGFNativeEdge[]; + }; +} +export type JGFEdgeV2 = Omit & { directed?: boolean }; +export interface JGFGraphV2 { + graph: { id?: string; label?: string; type?: string; directed?: boolean; + metadata?: Record; nodes: Record>; edges: JGFEdgeV2[] }; +} +/** Native v1 permits nullable metadata; explicit v2 output remains nonnullable. */ +export type JGFNativeNode = Omit & { metadata?: Record | null }; +export type JGFNativeEdge = Omit & { metadata?: Record | null }; +/** JGF v2 hyperedge documents are retained without a binary projection. */ +export type JGFHyperedge = { id?: string; label?: string; relation?: string; metadata?: Record } & + ({ source: string[]; target: string[] } | { nodes: string[] }); +export interface JGFNativeDocument { + graph: { id?: string; label?: string; type?: string; directed?: boolean | null; + metadata?: Record | null; nodes?: JGFNativeNode[] | Record> | null; + edges?: JGFNativeEdge[] | null; hyperedges?: JGFHyperedge[] }; } +export type JGFInput = JGFGraph | JGFGraphV1 | JGFGraphV2 | JGFNativeDocument; +export interface JGFExportOptions extends FormatExportOptions { version?: 1 | 2 } +export interface JGFImportOptions extends FormatImportOptions {} -export interface JGFEdge { - id?: string; - source: string; - target: string; - label?: string; - metadata?: Record; +function toMetadataObject(value: any): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? getClonedJSON(value) : {}; +} +function getEditableMetadata(metadata: any, relation?: string): Record { + const result = { ...metadata }; + delete result.__statelyai; + if (relation !== undefined) result.relation = relation; + return result; } -export interface JGFGraph { - graph: { - id?: string; - directed?: boolean; - metadata?: Record; - nodes: JGFNode[]; - edges: JGFEdge[]; - }; +const NODE_FIELDS = ['parentId', 'initialNodeId', 'x', 'y', 'width', 'height', 'shape', 'color', 'style', 'ports']; +const EDGE_FIELDS = ['mode', 'weight', 'points', 'routing', 'x', 'y', 'width', 'height', 'color', 'style', 'sourcePort', 'targetPort']; +function getFields(value: any, fields: string[]): any { + return Object.fromEntries(fields.filter((key) => value && Object.hasOwn(value, key)).map((key) => [key, value[key]])); +} +function getJGFRoot(input: JGFInput, legacy: boolean): Omit { + const graph = input.graph; + return { id: graph.id ?? '', mode: legacy ? graph.metadata?.mode ?? (graph.directed === false ? 'undirected' : 'directed') : graph.directed === false ? 'undirected' : 'directed', + initialNodeId: legacy ? graph.metadata?.initialNodeId ?? null : null, + data: legacy ? graph.metadata?.data : graph.metadata, + ...(legacy && getFields(graph.metadata, ['direction', 'style'])) }; +} +function getJGFNode(node: JGFNativeNode, legacy: boolean): Graph['nodes'][number] { + return { type: 'node', id: node.id, parentId: legacy ? node.metadata?.parentId ?? null : null, + initialNodeId: legacy ? node.metadata?.initialNodeId ?? null : null, + label: node.label ?? '', data: legacy ? node.metadata?.data : node.metadata, + ...(legacy && getFields(node.metadata, NODE_FIELDS)) }; +} +function getJGFEdge(edge: JGFNativeEdge, index: number, legacy: boolean): Graph['edges'][number] { + return { type: 'edge', id: edge.id ?? `e${index}`, sourceId: edge.source, targetId: edge.target, label: edge.label ?? '', + data: legacy ? edge.metadata?.data : { ...(edge.metadata ?? {}), ...(Object.hasOwn(edge, 'relation') && { relation: edge.relation }) }, + ...(legacy && getFields(edge.metadata, EDGE_FIELDS)), + ...(edge.directed != null && { mode: edge.directed ? 'directed' : 'undirected' }) }; } -// --- Conversion --- +function getNormalizedJGF(native: JGFInput, baseline: Graph, version: 1 | 2 | 'legacy'): JGFInput { + const before = toRawJGF(baseline, version === 'legacy' ? {} : { version }); + const nativeVersion = Array.isArray(native.graph.nodes) || native.graph.nodes == null ? native.graph.id !== undefined ? 'legacy' : 1 : 2; + const sourceRecords = Array.isArray(native.graph.nodes) ? native.graph.nodes : Object.values(native.graph.nodes ?? {}); + const nullableMetadata = native.graph.metadata === null || sourceRecords.some((node) => node.metadata === null) || (native.graph.edges ?? []).some((edge) => edge.metadata === null); + const nullableDirection = native.graph.directed === null || (native.graph.edges ?? []).some((edge) => edge.directed === null); + if (nativeVersion === version && native.graph.nodes != null && Array.isArray(native.graph.edges) && + (version === 1 || !nullableMetadata && (version !== 2 || !nullableDirection))) { + const unchanged = getClonedJSON(native); + if (version === 2) unchanged.graph.directed = before.graph.directed === true; + return unchanged; + } + const result = getClonedJSON(native); + const sourceNodes: JGFNativeNode[] = Array.isArray(result.graph.nodes) ? result.graph.nodes : + Object.entries(result.graph.nodes ?? {}).map(([id, node]) => ({ ...node, id })); + const beforeNodes: JGFNativeNode[] = Array.isArray(before.graph.nodes) ? before.graph.nodes : + Object.entries(before.graph.nodes ?? {}).map(([id, node]) => ({ ...node, id })); + const nodeMetadata = new Map(beforeNodes.map((node) => [node.id, node.metadata])); + const converted = sourceNodes.map((node) => ({ ...node, metadata: { ...node.metadata, ...nodeMetadata.get(node.id) } })); + result.graph.nodes = version !== 2 ? converted : Object.fromEntries(converted.map(({ id, ...node }) => [id, node])); + if (version === 1) delete result.graph.id; + else result.graph.id = baseline.id; + if (version === 2) result.graph.directed = before.graph.directed === true; + result.graph.metadata = { ...result.graph.metadata, ...before.graph.metadata }; + result.graph.edges = (result.graph.edges ?? []).map((edge, index) => { + const normalized = { ...edge, metadata: { ...edge.metadata, ...before.graph.edges?.[index]?.metadata } }; + if (version === 2 && normalized.directed === null) delete normalized.directed; + return normalized; + }); + return result; +} -/** - * Converts a graph to JSON Graph Format (JGF). - * - * @example - * ```ts - * import { createGraph } from '@statelyai/graph'; - * import { toJGF } from '@statelyai/graph/jgf'; - * - * const graph = createGraph({ - * nodes: [{ id: 'a' }, { id: 'b' }], - * edges: [{ id: 'e0', sourceId: 'a', targetId: 'b' }], - * }); - * - * const jgf = toJGF(graph); - * // { graph: { directed: true, nodes: [...], edges: [...] } } - * ``` - */ -export function toJGF(graph: Graph): JGFGraph { - const metadata: Record = {}; - // JGF `directed` is boolean; preserve bidirectional via metadata. - if (graph.mode === 'bidirectional') metadata.mode = graph.mode; - if (graph.initialNodeId) metadata.initialNodeId = graph.initialNodeId; - if (graph.data !== undefined) metadata.data = graph.data; - if (graph.direction) metadata.direction = graph.direction; - if (graph.style !== undefined) metadata.style = graph.style; +/** Preserve the legacy array/id output by default; request standards-valid v1 or v2 explicitly. */ +export function toJGF(graph: Graph): JGFGraph; +export function toJGF(graph: Graph, options: JGFExportOptions & { version?: undefined }): JGFGraph; +export function toJGF(graph: Graph, options: JGFExportOptions & { version: 1 }): JGFGraphV1; +export function toJGF(graph: Graph, options: JGFExportOptions & { version: 2 }): JGFGraphV2; +export function toJGF(graph: Graph, options: JGFExportOptions): JGFInput; +export function toJGF(graph: Graph, options: JGFExportOptions = {}): JGFInput { + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, 'jgf'); + if (carrier) { + const version = options.version ?? 'legacy'; + return getMergedNative(getNormalizedJGF(carrier.native, carrier.baseline, version), + toRawJGF(carrier.baseline, version === 'legacy' ? {} : { version }), + toRawJGF({ ...graph, data: getCarrierData(graph.data, 'jgf') }, version === 'legacy' ? {} : { version })); + } + return toRawJGF(graph, options); +} - return { - graph: { - id: graph.id || undefined, - directed: graph.mode !== 'undirected', - ...(Object.keys(metadata).length > 0 && { metadata }), - nodes: graph.nodes.map((n) => { - const meta: Record = {}; - if (n.parentId) meta.parentId = n.parentId; - if (n.initialNodeId) meta.initialNodeId = n.initialNodeId; - if (n.data !== undefined) meta.data = n.data; - if (n.x !== undefined) meta.x = n.x; - if (n.y !== undefined) meta.y = n.y; - if (n.width !== undefined) meta.width = n.width; - if (n.height !== undefined) meta.height = n.height; - if (n.shape) meta.shape = n.shape; - if (n.color) meta.color = n.color; - if (n.style !== undefined) meta.style = n.style; - if (n.ports !== undefined) meta.ports = n.ports; - return { - id: n.id, - ...(n.label && { label: n.label }), - ...(Object.keys(meta).length > 0 && { metadata: meta }), - }; - }), - edges: graph.edges.map((e) => { - const meta: Record = {}; - if (e.mode) meta.mode = e.mode; - if (e.data !== undefined) meta.data = e.data; - if (e.weight !== undefined) meta.weight = e.weight; - if (e.points !== undefined) meta.points = e.points; - if (e.routing !== undefined) meta.routing = e.routing; - if (e.x !== undefined) meta.x = e.x; - if (e.y !== undefined) meta.y = e.y; - if (e.width !== undefined) meta.width = e.width; - if (e.height !== undefined) meta.height = e.height; - if (e.color) meta.color = e.color; - if (e.style !== undefined) meta.style = e.style; - if (e.sourcePort !== undefined) meta.sourcePort = e.sourcePort; - if (e.targetPort !== undefined) meta.targetPort = e.targetPort; - return { - id: e.id, - source: e.sourceId, - target: e.targetId, - ...(e.label && { label: e.label }), - ...(Object.keys(meta).length > 0 && { metadata: meta }), - }; - }), - }, - }; +function toRawJGF(graph: Graph, options: JGFExportOptions = {}): JGFInput { + const { nodes, edges, ...root } = graph; + const nativeNodes: JGFNode[] = nodes.map((node) => { + const native: JGFNode = { id: node.id, ...(typeof node.label === 'string' && { label: node.label }) }; + native.metadata = toMetadataObject(node.data); + const mapped = getJGFNode(native, false); + mapped.data = getEditableMetadata(native.metadata); + native.metadata.__statelyai = createMetadata('jgf.node', node, mapped); + return native; + }); + const nativeEdges: JGFEdge[] = edges.map((edge, index) => { + const native: JGFEdge = { id: edge.id, source: edge.sourceId, target: edge.targetId, + ...(typeof edge.label === 'string' && { label: edge.label }), + ...(edge.mode !== undefined && { directed: edge.mode !== 'undirected' }), + ...(typeof edge.data?.relation === 'string' && { relation: edge.data.relation }) }; + native.metadata = toMetadataObject(edge.data); + const mapped = getJGFEdge(native, index, false); + mapped.data = getEditableMetadata(native.metadata, native.relation); + native.metadata.__statelyai = createMetadata('jgf.edge', edge, mapped); + return native; + }); + const input: JGFInput = { graph: { directed: graph.mode !== 'undirected', + ...((options.version === 2 || options.version === undefined) && { id: graph.id }), + nodes: options.version === 2 ? Object.fromEntries(nativeNodes.map(({ id, ...node }) => [id, node])) : nativeNodes, + edges: nativeEdges, + } } as JGFInput; + input.graph.metadata = toMetadataObject(graph.data); + const mappedRoot = getJGFRoot(input, false); + mappedRoot.data = getEditableMetadata(input.graph.metadata); + input.graph.metadata.__statelyai = { ...createMetadata('jgf.graph', root, mappedRoot), + nodeOrder: nodes.map((node) => node.id), nativeNodeOrder: options.version === 2 ? Object.keys(input.graph.nodes ?? {}) : nodes.map((node) => node.id) }; + return getClonedJSON(input); } -/** - * Parses a JSON Graph Format (JGF) object into a graph. - * - * @example - * ```ts - * import { fromJGF } from '@statelyai/graph/jgf'; - * - * const graph = fromJGF({ - * graph: { - * directed: true, - * nodes: [{ id: 'a' }, { id: 'b' }], - * edges: [{ source: 'a', target: 'b' }], - * }, - * }); - * ``` - */ -export function fromJGF(jgf: JGFGraph): Graph { - if (!jgf || typeof jgf !== 'object') { - throw new Error('JGF: expected an object'); +/** Accept v1 arrays, v2 dictionaries and the former hybrid dialect. */ +export function fromJGF(input: JGFInput, options: JGFImportOptions = {}): Graph { + if (!input || typeof input !== 'object') throw new Error('JGF: expected an object'); + if (!input.graph || typeof input.graph !== 'object') throw new Error('JGF: missing "graph" property'); + const native = input.graph; + if ('hyperedges' in native && native.hyperedges != null) { + if (!Array.isArray(native.hyperedges)) throw new Error('JGF: "graph.hyperedges" must be an array'); + if (native.hyperedges.length) throw new Error('JGF: hyperedges cannot be represented by a binary Graph; use jgfDocumentConverter for source retention'); } - if (!jgf.graph || typeof jgf.graph !== 'object') { - throw new Error('JGF: missing "graph" property'); - } - const g = jgf.graph; - if (!Array.isArray(g.nodes)) { - throw new Error('JGF: "graph.nodes" must be an array'); + if (native.nodes != null && typeof native.nodes !== 'object') throw new Error('JGF: "graph.nodes" must be an array or object'); + if (native.edges != null && !Array.isArray(native.edges)) throw new Error('JGF: "graph.edges" must be an array'); + const getWireMetadata = (value: any, format: string) => options.metadata === 'native' ? undefined : getMetadata(value?.__statelyai, format); + const metadata = getWireMetadata(native.metadata, 'jgf.graph'); + const legacy = options.metadata === 'legacy' || (options.metadata !== 'native' && !metadata && Array.isArray(native.nodes) && native.id !== undefined); + const nodes: JGFNativeNode[] = Array.isArray(native.nodes) ? native.nodes : Object.entries(native.nodes ?? {}).map(([id, node]) => ({ ...node, id })); + const mappedRoot = getJGFRoot(input, legacy); + if (metadata) mappedRoot.data = getEditedData(metadata.original.data, metadata.mapped.data, getEditableMetadata(native.metadata)); + const graph: Graph = { + ...getRestoredRecord(mappedRoot, metadata), + nodes: nodes.map((node) => { + const wire = getWireMetadata(node.metadata, 'jgf.node'); + const mapped = getJGFNode(node, legacy); + if (wire) mapped.data = getEditedData(wire.original.data, wire.mapped.data, getEditableMetadata(node.metadata)); + return getRestoredRecord(mapped, wire); + }), + edges: (native.edges ?? []).map((edge, index) => { + const wire = getWireMetadata(edge.metadata, 'jgf.edge'); + const mapped = getJGFEdge(edge, index, legacy); + if (wire) mapped.data = getEditedData(wire.original.data, wire.mapped.data, getEditableMetadata(edge.metadata, edge.relation)); + return getRestoredRecord(mapped, wire); + }), + }; + const order = native.metadata?.__statelyai; + if (metadata && Array.isArray(order.nodeOrder) && isJSONEqual(nodes.map((node) => node.id), order.nativeNodeOrder)) { + const positions = new Map(order.nodeOrder.map((id: string, index: number) => [id, index])); + graph.nodes.sort((a, b) => Number(positions.get(a.id)) - Number(positions.get(b.id))); } - if (!Array.isArray(g.edges)) { - throw new Error('JGF: "graph.edges" must be an array'); + if (!metadata || !isJSONEqual(input, toRawJGF(graph, Array.isArray(native.nodes) || native.nodes == null ? native.id !== undefined ? {} : { version: 1 } : { version: 2 }))) { + graph.data = createNativeCarrier(graph.data, 'jgf', input, graph); } - return { - id: g.id ?? '', - mode: - g.metadata?.mode ?? (g.directed === false ? 'undirected' : 'directed'), - initialNodeId: g.metadata?.initialNodeId ?? null, - data: g.metadata?.data, - ...(g.metadata?.direction && { direction: g.metadata.direction }), - ...(g.metadata?.style !== undefined && { style: g.metadata.style }), - nodes: g.nodes.map((n) => ({ - type: 'node' as const, - id: n.id, - parentId: n.metadata?.parentId ?? null, - initialNodeId: n.metadata?.initialNodeId ?? null, - label: n.label ?? '', - data: n.metadata?.data, - ...(n.metadata?.x !== undefined && { x: n.metadata.x }), - ...(n.metadata?.y !== undefined && { y: n.metadata.y }), - ...(n.metadata?.width !== undefined && { width: n.metadata.width }), - ...(n.metadata?.height !== undefined && { height: n.metadata.height }), - ...(n.metadata?.shape && { shape: n.metadata.shape }), - ...(n.metadata?.color && { color: n.metadata.color }), - ...(n.metadata?.style !== undefined && { style: n.metadata.style }), - ...(n.metadata?.ports !== undefined && { ports: n.metadata.ports }), - })), - edges: g.edges.map((e, i) => ({ - type: 'edge' as const, - id: e.id ?? `e${i}`, - sourceId: e.source, - targetId: e.target, - label: e.label ?? '', - ...(e.metadata?.mode && { mode: e.metadata.mode }), - data: e.metadata?.data, - ...(e.metadata?.weight !== undefined && { weight: e.metadata.weight }), - ...(e.metadata?.points !== undefined && { points: e.metadata.points }), - ...(e.metadata?.routing !== undefined && { routing: e.metadata.routing }), - ...(e.metadata?.x !== undefined && { x: e.metadata.x }), - ...(e.metadata?.y !== undefined && { y: e.metadata.y }), - ...(e.metadata?.width !== undefined && { width: e.metadata.width }), - ...(e.metadata?.height !== undefined && { height: e.metadata.height }), - ...(e.metadata?.color && { color: e.metadata.color }), - ...(e.metadata?.style !== undefined && { style: e.metadata.style }), - ...(e.metadata?.sourcePort !== undefined && { - sourcePort: e.metadata.sourcePort, - }), - ...(e.metadata?.targetPort !== undefined && { - targetPort: e.metadata.targetPort, - }), - })), - }; + return graph; } -/** - * Bidirectional converter for JSON Graph Format. - * - * @example - * ```ts - * import { createGraph } from '@statelyai/graph'; - * import { jgfConverter } from '@statelyai/graph/jgf'; - * - * const graph = createGraph({ - * nodes: [{ id: 'a' }, { id: 'b' }], - * edges: [{ id: 'e0', sourceId: 'a', targetId: 'b' }], - * }); - * - * const jgf = jgfConverter.to(graph); - * const roundTripped = jgfConverter.from(jgf); - * ``` - */ -export const jgfConverter: GraphFormatConverter = - createFormatConverter(toJGF, fromJGF); +export const jgfConverter: Omit, 'from'> & { from: typeof fromJGF } = + { to: toJGF, from: fromJGF }; + + +/** Exact native source preservation, with strict verified field edits. */ +export const jgfDocumentConverter = createGraphDocumentConverter({ + format: 'jgf', + codec: { from: (source: JGFInput) => fromJGF(source, { metadata: 'native' }), to: (graph: Graph) => toJGF(graph, { nativeCarrier: false }) }, + project: (source: JGFInput) => ({ graph: getStructuredDocumentProjectionGraph(fromJGF(source, { metadata: 'native' }), source, 'jgf') }), + patch: (context) => getStructuredDocumentPatch(context, 'jgf'), +}); diff --git a/src/formats/matrix-market/README.md b/src/formats/matrix-market/README.md new file mode 100644 index 0000000..5ab732a --- /dev/null +++ b/src/formats/matrix-market/README.md @@ -0,0 +1,27 @@ +# Matrix Market + +Dependency-free Matrix Market text conversion for square graphs. Supported profiles: coordinate `real`, `integer` or `pattern`, and array `real` or `integer`, each with `general` or `symmetric` storage. Array coefficients use column-major order; symmetric storage uses the lower triangle. + + +```ts +import { toMatrixMarket, fromMatrixMarket, matrixMarketDocumentConverter } from '@statelyai/graph/matrix-market'; +const source = toMatrixMarket(graph); // coordinate real general +const restored = fromMatrixMarket(source); +const document = matrixMarketDocumentConverter.from(source); +document.graph!.edges[0].weight = -2; +const updated = matrixMarketDocumentConverter.to(document); +``` + +`matrixMarketConverter` exposes `.to(graph)` and `.from(source)`. Export accepts `{ storage, field, symmetry, aggregation, maxCells }`. Import accepts `{ nodeIds, mode, aggregation, maxCells, maxNodes, maxEntries }`; ordinary input uses IDs `"0"`, `"1"`, etc. General storage defaults to directed; symmetric storage is undirected. Explicit undirected general input requires mirrored coefficients and mirrored stored coordinate presence, including zeros. Explicit `nodeIds`/`mode` cannot override a recognized sidecar silently; `{ metadata: 'native' }` opts out. + +A stored coordinate zero represents a zero-weight edge. Dense array zeros mean absence unless the Graph sidecar explicitly records zero presence. Pattern entries have weight 1. Integer coefficients must be safe integers. Complex, Hermitian, skew-symmetric and rectangular matrices have no projection in this scoped API. Array input requires one coefficient per data line; unrecognized variants reject rather than flattening them. + +Duplicate coordinate entries and parallel Graph edges reject by default. Explicit `sum`, `min`, `max` or `count` aggregation combines coefficients with declared semantic loss for independent readers. Versioned sidecars retain original Graph identities/data; edited aggregates reject when individual weights cannot be inferred. All changed count cells reject because cardinality cannot determine weights. Sparse coordinate conversion costs O(N+E); dense arrays have a `maxCells` guard of 1,000,000. Import defaults `maxNodes`/`maxEntries` to 1,000,000 and validates dimensions/counts/indices before allocation. + + +A `% @statelyai/graph/matrix` JSON comment preserves JSON-domain Graph data, IDs, ports and optional-field presence. Live coefficients and ordering remain authoritative. Automatic metadata recognition follows an explicit convention; import `{ metadata: 'native' }` and export `{ nativeCarrier: false }` disable interpretation. Bare export regenerates text in the requested profile; use the document converter for source formatting preservation. + +Documents retain exact original text, comments, coordinate order, whitespace and line endings through no-op and JSON persistence, including unsupported source with `graph: null`. Strict weight patches replace actual coefficients and preserve surrounding text; integer/pattern constraints, conflicts, aggregated and structural edits receive diagnostics. Array zero-weight changes require an available sidecar presence mapping. `.getUpdate(document)` returns diagnostics; `.to(document)` rejects unsupported edits. Source preservation and Graph projection are separate capabilities. + + +Native proof uses actual SciPy 1.18.1 / NumPy 2.5.4 `mmread`/`mmwrite`: stored zeros, duplicate COO entries, column-major arrays, symmetry, pattern values, malformed dimensions and a 20,000-node sparse matrix. [Matrix Market format specification](https://math.nist.gov/MatrixMarket/formats.html) and [SciPy reader](https://docs.scipy.org/doc/scipy/reference/generated/scipy.io.mmread.html). diff --git a/src/formats/matrix-market/index.ts b/src/formats/matrix-market/index.ts new file mode 100644 index 0000000..55e9f43 --- /dev/null +++ b/src/formats/matrix-market/index.ts @@ -0,0 +1,192 @@ +import type { Graph } from '../../types'; +import { createFormatConverter } from '../converter'; +import { createGraphDocumentConverter } from '../document'; +import { createNativeCarrier, getNativeCarrier, getCarrierData, getNativeDocumentGraph, isJSONEqual, type FormatImportOptions, type FormatExportOptions } from '../preservation'; +import { createMatrixSidecar, getMatrixSidecar, getMatrixGraph, getGraphMatrixView, getMatrixKey, getMatrixLimit, getValidatedNodeIds, getMatrixAggregation, getMatrixDocumentChanges, type MatrixAggregation, type MatrixSidecar, type MatrixView } from '../adjacency-matrix/core'; + +export interface MatrixMarketImportOptions extends FormatImportOptions { + nodeIds?: string[]; mode?: 'directed' | 'undirected'; aggregation?: MatrixAggregation; + maxCells?: number; maxNodes?: number; maxEntries?: number; +} +export interface MatrixMarketExportOptions extends FormatExportOptions { + storage?: 'coordinate' | 'array'; field?: 'real' | 'integer' | 'pattern'; symmetry?: 'general' | 'symmetric'; + aggregation?: MatrixAggregation; maxCells?: number; +} +interface MatrixMarketRecord { row: number; col: number; value: number; line: number } +interface MatrixMarketSource { + lines: string[]; endings: string[]; storage: 'coordinate' | 'array'; field: 'real' | 'integer' | 'pattern'; symmetry: 'general' | 'symmetric'; + size: number; dimensionLine: number; records: MatrixMarketRecord[]; sidecar?: MatrixSidecar; sidecarLine?: number; +} +const SIDECAR = '% @statelyai/graph/matrix '; + +function getInteger(value: string, label: string): number { + if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) throw new Error(`Matrix Market: invalid ${label}`); + return Number(value); +} +function getNumber(value: string, field: MatrixMarketSource['field']): number { + if ((field === 'integer' && !/^[+-]?\d+$/.test(value)) || !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eEdD][+-]?\d+)?$/.test(value)) throw new Error('Matrix Market: invalid numeric coefficient'); + const number = Number(value.replace(/[dD]/, 'e')); + if (!Number.isFinite(number) || (field === 'integer' && !Number.isSafeInteger(number))) throw new Error('Matrix Market: coefficient is non-finite or not a safe integer'); + return number; +} +function getParsedMatrixMarket(text: string, options: MatrixMarketImportOptions = {}): MatrixMarketSource { + if (typeof text !== 'string') throw new Error('Matrix Market: expected text'); + const lines: string[] = [], endings: string[] = []; + const pattern = /([^\r\n]*)(\r\n|\r|\n|$)/g; let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) && (match[0].length || match.index < text.length)) { lines.push(match[1]); endings.push(match[2]); if (!match[2]) break; } + const header = lines[0]?.trim().toLowerCase().split(/\s+/); + if (header?.length !== 5 || header[0] !== '%%matrixmarket' || header[1] !== 'matrix' || !['coordinate', 'array'].includes(header[2]) || !['real', 'integer', 'pattern'].includes(header[3]) || !['general', 'symmetric'].includes(header[4])) throw new Error('Matrix Market: unsupported header/storage/field/symmetry'); + const storage = header[2] as MatrixMarketSource['storage'], field = header[3] as MatrixMarketSource['field'], symmetry = header[4] as MatrixMarketSource['symmetry']; + if (storage === 'array' && field === 'pattern') throw new Error('Matrix Market: array pattern storage is unsupported'); + let sidecar: MatrixSidecar | undefined, sidecarLine: number | undefined; + const dataLines: number[] = []; + for (let line = 1; line < lines.length; line++) { + const value = lines[line].trim(); if (!value) continue; + if (value.startsWith('%')) { + if (options.metadata !== 'native' && value.startsWith(SIDECAR)) { + let parsed: any; try { parsed = JSON.parse(value.slice(SIDECAR.length)); } catch { throw new Error('Matrix Market: malformed Graph sidecar'); } + const metadata = getMatrixSidecar(parsed, 'matrix-market'); + if (metadata) { sidecar = metadata; sidecarLine = line; } + } + continue; + } + dataLines.push(line); + } + const dimensionLine = dataLines.shift(); + if (dimensionLine === undefined) throw new Error('Matrix Market: missing dimensions'); + const dimensions = lines[dimensionLine].trim().split(/\s+/); + if (dimensions.length !== (storage === 'coordinate' ? 3 : 2)) throw new Error('Matrix Market: invalid dimensions'); + const rows = getInteger(dimensions[0], 'row count'), cols = getInteger(dimensions[1], 'column count'); + if (rows !== cols) throw new Error('Matrix Market: rectangular matrices have no square Graph projection'); + const maxNodes = options.maxNodes ?? 1_000_000, maxEntries = options.maxEntries ?? 1_000_000; + if (!Number.isSafeInteger(maxNodes) || maxNodes < 0 || !Number.isSafeInteger(maxEntries) || maxEntries < 0 || rows > maxNodes) throw new Error('Matrix Market: maxNodes/maxEntries exceeded or invalid'); + const count = storage === 'coordinate' ? getInteger(dimensions[2], 'entry count') : symmetry === 'symmetric' ? rows * (rows + 1) / 2 : rows * rows; + if (storage === 'array') getMatrixLimit(rows, options.maxCells); + if (!Number.isSafeInteger(count) || count > maxEntries || dataLines.length !== count) throw new Error('Matrix Market: record count does not match dimensions or exceeds maxEntries'); + const records: MatrixMarketRecord[] = []; + let row = 0, col = 0; + for (const line of dataLines) { + const values = lines[line].trim().split(/\s+/); + if (storage === 'coordinate') { + if (values.length !== (field === 'pattern' ? 2 : 3)) throw new Error('Matrix Market: invalid coordinate record'); + const r = getInteger(values[0], 'row index') - 1, c = getInteger(values[1], 'column index') - 1; + if (r < 0 || c < 0 || r >= rows || c >= cols || (symmetry === 'symmetric' && r < c)) throw new Error('Matrix Market: coordinate index outside declared lower-triangle dimensions'); + records.push({ row: r, col: c, value: field === 'pattern' ? 1 : getNumber(values[2], field), line }); + } else { + if (values.length !== 1) throw new Error('Matrix Market: scoped array records require one coefficient per line'); + records.push({ row, col, value: getNumber(values[0], field), line }); row++; + if (row === rows) { col++; row = symmetry === 'symmetric' ? col : 0; } + } + } + return { lines, endings, storage, field, symmetry, size: rows, dimensionLine, records, sidecar, sidecarLine }; +} + +function getMatrixMarketView(parsed: MatrixMarketSource, options: MatrixMarketImportOptions = {}): MatrixView { + if (parsed.sidecar && options.nodeIds && !isJSONEqual(options.nodeIds, parsed.sidecar.nodeIds)) throw new Error('Matrix Market: nodeIds parameter conflicts with Graph sidecar; use metadata:native to override'); + if (parsed.sidecar && options.mode && options.mode !== parsed.sidecar.mode) throw new Error('Matrix Market: mode parameter conflicts with Graph sidecar'); + const nodeIds = options.nodeIds ?? parsed.sidecar?.nodeIds ?? Array.from({ length: parsed.size }, (_, index) => String(index)); + getValidatedNodeIds(nodeIds); if (nodeIds.length !== parsed.size) throw new Error('Matrix Market: nodeIds count differs from dimensions'); + const mode = options.mode ?? parsed.sidecar?.mode ?? (parsed.symmetry === 'symmetric' ? 'undirected' : 'directed'); + const zeros = new Set(parsed.sidecar?.zeroPresence?.map(([sourceId, targetId]) => getMatrixKey(sourceId, targetId, mode))); + const groups = new Map(); + for (const record of parsed.records) { + const endpoints = [[record.row, record.col]]; + if (parsed.symmetry === 'symmetric' && mode === 'directed' && record.row !== record.col) endpoints.push([record.col, record.row]); + for (const [row, col] of endpoints) { + const sourceId = nodeIds[row], targetId = nodeIds[col], key = getMatrixKey(sourceId, targetId, mode); + if (parsed.storage === 'array' && record.value === 0 && !zeros.has(key)) continue; + const group = groups.get(key) ?? { sourceId, targetId, values: [] }; group.values.push(record.value); groups.set(key, group); + } + } + if (mode === 'undirected' && parsed.symmetry === 'general') { + // A general dense/sparse symmetric matrix contains both halves of each edge. + const positions = new Map(); + for (const record of parsed.records) { const key = `${record.row},${record.col}`, values = positions.get(key) ?? []; values.push(record.value); positions.set(key, values); } + const byPosition = new Map([...positions].map(([key, values]) => [key, getMatrixAggregation(values, options.aggregation ?? 'reject')])); + for (const record of parsed.records) if (record.row !== record.col && ((parsed.storage === 'coordinate' && !byPosition.has(`${record.col},${record.row}`)) || byPosition.get(`${record.row},${record.col}`) !== (byPosition.get(`${record.col},${record.row}`) ?? 0))) throw new Error('Matrix Market: undirected general coefficients and stored presence must be symmetric'); + groups.clear(); + for (const record of parsed.records) if (record.row <= record.col && (parsed.storage === 'coordinate' || record.value !== 0 || zeros.has(getMatrixKey(nodeIds[record.row], nodeIds[record.col], mode)))) { + const key = getMatrixKey(nodeIds[record.row], nodeIds[record.col], mode), group = groups.get(key) ?? { sourceId: nodeIds[record.row], targetId: nodeIds[record.col], values: [] }; + group.values.push(record.value); groups.set(key, group); + } + } + return { nodeIds, mode, cells: [...groups.values()].map((group) => ({ sourceId: group.sourceId, targetId: group.targetId, value: getMatrixAggregation(group.values, options.aggregation ?? 'reject') })) }; +} + +/** Stored coordinate zeros are edges; dense array zeros default to absence. */ +export function fromMatrixMarket(text: string, options: MatrixMarketImportOptions = {}): Graph { + const parsed = getParsedMatrixMarket(text, options), view = getMatrixMarketView(parsed, options); + const graph = getMatrixGraph(view, parsed.sidecar); + graph.data = createNativeCarrier(graph.data, 'matrix-market', text, graph); return graph; +} + +function toRawMatrixMarket(graph: Graph, options: MatrixMarketExportOptions): string { + const aggregation = options.aggregation ?? 'reject'; const view = getGraphMatrixView(graph, aggregation); + const storage = options.storage ?? 'coordinate', field = options.field ?? 'real', symmetry = options.symmetry ?? (view.mode === 'undirected' ? 'symmetric' : 'general'); + if (!['coordinate', 'array'].includes(storage) || !['real', 'integer', 'pattern'].includes(field) || !['general', 'symmetric'].includes(symmetry)) throw new Error('Matrix Market: unsupported export profile'); + if (symmetry === 'symmetric' && view.mode !== 'undirected') throw new Error('Matrix Market: symmetric export requires undirected Graph semantics'); + if (storage === 'array' && field === 'pattern') throw new Error('Matrix Market: pattern array is unsupported'); + if (field === 'integer' && view.cells.some((cell) => !Number.isSafeInteger(cell.value))) throw new Error('Matrix Market: integer export requires safe integer weights'); + if (field === 'pattern' && view.cells.some((cell) => cell.value !== 1)) throw new Error('Matrix Market: pattern export requires unit coefficients'); + const sidecar = createMatrixSidecar(graph, 'matrix-market', aggregation); + if (storage === 'array' && view.cells.some((cell) => cell.value === 0)) sidecar.zeroPresence = view.cells.filter((cell) => cell.value === 0).map((cell) => [cell.sourceId, cell.targetId]); + const lines = [`%%MatrixMarket matrix ${storage} ${field} ${symmetry}`, `${SIDECAR}${JSON.stringify(sidecar)}`]; + const indices = new Map(view.nodeIds.map((id, index) => [id, index])); + if (storage === 'coordinate') { + const records: Array<[number, number, number]> = []; + for (const cell of view.cells) { + let row = indices.get(cell.sourceId)!, col = indices.get(cell.targetId)!; + if (symmetry === 'symmetric' && row < col) [row, col] = [col, row]; + records.push([row, col, cell.value]); if (view.mode === 'undirected' && symmetry === 'general' && row !== col) records.push([col, row, cell.value]); + } + lines.push(`${view.nodeIds.length} ${view.nodeIds.length} ${records.length}`); + for (const [row, col, value] of records) lines.push(`${row + 1} ${col + 1}${field === 'pattern' ? '' : ` ${value}`}`); + } else { + getMatrixLimit(view.nodeIds.length, options.maxCells); + const cells = new Map(view.cells.map((cell) => [getMatrixKey(cell.sourceId, cell.targetId, view.mode), cell.value])); + lines.push(`${view.nodeIds.length} ${view.nodeIds.length}`); + for (let col = 0; col < view.nodeIds.length; col++) for (let row = symmetry === 'symmetric' ? col : 0; row < view.nodeIds.length; row++) lines.push(String(cells.get(getMatrixKey(view.nodeIds[row], view.nodeIds[col], view.mode)) ?? 0)); + } + return `${lines.join('\n')}\n`; +} + +/** Sparse coordinate export remains O(N+E); array storage uses bounded column-major coefficients. */ +export function toMatrixMarket(graph: Graph, options: MatrixMarketExportOptions = {}): string { + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, 'matrix-market'); + const aggregation = options.aggregation ?? (carrier ? getParsedMatrixMarket(carrier.native).sidecar?.baseline.aggregation : undefined); + return toRawMatrixMarket(carrier ? { ...graph, data: getCarrierData(graph.data, 'matrix-market') } : graph, { ...options, aggregation }); +} + +export const matrixMarketConverter = createFormatConverter(toMatrixMarket, fromMatrixMarket); +export const matrixMarketDocumentConverter = createGraphDocumentConverter({ + format: 'matrix-market', codec: { from: fromMatrixMarket, to: (graph) => toMatrixMarket(graph, { nativeCarrier: false }) }, + project: (source: string) => ({ graph: getNativeDocumentGraph(fromMatrixMarket(source), 'matrix-market', source) }), + patch: (context) => { + const error = getMatrixDocumentChanges(context); if (error) return { ok: false, diagnostics: [{ code: 'unsupported-native-edit', severity: 'error', message: error }] }; + const parsed = getParsedMatrixMarket(context.source), view = getMatrixMarketView(parsed); + if (parsed.sidecar?.baseline.aggregation && parsed.sidecar.baseline.aggregation !== 'reject') return { ok: false, diagnostics: [{ code: 'ambiguous-aggregate-edit', severity: 'error', message: 'Aggregated coefficients cannot be distributed' }] }; + const indices = new Map(view.nodeIds.map((id, index) => [id, index])); + const edges = new Map(context.graph.edges.map((edge) => [edge.id, edge])); + const byCell = new Map(); + for (const record of parsed.records) { + const key = getMatrixKey(view.nodeIds[record.row], view.nodeIds[record.col], view.mode), records = byCell.get(key) ?? []; records.push(record); byCell.set(key, records); + } + const zeroPresence = parsed.sidecar?.zeroPresence ?? [], zeroKeys = new Set(zeroPresence.map(([a, b]) => getMatrixKey(a, b, view.mode))); let sidecarChanged = false; + for (const change of context.changes) { + const edge = edges.get(change.id!)!; const row = indices.get(edge.sourceId)!, col = indices.get(edge.targetId)!; const value = edge.weight ?? 1; + if (!Number.isFinite(value) || (parsed.field === 'integer' && !Number.isSafeInteger(value)) || (parsed.field === 'pattern' && value !== 1)) return { ok: false, diagnostics: [{ code: 'unsupported-native-edit', severity: 'error', message: 'Requested coefficient is incompatible with the native field' }] }; + const records = byCell.get(getMatrixKey(edge.sourceId, edge.targetId, view.mode)) ?? []; + if (!records.length || (parsed.storage === 'coordinate' && records.length > (view.mode === 'undirected' && parsed.symmetry === 'general' && row !== col ? 2 : 1))) return { ok: false, diagnostics: [{ code: 'ambiguous-aggregate-edit', severity: 'error', message: 'Native record correspondence is not unique' }] }; + for (const record of records) { + if (parsed.field !== 'pattern') parsed.lines[record.line] = parsed.lines[record.line].replace(/\S+(\s*)$/, `${value}$1`); + } + if (parsed.storage === 'array' && value === 0) { + if (!parsed.sidecar || parsed.sidecarLine === undefined) return { ok: false, diagnostics: [{ code: 'unsupported-native-edit', severity: 'error', message: 'Dense zero edges require an explicit Graph presence sidecar' }] }; + const key = getMatrixKey(edge.sourceId, edge.targetId, view.mode); if (!zeroKeys.has(key)) { zeroPresence.push([edge.sourceId, edge.targetId]); zeroKeys.add(key); sidecarChanged = true; } + parsed.sidecar.zeroPresence = zeroPresence; + } + } + if (sidecarChanged) parsed.lines[parsed.sidecarLine!] = `${SIDECAR}${JSON.stringify(parsed.sidecar)}`; + return { ok: true, source: parsed.lines.map((line, index) => line + parsed.endings[index]).join('') }; + }, +}); diff --git a/src/formats/mermaid/README.md b/src/formats/mermaid/README.md index 406b47e..6af8118 100644 --- a/src/formats/mermaid/README.md +++ b/src/formats/mermaid/README.md @@ -2,6 +2,26 @@ Converters for [Mermaid](https://mermaid.js.org/) diagram syntax. Each diagram type has its own dedicated converter with type-specific node/edge data interfaces. +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **partial**. +Source document preservation: **partial**. These are separate guarantees; +selected-field round-trip tests do not prove unrestricted losslessness. + +Each converter supports selected syntax. Comments, opaque YAML frontmatter and +JSON directives are retained in additive `graph.data.source` metadata and +re-emitted; exact source placement/formatting remains unsupported. Arbitrary +Graph fields still lack a complete native encoding. + + + +For the original eight codecs, unknown statements retained in `source.unknownStatements` are available for +inspection, not silently treated as supported graph semantics. These codecs +remain synchronous and dependency-free; frontmatter retention does not imply +general YAML configuration evaluation. GitGraph interprets its scalar `mainBranchName` configuration; other frontmatter remains opaque. The six additional profiles reject unsupported grammar instead of creating misleading nodes; their document converters retain the unprojectable source with diagnostics. + ## Resources - [Mermaid documentation](https://mermaid.js.org/intro/) @@ -29,6 +49,21 @@ import { fromMermaidBlock, toMermaidBlock, mermaidBlockConverter, // Ishikawa diagram fromMermaidIshikawa, toMermaidIshikawa, mermaidIshikawaConverter, + // Additional native graph profiles + fromMermaidArchitecture, toMermaidArchitecture, mermaidArchitectureConverter, + fromMermaidRequirement, toMermaidRequirement, mermaidRequirementConverter, + fromMermaidSankey, toMermaidSankey, mermaidSankeyConverter, + fromMermaidGitGraph, toMermaidGitGraph, mermaidGitGraphConverter, + fromMermaidC4, toMermaidC4, mermaidC4Converter, + fromMermaidUseCase, toMermaidUseCase, mermaidUseCaseConverter, + // Exact native document retention with strict editing policies + mermaidFlowchartDocumentConverter, mermaidSequenceDocumentConverter, + mermaidStateDocumentConverter, mermaidClassDocumentConverter, + mermaidERDocumentConverter, mermaidBlockDocumentConverter, + mermaidMindmapDocumentConverter, mermaidIshikawaDocumentConverter, + mermaidArchitectureDocumentConverter, mermaidRequirementDocumentConverter, + mermaidSankeyDocumentConverter, mermaidGitGraphDocumentConverter, + mermaidC4DocumentConverter, mermaidUseCaseDocumentConverter, } from '@statelyai/graph/mermaid'; ``` @@ -70,7 +105,22 @@ const graph = fromMermaidSequence(`sequenceDiagram const mermaid = toMermaidSequence(graph); ``` -**Types:** `SequenceNodeData` (actorType, alias), `SequenceEdgeData` (kind, stroke, arrowType), `SequenceGraphData` (autoNumber, blocks), `SequenceBlock` + + +**Types:** `SequenceNodeData` (`actorType`, `alias`, `configuration`, `created`, +`destroyed`, `notes`, `box`), `SequenceNote` (`id`, `position`, `text`, `over`), +`SequenceEdgeData` (`kind`, `stroke`, `arrowType`, `bidirectional`, +`centralSource`, `centralTarget`, `sequenceNumber`), `SequenceGraphData` +(`autonumber`, `autonumberStart`, `autonumberIncrement`, `blocks`, `events`), +`SequenceBlock`, `SequenceEvent` + +Notes and lifecycle directives use ordered events anchored to live edge IDs. +Notes carry stable IDs, so rearranging the convenience notes array does not +move them on the timeline. Spanning notes are emitted once; the event-linked +participant owns their editable text. Missing event participants, notes or +edge anchors fail explicitly. Legacy `noteIndex` events and lifecycle flags +remain readable. Participant stereotypes emit official `participant ID@{...}` +JSON syntax; legacy `database ID` and similar inputs remain readable. --- @@ -101,11 +151,17 @@ const mermaid = toMermaidFlowchart(graph); **Types:** `FlowchartNodeData` (`classes`, deprecated `link`, `tooltip`, -`direction`, `click`), `FlowchartClick` (`kind`, `target`, `tooltip`, +`direction`, `click`, `properties`), `FlowchartClick` (`kind`, `target`, `tooltip`, `linkTarget`, `explicitCall`), `FlowchartEdgeData` (`stroke`, `arrowType`, -`endMarker`, `startMarker`, `bidirectional`, `linkStyle`), +`endMarker`, `startMarker`, `bidirectional`, `linkStyle`, `properties`, +`explicitId`, `arrowToken`), `FlowchartGraphData` (`diagramType`, `classDefs`, `defaultLinkStyle`, `init`) +Expanded shapes and image/icon properties retain their property maps. Canonical +labels/shapes/modes and live stroke edits take precedence during emission. +Explicit edge IDs, long connectors, direct node styles and declaration +overrides retain their supported semantics. + --- ### State Diagram @@ -167,7 +223,13 @@ const graph = fromMermaidClass(`classDiagram const mermaid = toMermaidClass(graph); ``` -**Types:** `ClassNodeData` (members, annotation, genericType), `ClassEdgeData` (relationType, sourceCardinality, targetCardinality) + + +**Types:** `ClassNodeData` (`members`, `annotation`, `genericType`, `namespace`, +`notes`, `classes`), `ClassEdgeData` (`relationType`, `sourceCardinality`, +`targetCardinality`, `sourceMarker`, `targetMarker`, `lineStyle`), +`ClassGraphData` (`notes`, `classDefs`). Namespace hierarchy, display aliases, +notes, styles, lollipop interfaces and two-sided relationships are retained. --- @@ -196,7 +258,11 @@ const graph = fromMermaidER(`erDiagram const mermaid = toMermaidER(graph); ``` -**Types:** `ERNodeData` (attributes with type, name, key, comment), `EREdgeData` (sourceCardinality, targetCardinality, identifying) + + +**Types:** `ERNodeData` (attributes with `type`, `name`, legacy `key`, multiple +`keys`, `comment`), `EREdgeData` (`sourceCardinality`, `targetCardinality`, +`identifying`). Isolated entities, display aliases and diagram direction emit. --- @@ -216,13 +282,17 @@ const graph = fromMermaidMindmap(`mindmap Testing `); // a graph containing: -// nodes: - root - Design - Wireframes - Prototypes - Development - Frontend - Backend - Testing -// edges: - (root -> Design) - (Design -> Wireframes) - (Design -> Prototypes) - (root -> Development) - (Development -> Frontend) - (Development -> Backend) - (root -> Testing) +// Authored ID root; anonymous text nodes receive generated mm_* IDs. +// Indentation defines parentId and parent-child edges. const mermaid = toMermaidMindmap(graph); ``` -**Types:** `MindmapNodeData` (icon), `MindmapEdgeData`, `MindmapGraphData` + + +**Types:** `MindmapNodeData` (`icon`, `authoredId`, `classes`), +`MindmapEdgeData`, `MindmapGraphData`. Named shapes retain authored IDs; +icon/class decoration lines attach to their node. --- @@ -244,7 +314,13 @@ const graph = fromMermaidBlock(`block-beta const mermaid = toMermaidBlock(graph); ``` -**Types:** `BlockNodeData` (span), `BlockEdgeData` (stroke, arrowType), `BlockGraphData` (columns) + + +**Types:** `BlockNodeData` (`span`, scoped `columns`), `BlockEdgeData` +(`stroke`, `arrowType`, `arrowToken`), `BlockGraphData` (`columns`). +Quote-aware declarations retain multiword labels, node shapes and row spans; +nested column counts remain on their container. Unsupported spacing/directive +syntax is retained for inspection instead of creating directive nodes. ### Ishikawa Diagram @@ -260,10 +336,113 @@ Blurry Photo Shaky hands `); // a graph containing: -// nodes: - Blurry Photo - Process - Out of focus - Shutter speed too slow - User - Shaky hands -// edges: - (Blurry Photo -> Process) - (Process -> Out of focus) - (Process -> Shutter speed too slow) - (Blurry Photo -> User) - (User -> Shaky hands) +// Generated ish_* IDs; labels and indentation define cause hierarchy. const mermaid = toMermaidIshikawa(graph); ``` **Types:** `IshikawaNodeData` (kind), `IshikawaEdgeData`, `IshikawaGraphData` + + +## Native documents + + + +Each diagram exports a matching `mermaid*DocumentConverter`: Flowchart, Sequence, +State, Class, ER, Block, Mindmap and Ishikawa. These retain exact original text, +including comments, frontmatter, unsupported syntax and whitespace, after JSON +persistence. Semantic projections remain partial; failed imports retain source +with a null projection and diagnostics. + +Verified label edits cover standalone flowchart node declarations, quoted state +aliases, simple sequence participant aliases and uniquely identified messages. +Unedited source text remains exact. Duplicate declarations, expanded flow properties, +structural changes and other unsupported edits fail explicitly. Class, ER, Block, +Mindmap and Ishikawa currently support source editing and exact no-op export only. + +Document projections expose one editable label where native syntax also stores +an alias/description/configuration label; native source retains those original +fields. Arbitrary new Graph data edits still count and can be rejected when no +native mapping exists. See [document API](../document/README.md). + + +## Native interoperability + + + +The Node 24 native suite uses pinned Mermaid 12.1.0 to parse external fixtures +and emitted output for all fourteen scoped dialects. Selected native database +assertions cover labels, shapes, relationships, participant/note order and +hierarchy. Output uses native numeric entities for literal backslashes; raw +native backslash counts are preserved on import. Class multiplicities use +native placement, and nested state transitions remain inside their common +container. These checks establish the tested profile, not complete native +language coverage. See [validation scope](../../../docs/native-validation.md). + +Class/ER relation markers and cardinality metadata govern native relationships; +generic Graph edge mode edits do not yet have a complete mapping for those +semantic models. State profiles use directed transitions. Document export rejects +unsupported mode edits; explicit regeneration reports differing Graph fields. + + +## Additional native graph profiles + + + +| Profile | Graph mapping | Explicit limits | +| --- | --- | --- | +| Architecture | Group/service/junction kinds, icon names, hierarchy, alignment and canonical arrows; L/R/T/B sides become ports | Edges have no label field; arbitrary ports reject. Icon resolution/layout are native renderer concerns. | +| Requirement | Requirement types, business ID, body text as `node.label`, risk, verification, typed relationships, selected styles/classes | Element display names are identities. Native field strings cannot be empty; empty body text emits by omitting `text`. Hierarchy/ports reject. | +| Sankey | Inferred name identities and numeric edge weights; parallels and zero weights | Mermaid12.1's native lexer accepts printable ASCII only and trims outer name whitespace even in quotes. Unicode, isolated nodes, ports and incompatible modes reject. Missing export weights default to 1, configurable through `defaultWeight`. | +| GitGraph | Ordered actions and child-to-parent commit DAG; merges and cherry-picks retain both parents | Requires `graph.data.actions`; arbitrary DAGs and inconsistent action/DAG edits reject. Anonymous commit IDs normalize. `mainBranch` controls native configuration and inherited main-branch references. Commit display identity cannot have an independent label. | +| C4 | Context/Container/Component/Dynamic/Deployment typed macros, boundaries, descriptions, technologies and positional/named relation arguments | Scoped supported macro list; renderer update/layout macros and executable external DSL reject. Native optional arguments and Graph field presence remain distinct. | +| UseCase | Actors/variants, one-level boundaries, include/extend/generalization, association markers/length, stereotypes, notes, JSON tables and selected styles/classes | Anonymous notes normalize after regular nodes/edges; JSON property spelling/order is source-only. Multiline Markdown/accessibility grammar is outside this parser profile. Note and relationship topology must agree. | + +Bare Graph serialization still lacks a complete encoding for arbitrary user data, +null/presence, geometry, styles and entity order across these languages. Numeric +entity escaping supports labels; bare ID encoding is injective, including empty +IDs where the dialect permits aliases. Literal values matching the private ID +encoding convention have an ambiguity boundary; conflicting identities reject +rather than merge. Source documents remain the reliable place for original IDs, +comments, unknown syntax and metadata. + + + +All six `*DocumentConverter` registrations preserve exact no-op source after JSON +persistence, including unprojectable syntax with `graph: null`. Architecture +standalone declaration labels, Requirement body text, C4 declaration labels and +UseCase actor/case/boundary declaration labels have verified text-span patches. +Untouched comments and text remain exact; output must reparse to the requested +Graph. Structural, ambiguous and other unsupported edits reject. Sankey/GitGraph +Graph edits currently require explicit source editing or explicit diagnostic +regeneration. Requirement empty text patches remove the field line while retaining +its comments; null labels are not silently converted into empty text. + + + +Native Mermaid12.1 database assertions inspect sides/arrowheads/groups, requirement +fields/styles, weighted flows, commit parents/branch configuration, C4 technology +and hierarchy, and UseCase modifiers/notes/JSON cells. Display text tests use +Mermaid's native decoder to check exact Unicode, quotes, backslashes and line +breaks where supported. Upstream-negative controls characterize Sankey Unicode +and Requirement explicit empty-string syntax. Bulk/deep fixtures exercise 2,000 +native architecture services and 100 nested deployment nodes. These assertions +establish the stated profiles rather than complete language conformance. + + + + +Native display strings reject NUL and lone UTF-16 surrogates: Mermaid/DOM numeric +entity decoding substitutes a replacement character, so a round-trip would lose +the requested text. Astral surrogate pairs remain supported. Strict label edits +reject these unrepresentable values; exact document source and JSON-escaped +opaque data remain preservable. ID encoding still accepts arbitrary UTF-16 code +units when a dialect supports a separate native-safe alias. Architecture's native +empty label token is unsupported; null labels omit the optional label field. +Git commit/branch identity tokens retain literal Unicode, backslashes and `#` +characters; embedded quote/control characters reject where the native token +cannot encode them. Display tags use the normal numeric entity encoding. + +C4 `Rel_Back` normalizes the native start arrow to reversed Graph endpoints. +Repeated native alias pairs are rejected: Mermaid replaces an existing relation +for that ordered pair. GraphDocument retains such source with an unavailable projection. diff --git a/src/formats/mermaid/architecture.ts b/src/formats/mermaid/architecture.ts new file mode 100644 index 0000000..2d38db2 --- /dev/null +++ b/src/formats/mermaid/architecture.ts @@ -0,0 +1,75 @@ +import type { Graph, GraphNode, GraphEdge, GraphFormatConverter } from '../../types'; +import { getEdgeMode } from '../../mode'; +import { createFormatConverter } from '../converter'; +import { escapeMermaidId, unescapeMermaidId, generateEdgeId, getMermaidPreamble, type MermaidSourceData } from './shared'; +import { getMermaidProfile, getMermaidProfileGraph, getMermaidProfileOrderedNodes, getMermaidProfileText, toMermaidProfileText } from './profile-shared'; + +export interface ArchitectureNodeData { kind: 'group' | 'service' | 'junction'; icon?: string } +export interface ArchitectureEdgeData { arrow?: '--' | '-->' | '<--' | '<-->'; sourceGroup?: boolean; targetGroup?: boolean } +export interface ArchitectureGraphData extends MermaidSourceData { diagramType: 'architecture'; align?: Array<{ direction: 'row' | 'column'; members: string[] }> } +export type MermaidArchitectureGraph = Graph; +const SIDES = ['L', 'R', 'T', 'B']; + +export function fromMermaidArchitecture(input: string): MermaidArchitectureGraph { + const { lines, source } = getMermaidProfile(input, /^architecture-beta$/, 'Architecture'); + const nodes: GraphNode[] = []; const edges: GraphEdge[] = []; + const align: NonNullable = []; + for (const text of lines.slice(1)) { + const line = text.trim(); + const node = line.match(/^(group|service|junction)\s+([A-Za-z0-9_]+)(?:\(([^)]*)\))?(?:\[([^\]]*)\])?(?:\s+in\s+([A-Za-z0-9_]+))?$/); + if (node) { + if (node[4] === '') throw new Error('Mermaid Architecture: native empty labels are unsupported'); + const kind = node[1] as ArchitectureNodeData['kind']; + if (kind === 'junction' && (node[3] !== undefined || node[4] !== undefined)) throw new Error('Mermaid Architecture: junctions cannot have icons/labels'); + nodes.push({ type: 'node', id: unescapeMermaidId(node[2]), parentId: node[5] ? unescapeMermaidId(node[5]) : null, initialNodeId: null, + label: node[4] === undefined ? null : getMermaidProfileText(node[4]), data: { kind, ...(node[3] !== undefined && { icon: node[3] }) }, + ...(kind !== 'group' && { ports: SIDES.map((name) => ({ name, direction: 'inout' as const, data: null })) }) }); + continue; + } + const edge = line.match(/^([A-Za-z0-9_]+)(\{group\})?:([LRTB])\s+(<)?--(>)?\s+([LRTB]):([A-Za-z0-9_]+)(\{group\})?$/); + if (edge) { + const reverse = !!edge[4] && !edge[5]; + const sourceId = unescapeMermaidId(reverse ? edge[7] : edge[1]); const targetId = unescapeMermaidId(reverse ? edge[1] : edge[7]); + const arrow = `${edge[4] ?? ''}--${edge[5] ?? ''}` as ArchitectureEdgeData['arrow']; + edges.push({ type: 'edge', id: generateEdgeId(sourceId, targetId, edges.length), sourceId, targetId, + sourcePort: reverse ? edge[6] : edge[3], targetPort: reverse ? edge[3] : edge[6], label: null, + mode: edge[4] && edge[5] ? 'bidirectional' : edge[4] || edge[5] ? 'directed' : 'undirected', + data: { arrow, ...(edge[2] && { [reverse ? 'targetGroup' : 'sourceGroup']: true }), ...(edge[8] && { [reverse ? 'sourceGroup' : 'targetGroup']: true }) } }); + continue; + } + const alignment = line.match(/^align\s+(row|column)\s+([A-Za-z0-9_ ]+)$/); + if (alignment) { align.push({ direction: alignment[1] as 'row' | 'column', members: alignment[2].trim().split(/\s+/).map(unescapeMermaidId) }); continue; } + throw new Error(`Mermaid Architecture: unsupported statement ${line}`); + } + const graph = getMermaidProfileGraph(nodes, edges, { diagramType: 'architecture' as const, ...(source && { source }), ...(align.length && { align }) }); + const byId = new Map(nodes.map((node) => [node.id, node])); + getMermaidProfileOrderedNodes(nodes); + for (const node of nodes) if (node.parentId != null && byId.get(node.parentId)?.data.kind !== 'group') throw new Error('Mermaid Architecture: parent must be a group'); + for (const edge of edges) if ([edge.sourceId, edge.targetId].some((id) => byId.get(id)?.data.kind === 'group')) throw new Error('Mermaid Architecture: edges require services/junctions'); + for (const item of align) if (item.members.some((id) => !byId.has(id))) throw new Error('Mermaid Architecture: missing alignment member'); + return graph; +} + +export function toMermaidArchitecture(graph: MermaidArchitectureGraph): string { + const lines = [...getMermaidPreamble(graph.data ?? {}), 'architecture-beta']; + for (const node of getMermaidProfileOrderedNodes(graph.nodes)) { + const kind = node.data?.kind ?? 'service'; + if (node.label === '') throw new Error('Mermaid Architecture: native empty labels are unsupported'); + if (!['group', 'service', 'junction'].includes(kind)) throw new Error('Mermaid Architecture: invalid node kind'); + if (node.data?.icon && !/^[A-Za-z0-9_:-]+$/.test(node.data.icon)) throw new Error('Mermaid Architecture: invalid icon'); + if (kind === 'junction' && (node.label != null || node.data?.icon)) throw new Error('Mermaid Architecture: junction labels/icons unsupported'); + lines.push(` ${kind} ${escapeMermaidId(node.id)}${node.data?.icon ? `(${node.data.icon})` : ''}${node.label != null ? `[${toMermaidProfileText(node.label)}]` : ''}${node.parentId != null ? ` in ${escapeMermaidId(node.parentId)}` : ''}`); + } + for (const edge of graph.edges) { + if (edge.label) throw new Error('Mermaid Architecture: native edges have no labels'); + const mode = getEdgeMode(graph, edge); const reverse = mode === 'directed' && edge.data?.arrow === '<--'; + const left = reverse ? edge.targetId : edge.sourceId; const right = reverse ? edge.sourceId : edge.targetId; + const leftSide = (reverse ? edge.targetPort : edge.sourcePort) ?? 'R'; const rightSide = (reverse ? edge.sourcePort : edge.targetPort) ?? 'L'; + if (!SIDES.includes(leftSide) || !SIDES.includes(rightSide)) throw new Error('Mermaid Architecture: ports must be L/R/T/B sides'); + const arrow = mode === 'undirected' ? '--' : mode === 'bidirectional' ? '<-->' : reverse ? '<--' : '-->'; + lines.push(` ${escapeMermaidId(left)}${(reverse ? edge.data?.targetGroup : edge.data?.sourceGroup) ? '{group}' : ''}:${leftSide} ${arrow} ${rightSide}:${escapeMermaidId(right)}${(reverse ? edge.data?.sourceGroup : edge.data?.targetGroup) ? '{group}' : ''}`); + } + for (const item of graph.data?.align ?? []) lines.push(` align ${item.direction} ${item.members.map(escapeMermaidId).join(' ')}`); + const result = lines.join('\n'); fromMermaidArchitecture(result); return result; +} +export const mermaidArchitectureConverter: GraphFormatConverter = createFormatConverter(toMermaidArchitecture, fromMermaidArchitecture); diff --git a/src/formats/mermaid/block.ts b/src/formats/mermaid/block.ts index e941536..9fe7843 100644 --- a/src/formats/mermaid/block.ts +++ b/src/formats/mermaid/block.ts @@ -1,8 +1,12 @@ import type { Graph, GraphNode, GraphEdge, GraphFormatConverter } from '../../types'; import { createFormatConverter } from '../converter'; +import { getEdgeMode } from '../../mode'; import { validateInput, prepareLines, + getMermaidPreamble, + type MermaidSourceData, + getMermaidTokens, escapeMermaidLabel, unescapeMermaidLabel, generateEdgeId, @@ -12,6 +16,7 @@ import { export interface BlockNodeData { span?: number; + columns?: number; } export interface BlockEdgeData { @@ -29,7 +34,7 @@ export interface BlockEdgeData { arrowToken?: string; } -export interface BlockGraphData { +export interface BlockGraphData extends MermaidSourceData { diagramType: 'block'; columns?: number; } @@ -42,6 +47,8 @@ type BlockNode = GraphNode; // Block shape patterns (subset of flowchart) const BLOCK_SHAPES: [string, string, string][] = [ ['((', '))', 'circle'], + ['([', '])', 'stadium'], + ['[(', ')]', 'cylinder'], ['(', ')', 'rounded'], ['{{', '}}', 'hexagon'], ['{', '}', 'diamond'], @@ -49,18 +56,21 @@ const BLOCK_SHAPES: [string, string, string][] = [ ['[', ']', 'rectangle'], ]; -function parseBlockNode(text: string): { id: string; label: string; shape?: string } | null { +function parseBlockNode(text: string): { id: string; label: string; shape?: string; span?: number } | null { + const spanMatch = text.match(/:(\d+)$/); + const span = spanMatch ? Number(spanMatch[1]) : undefined; + if (spanMatch) text = text.slice(0, spanMatch.index); for (const [opener, closer, shapeName] of BLOCK_SHAPES) { const idx = text.indexOf(opener); if (idx < 0) continue; const id = text.slice(0, idx).trim(); - if (!id) continue; + if (!/^[\p{L}\p{N}_-]+$/u.test(id)) continue; if (!text.endsWith(closer)) continue; const label = text.slice(idx + opener.length, text.length - closer.length).trim(); - return { id, label, shape: shapeName }; + return { id, label: unescapeMermaidLabel(label.replace(/^"([\s\S]*)"$/, '$1')), shape: shapeName, ...(span !== undefined && { span }) }; } if (/^[a-zA-Z_][\w]*$/.test(text.trim())) { - return { id: text.trim(), label: '', shape: undefined }; + return { id: text.trim(), label: '', shape: undefined, ...(span !== undefined && { span }) }; } return null; } @@ -78,7 +88,9 @@ function parseBlockNode(text: string): { id: string; label: string; shape?: stri */ export function fromMermaidBlock(input: string): MermaidBlockGraph { validateInput(input, 'Mermaid block'); - const { lines } = prepareLines(input); + const prepared = prepareLines(input); + const { lines } = prepared; + let source = prepared.source; const header = lines[0]?.trim(); if (!header || !header.startsWith('block-beta')) { @@ -112,11 +124,27 @@ export function fromMermaidBlock(input: string): MermaidBlockGraph { for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; + const styleMatch = line.match(/^style\s+(\S+)\s+(.+)$/); + if (styleMatch) { + const style: Record = Object.create(null); + for (const property of styleMatch[2].split(',')) { + const index = property.indexOf(':'); + if (index >= 0) style[property.slice(0, index).trim()] = property.slice(index + 1).trim(); + } + ensureNode(styleMatch[1]).style = style; + continue; + } + if (/^(?:columns\s+(?!\d)|classDef\s|class\s|space(?:\s|:|$))/.test(line)) { + ((source ??= {}).unknownStatements ??= []).push(line); + continue; + } // columns N const colsMatch = line.match(/^columns\s+(\d+)\s*$/); if (colsMatch) { - columns = parseInt(colsMatch[1], 10); + const parentId = parentStack[parentStack.length - 1]; + if (parentId) nodeMap.get(parentId)!.data.columns = Number(colsMatch[1]); + else columns = Number(colsMatch[1]); continue; } @@ -138,7 +166,7 @@ export function fromMermaidBlock(input: string): MermaidBlockGraph { // Edge: A --> B, A --- B, A ==> B, A -.-> B, optionally with a pipe label: // A -->|label| B const edgeMatch = line.match( - /^(\S+)\s*(-->|---|==>|-\.->)\s*(?:\|([^|]*)\|\s*)?(\S+)\s*$/, + /^(\S+)\s*(<-->|-->|---|==>|-\.->)\s*(?:\|([^|]*)\|\s*)?(\S+)\s*$/, ); if (edgeMatch) { const sourceId = edgeMatch[1]; @@ -154,6 +182,7 @@ export function fromMermaidBlock(input: string): MermaidBlockGraph { sourceId, targetId, label: label ? unescapeMermaidLabel(label) : '', + mode: token === '---' ? 'undirected' : token === '<-->' ? 'bidirectional' : 'directed', data: { stroke: token === '==>' ? 'thick' : token === '-.->' ? 'dotted' : 'normal', @@ -177,12 +206,13 @@ export function fromMermaidBlock(input: string): MermaidBlockGraph { // Space-separated node declarations on one line // (block-beta allows `a b c` on one line to declare multiple nodes) - const parts = line.split(/\s+/); + const parts = getMermaidTokens(line, ' ', false); let consumed = false; for (const part of parts) { const parsed = parseBlockNode(part); if (parsed) { ensureNode(parsed.id, parsed.label, parsed.shape); + if (parsed.span !== undefined) nodeMap.get(parsed.id)!.data.span = parsed.span; consumed = true; } } @@ -192,6 +222,9 @@ export function fromMermaidBlock(input: string): MermaidBlockGraph { const nodeDecl = parseBlockNode(line); if (nodeDecl) { ensureNode(nodeDecl.id, nodeDecl.label, nodeDecl.shape); + if (nodeDecl.span !== undefined) nodeMap.get(nodeDecl.id)!.data.span = nodeDecl.span; + } else { + ((source ??= {}).unknownStatements ??= []).push(line); } } @@ -203,6 +236,7 @@ export function fromMermaidBlock(input: string): MermaidBlockGraph { edges, data: { diagramType: 'block', + ...(source && { source }), ...(columns !== undefined && { columns }), }, }; @@ -218,7 +252,7 @@ export function fromMermaidBlock(input: string): MermaidBlockGraph { * // "block-beta\n columns 2\n a[\"Task A\"]\n ..." */ export function toMermaidBlock(graph: MermaidBlockGraph): string { - const lines: string[] = ['block-beta']; + const lines: string[] = [...getMermaidPreamble(graph.data ?? {}), 'block-beta']; if (graph.data?.columns) { lines.push(` columns ${graph.data.columns}`); @@ -237,32 +271,41 @@ export function toMermaidBlock(graph: MermaidBlockGraph): string { if (childrenMap.has(node.id)) isParent.add(node.id); } - function writeNodes(parentId: string | null, indent: string) { - const children = childrenMap.get(parentId) ?? []; - for (const child of children) { - if (isParent.has(child.id)) { - lines.push(`${indent}block:${child.id}`); - writeNodes(child.id, indent + ' '); - lines.push(`${indent}end`); - } else { - const label = child.label && child.label !== child.id - ? `["${escapeMermaidLabel(child.label)}"]` - : ''; - const span = child.data?.span ? `:${child.data.span}` : ''; - lines.push(`${indent}${child.id}${label}${span}`); - } + const stack: Array<{ node?: BlockNode; indent: string }> = (childrenMap.get(null) ?? []).slice().reverse().map((node) => ({ node, indent: ' ' })); + while (stack.length) { + const { node: child, indent } = stack.pop()!; + if (!child) { lines.push(`${indent}end`); continue; } + if (isParent.has(child.id)) { + lines.push(`${indent}block:${child.id}`); + if (child.data?.columns !== undefined) lines.push(`${indent} columns ${child.data.columns}`); + stack.push({ indent }); + for (const node of (childrenMap.get(child.id) ?? []).slice().reverse()) stack.push({ node, indent: indent + ' ' }); + } else { + const shape = BLOCK_SHAPES.find((entry) => entry[2] === child.shape) ?? ['[', ']', 'rectangle']; + const label = child.label && (child.label !== child.id || child.shape) + ? `${shape[0]}"${escapeMermaidLabel(child.label)}"${shape[1]}` : ''; + const span = child.data?.span ? `:${child.data.span}` : ''; + lines.push(`${indent}${child.id}${label}${span}`); } } - writeNodes(null, ' '); + for (const node of graph.nodes) { + if (node.style) lines.push(` style ${node.id} ${Object.entries(node.style).map(([key, value]) => `${key}:${value}`).join(',')}`); + } // Emit edges. Prefer the preserved original token so approximate mappings // round-trip exactly; otherwise derive from stroke/arrowType. for (const edge of graph.edges) { const d = edge.data; let arrow = d?.arrowToken; + const mode = edge.mode ?? (graph.mode !== 'directed' ? getEdgeMode(graph, edge) : d?.arrowToken === '---' || d?.arrowType === 'none' ? 'undirected' + : d?.arrowToken === '<-->' ? 'bidirectional' : getEdgeMode(graph, edge)); + const tokenMode = arrow === '---' ? 'undirected' : arrow === '<-->' ? 'bidirectional' : 'directed'; + if (arrow && tokenMode !== mode) arrow = undefined; if (!arrow) { - if (d?.stroke === 'thick') arrow = '==>'; + if (mode === 'undirected') arrow = '---'; + else if (mode === 'bidirectional') arrow = '<-->'; + else if (d?.stroke === 'thick') arrow = '==>'; else if (d?.stroke === 'dotted') arrow = '-.->'; else arrow = d?.arrowType === 'none' ? '---' : '-->'; } diff --git a/src/formats/mermaid/c4.ts b/src/formats/mermaid/c4.ts new file mode 100644 index 0000000..0566c05 --- /dev/null +++ b/src/formats/mermaid/c4.ts @@ -0,0 +1,100 @@ +import type { Graph, GraphNode, GraphEdge, GraphFormatConverter } from '../../types'; +import { getEdgeMode } from '../../mode'; +import { createFormatConverter } from '../converter'; +import { escapeMermaidId, unescapeMermaidId, generateEdgeId, getMermaidPreamble, getMermaidIdDisplay, getMermaidDisplayText, type MermaidSourceData } from './shared'; +import { getMermaidProfile, getMermaidProfileArguments, getMermaidProfileGraph, getMermaidProfileOrderedNodes, getMermaidProfileText, toMermaidProfileText } from './profile-shared'; +export type C4DiagramType = 'C4Context' | 'C4Container' | 'C4Component' | 'C4Dynamic' | 'C4Deployment'; +export interface C4NodeData { kind: string; description?: string; technology?: string; extraArguments?: string[]; namedArguments?: Array<{ name: string; value: string }> } +export interface C4EdgeData { kind: string; technology?: string; extraArguments?: string[]; namedArguments?: Array<{ name: string; value: string }>; index?: string } +export interface C4GraphData extends MermaidSourceData { diagramType: C4DiagramType; title?: string } +export type MermaidC4Graph = Graph; +const NODE_KINDS = new Set(['Person', 'Person_Ext', 'System', 'System_Ext', 'SystemDb', 'SystemDb_Ext', 'SystemQueue', 'SystemQueue_Ext', 'Container', 'Container_Ext', 'ContainerDb', 'ContainerDb_Ext', 'ContainerQueue', 'ContainerQueue_Ext', 'Component', 'Component_Ext', 'ComponentDb', 'ComponentDb_Ext', 'ComponentQueue', 'ComponentQueue_Ext', 'Boundary', 'Enterprise_Boundary', 'System_Boundary', 'Container_Boundary', 'Deployment_Node', 'Node']); +const EDGE_KINDS = new Set(['Rel', 'BiRel', 'Rel_Back', 'Rel_U', 'Rel_Up', 'Rel_D', 'Rel_Down', 'Rel_L', 'Rel_Left', 'Rel_R', 'Rel_Right', 'RelIndex']); +function getC4Technology(kind: string): boolean { return /^(?:Container|Component|Deployment_Node|Node)/.test(kind) && !kind.endsWith('_Boundary'); } +function getC4Arguments(values: string[]) { + const positional: string[] = []; const named: Array<{ name: string; value: string }> = []; + for (const value of values) { + const pair = value.match(/^(\$[A-Za-z_][\w]*)\s*=\s*(.*)$/); + if (pair) named.push({ name: pair[1], value: getMermaidProfileText(pair[2]) }); + else if (named.length) throw new Error('Mermaid C4: positional argument after named argument'); + else positional.push(getMermaidProfileText(value)); + } + return { positional, named }; +} +function toC4Arguments(values: string[], named: C4NodeData['namedArguments']): string { + const quoted = values.map((value) => `"${toMermaidProfileText(value)}"`); + for (const item of named ?? []) { + if (!/^\$[A-Za-z_]\w*$/.test(item.name)) throw new Error('Mermaid C4: invalid named argument'); + quoted.push(`${item.name}="${toMermaidProfileText(item.value)}"`); + } + return quoted.join(', '); +} +export function fromMermaidC4(input: string): MermaidC4Graph { + const { lines, source } = getMermaidProfile(input, /^C4(?:Context|Container|Component|Dynamic|Deployment)$/, 'C4'); + const nodes: GraphNode[] = []; const edges: GraphEdge[] = []; const stack: string[] = []; + let title: string | undefined; const relationPairs = new Set(); + for (const text of lines.slice(1)) { + const line = text.trim(); + if (line === '}') { if (!stack.length) throw new Error('Mermaid C4: unmatched closing boundary'); stack.pop(); continue; } + if (line.startsWith('title ')) { title = getMermaidDisplayText(line.slice(6)); continue; } + const macro = line.match(/^([A-Za-z_]+)\((.*)\)\s*(\{)?$/); + if (!macro) throw new Error(`Mermaid C4: unsupported statement ${line}`); + const kind = macro[1]; const { positional: args, named } = getC4Arguments(getMermaidProfileArguments(macro[2])); + if (NODE_KINDS.has(kind)) { + if (args.length < 2 || !args[0]) throw new Error('Mermaid C4: node requires alias and label'); + const id = unescapeMermaidId(args[0]); const tech = getC4Technology(kind); + const description = args[tech ? 3 : 2]; const extraArguments = args.slice(tech ? 4 : 3); + nodes.push({ type: 'node', id, label: args[1], parentId: stack.at(-1) ?? null, initialNodeId: null, data: { kind, + ...(tech && args[2] !== undefined && { technology: args[2] }), ...(description !== undefined && { description }), + ...(extraArguments.length && { extraArguments }), ...(named.length && { namedArguments: named }) } }); + if (macro[3]) { if (!kind.endsWith('_Boundary') && !['Boundary', 'Deployment_Node', 'Node'].includes(kind)) throw new Error('Mermaid C4: this node kind cannot contain children'); stack.push(id); } + } else if (EDGE_KINDS.has(kind)) { + if (macro[3]) throw new Error('Mermaid C4: relationship cannot contain children'); + const index = kind === 'RelIndex' ? args.shift() : undefined; + if (args.length < 3) throw new Error('Mermaid C4: relation requires aliases and label'); + const pair = JSON.stringify(args.slice(0, 2)); + if (relationPairs.has(pair)) throw new Error('Mermaid C4: repeated native relationship pair unsupported'); + relationPairs.add(pair); + const sourceId = unescapeMermaidId(args[kind === 'Rel_Back' ? 1 : 0]); const targetId = unescapeMermaidId(args[kind === 'Rel_Back' ? 0 : 1]); + edges.push({ type: 'edge', id: generateEdgeId(sourceId, targetId, edges.length), sourceId, targetId, label: args[2], mode: kind === 'BiRel' ? 'bidirectional' : 'directed', data: { kind, + ...(args[3] !== undefined && { technology: args[3] }), ...(args.length > 4 && { extraArguments: args.slice(4) }), ...(named.length && { namedArguments: named }), ...(index !== undefined && { index }) } }); + } else throw new Error(`Mermaid C4: unsupported macro ${kind}`); + } + if (stack.length) throw new Error('Mermaid C4: unclosed boundary'); + const graph = getMermaidProfileGraph(nodes, edges, { diagramType: lines[0].trim() as C4DiagramType, ...(source && { source }), ...(title !== undefined && { title }) }); + getMermaidProfileOrderedNodes(nodes); return graph; +} +export function toMermaidC4(graph: MermaidC4Graph): string { + const header = graph.data?.diagramType ?? 'C4Context'; + if (!/^C4(?:Context|Container|Component|Dynamic|Deployment)$/.test(header)) throw new Error('Mermaid C4: invalid diagram type'); + const lines = [...getMermaidPreamble(graph.data ?? {}), header]; + if (graph.data?.title !== undefined) { if (/[\r\n]/.test(graph.data.title)) throw new Error('Mermaid C4: multiline title unsupported'); lines.push(`title ${getMermaidDisplayText(graph.data.title)}`); } + const ordered = getMermaidProfileOrderedNodes(graph.nodes); const children = new Set(graph.nodes.flatMap((node) => node.parentId == null ? [] : [node.parentId])); + const stack: string[] = []; + for (const node of ordered) { + while (stack.length && stack.at(-1) !== (node.parentId ?? null)) { lines.push(' }'); stack.pop(); } + const kind = node.data?.kind ?? 'System'; + if (!NODE_KINDS.has(kind) || node.ports?.length) throw new Error('Mermaid C4: invalid node kind'); + const tech = getC4Technology(kind); const fields = [node.label ?? getMermaidIdDisplay(node.id)]; + if (tech && (node.data?.technology !== undefined || node.data?.description !== undefined || node.data?.extraArguments?.length)) fields.push(node.data?.technology ?? ''); + if (node.data?.description !== undefined || node.data?.extraArguments?.length) fields.push(node.data?.description ?? ''); + fields.push(...(node.data?.extraArguments ?? [])); + const suffix = children.has(node.id) || kind.endsWith('_Boundary') || kind === 'Boundary' ? ' {' : ''; + lines.push(` ${kind}(${escapeMermaidId(node.id)}, ${toC4Arguments(fields, node.data?.namedArguments)})${suffix}`); + if (suffix) stack.push(node.id); + } + while (stack.length) { stack.pop(); lines.push(' }'); } + for (const edge of graph.edges) { + const mode = getEdgeMode(graph, edge); const authored = edge.data?.kind ?? 'Rel'; + if (mode === 'undirected' || edge.sourcePort || edge.targetPort) throw new Error('Mermaid C4: undirected relationship unsupported'); + const kind = mode === 'bidirectional' ? 'BiRel' : authored === 'BiRel' ? 'Rel' : authored; + if (!EDGE_KINDS.has(kind)) throw new Error('Mermaid C4: invalid relationship'); + const fields = [edge.label ?? '']; + if (edge.data?.technology !== undefined || edge.data?.extraArguments?.length) fields.push(edge.data?.technology ?? ''); + fields.push(...(edge.data?.extraArguments ?? [])); + const index = kind === 'RelIndex' ? `${toC4Arguments([edge.data?.index ?? '1'], undefined)}, ` : ''; + lines.push(` ${kind}(${index}${escapeMermaidId(kind === 'Rel_Back' ? edge.targetId : edge.sourceId)}, ${escapeMermaidId(kind === 'Rel_Back' ? edge.sourceId : edge.targetId)}, ${toC4Arguments(fields, edge.data?.namedArguments)})`); + } + const result = lines.join('\n'); fromMermaidC4(result); return result; +} +export const mermaidC4Converter: GraphFormatConverter = createFormatConverter(toMermaidC4, fromMermaidC4); diff --git a/src/formats/mermaid/class-diagram.ts b/src/formats/mermaid/class-diagram.ts index 35eb313..3acd4e8 100644 --- a/src/formats/mermaid/class-diagram.ts +++ b/src/formats/mermaid/class-diagram.ts @@ -3,6 +3,10 @@ import { createFormatConverter } from '../converter'; import { validateInput, prepareLines, + getMermaidPreamble, + type MermaidSourceData, + MERMAID_TO_DIRECTION, + DIRECTION_TO_MERMAID, escapeMermaidLabel, unescapeMermaidLabel, generateEdgeId, @@ -11,6 +15,9 @@ import { // --- Types --- export interface ClassNodeData { + namespace?: boolean; + notes?: string[]; + classes?: string[]; members?: Array<{ visibility: '+' | '-' | '#' | '~'; name: string; @@ -36,13 +43,19 @@ export interface ClassEdgeData { | 'dependency' | 'realization' | 'link' - | 'dashed'; + | 'dashed' + | 'lollipop'; sourceCardinality?: string; targetCardinality?: string; + sourceMarker?: string; + targetMarker?: string; + lineStyle?: 'solid' | 'dashed'; } -export interface ClassGraphData { +export interface ClassGraphData extends MermaidSourceData { diagramType: 'classDiagram'; + notes?: string[]; + classDefs?: Record>; } export type MermaidClassGraph = Graph; @@ -53,6 +66,8 @@ type ClassEdge = GraphEdge; // Ordered longest-first for greedy match const RELATIONSHIP_ARROWS: [string, ClassEdgeData['relationType'], boolean][] = [ + ['--()', 'lollipop', false], + ['()--', 'lollipop', true], // [arrow, relationType, reversed] — reversed means target is on the left ['<|--', 'inheritance', true], ['--|>', 'inheritance', false], @@ -77,7 +92,15 @@ function parseRelationship(line: string): { label: string; leftCard?: string; rightCard?: string; + sourceMarker?: string; + targetMarker?: string; + lineStyle?: 'solid' | 'dashed'; } | null { + const twoSided = line.match(/^(.+?)\s*(<\||\*|o|<)(--|\.\.)(\|>|\*|o|>)\s*(.+)$/); + if (twoSided) { + const relationship = parseRelationship(`${twoSided[1]} ${twoSided[3]} ${twoSided[5]}`); + if (relationship) return { ...relationship, sourceMarker: twoSided[2], targetMarker: twoSided[4], lineStyle: twoSided[3] === '--' ? 'solid' : 'dashed' }; + } for (const [arrow, relationType, reversed] of RELATIONSHIP_ARROWS) { const idx = line.indexOf(arrow); if (idx < 0) continue; @@ -99,24 +122,24 @@ function parseRelationship(line: string): { const leftCardMatch = left.match(/^"([^"]+)"\s+(.+)$/); if (leftCardMatch) { - leftCard = leftCardMatch[1]; + leftCard = unescapeMermaidLabel(leftCardMatch[1]); left = leftCardMatch[2].trim(); } else { const leftCardTrail = left.match(/^(.+?)\s+"([^"]+)"$/); if (leftCardTrail) { - leftCard = leftCardTrail[2]; + leftCard = unescapeMermaidLabel(leftCardTrail[2]); left = leftCardTrail[1].trim(); } } const rightCardMatch = right.match(/^"([^"]+)"\s+(.+)$/); if (rightCardMatch) { - rightCard = rightCardMatch[1]; + rightCard = unescapeMermaidLabel(rightCardMatch[1]); right = rightCardMatch[2].trim(); } else { const rightCardTrail = right.match(/^(.+?)\s+"([^"]+)"$/); if (rightCardTrail) { - rightCard = rightCardTrail[2]; + rightCard = unescapeMermaidLabel(rightCardTrail[2]); right = rightCardTrail[1].trim(); } } @@ -205,7 +228,9 @@ function parseMember(line: string): { */ export function fromMermaidClass(input: string): MermaidClassGraph { validateInput(input, 'Mermaid class'); - const { lines } = prepareLines(input); + const prepared = prepareLines(input); + const { lines } = prepared; + let source = prepared.source; const header = lines[0]?.trim(); if (!header || !header.startsWith('classDiagram')) { @@ -215,13 +240,17 @@ export function fromMermaidClass(input: string): MermaidClassGraph { const nodeMap = new Map(); const edges: ClassEdge[] = []; let edgeCounter = 0; + const namespaceStack: string[] = []; + const notes: string[] = []; + const classDefs: Record> = Object.create(null); + let direction: MermaidClassGraph['direction']; function ensureNode(id: string): ClassNode { if (!nodeMap.has(id)) { nodeMap.set(id, { type: 'node', id, - parentId: null, + parentId: namespaceStack[namespaceStack.length - 1] ?? null, initialNodeId: null, label: id, data: {}, @@ -238,6 +267,56 @@ export function fromMermaidClass(input: string): MermaidClassGraph { const line = lines[i].trim(); if (!line) continue; + if (!inClassBlock) { + const namespaceMatch = line.match(/^namespace\s+([^\s\[{}]+)(?:\["?([^\]"]*)"?\])?\s*\{$/); + if (namespaceMatch) { + const rawId = namespaceMatch[1]; + const parentId = namespaceStack[namespaceStack.length - 1]; + const namespaceId = parentId && !rawId.startsWith(parentId + '.') ? `${parentId}.${rawId}` : rawId; + const segments = namespaceId.split('.'); + for (let count = 1; count <= segments.length; count++) { + const id = segments.slice(0, count).join('.'); + const node = ensureNode(id); + node.parentId = count > 1 ? segments.slice(0, count - 1).join('.') : null; + node.data.namespace = true; + } + if (namespaceMatch[2]) nodeMap.get(namespaceId)!.label = unescapeMermaidLabel(namespaceMatch[2]); + namespaceStack.push(namespaceId); + continue; + } + if (line === '}' && namespaceStack.length) { namespaceStack.pop(); continue; } + const directionMatch = line.match(/^direction\s+(TB|TD|BT|LR|RL)$/); + if (directionMatch) { direction = MERMAID_TO_DIRECTION[directionMatch[1]]; continue; } + const noteMatch = line.match(/^note(?:\s+for\s+(\S+))?\s+"([\s\S]*)"$/); + if (noteMatch) { + const text = unescapeMermaidLabel(noteMatch[2]); + if (noteMatch[1]) (ensureNode(noteMatch[1]).data.notes ??= []).push(text); + else notes.push(text); + continue; + } + const styleMatch = line.match(/^(style|classDef)\s+(\S+)\s+(.+)$/); + if (styleMatch) { + const properties: Record = Object.create(null); + for (const entry of styleMatch[3].split(',')) { + const separator = entry.indexOf(':'); + if (separator >= 0) properties[entry.slice(0, separator).trim()] = entry.slice(separator + 1).trim().replace(/;$/, ''); + } + if (styleMatch[1] === 'style') ensureNode(styleMatch[2]).style = properties; + else classDefs[styleMatch[2]] = properties; + continue; + } + const cssMatch = line.match(/^cssClass\s+"([^"]+)"\s+(\S+?);?$/); + if (cssMatch) { + for (const id of cssMatch[1].split(',')) (ensureNode(id.trim()).data.classes ??= []).push(cssMatch[2]); + continue; + } + const aliasMatch = line.match(/^class\s+([^\s\[]+)\["?([\s\S]*?)"?\]$/); + if (aliasMatch) { + ensureNode(aliasMatch[1]).label = unescapeMermaidLabel(aliasMatch[2]); + continue; + } + } + // Class block: class ClassName { const classBlockMatch = line.match(/^class\s+(\S+?)(?:~(.+?)~)?\s*\{\s*$/); if (classBlockMatch) { @@ -326,14 +405,17 @@ export function fromMermaidClass(input: string): MermaidClassGraph { sourceId: rel.leftClass, targetId: rel.rightClass, label: rel.label ? unescapeMermaidLabel(rel.label) : '', + mode: rel.sourceMarker && rel.targetMarker ? 'bidirectional' : ['link', 'dashed'].includes(rel.relationType) ? 'undirected' : 'directed', data: { relationType: rel.relationType, ...(rel.leftCard && { sourceCardinality: rel.leftCard }), ...(rel.rightCard && { targetCardinality: rel.rightCard }), + ...(rel.sourceMarker && { sourceMarker: rel.sourceMarker, targetMarker: rel.targetMarker, lineStyle: rel.lineStyle }), }, }); continue; } + ((source ??= {}).unknownStatements ??= []).push(line); } return { @@ -342,7 +424,8 @@ export function fromMermaidClass(input: string): MermaidClassGraph { initialNodeId: null, nodes: Array.from(nodeMap.values()), edges, - data: { diagramType: 'classDiagram' }, + data: { diagramType: 'classDiagram', ...(source && { source }), ...(notes.length && { notes }), ...(Object.keys(classDefs).length && { classDefs }) }, + ...(direction && { direction }), }; } @@ -357,6 +440,7 @@ const RELATION_TO_ARROW: Record = { realization: '..|>', link: '--', dashed: '..', + lollipop: '--()', }; const VISIBILITY_SYMBOLS: Record = { @@ -374,48 +458,67 @@ const VISIBILITY_SYMBOLS: Record = { * // "classDiagram\n class Animal {\n ..." */ export function toMermaidClass(graph: MermaidClassGraph): string { - const lines: string[] = ['classDiagram']; + const lines: string[] = [...getMermaidPreamble(graph.data ?? {}), 'classDiagram']; - // Emit classes with members + if (graph.direction) lines.push(` direction ${DIRECTION_TO_MERMAID[graph.direction]}`); + const children = new Map(); for (const node of graph.nodes) { - if (node.data?.members && node.data.members.length > 0) { - const generic = node.data.genericType ? `~${node.data.genericType}~` : ''; - lines.push(` class ${node.id}${generic} {`); - if (node.data.annotation) { - lines.push(` <<${node.data.annotation}>>`); - } - for (const m of node.data.members) { - const vis = VISIBILITY_SYMBOLS[m.visibility] ?? '+'; - const typeStr = m.type ? (m.isMethod ? ` ${m.type}` : `${m.type} `) : ''; - if (m.isMethod) { - lines.push(` ${vis}${m.name}${typeStr}`); - } else { - lines.push(` ${vis}${typeStr}${m.name}`); - } + const parent = node.parentId ?? null; + const list = children.get(parent) ?? []; + list.push(node); + children.set(parent, list); + } + const stack: Array<{ node?: typeof graph.nodes[0]; indent: string }> = (children.get(null) ?? []).slice().reverse().map((node) => ({ node, indent: ' ' })); + while (stack.length) { + const item = stack.pop()!; + if (!item.node) { lines.push(`${item.indent}}`); continue; } + const node = item.node; + const nodeIndent = item.indent; + if (node.data?.namespace) { + const label = node.label && node.label !== node.id ? `["${escapeMermaidLabel(node.label)}"]` : ''; + lines.push(`${nodeIndent}namespace ${node.id}${label} {`); + stack.push({ indent: nodeIndent }); + for (const child of (children.get(node.id) ?? []).slice().reverse()) stack.push({ node: child, indent: nodeIndent + ' ' }); + continue; + } + if (node.label && node.label !== node.id) lines.push(`${nodeIndent}class ${node.id}["${escapeMermaidLabel(node.label)}"]`); + const generic = node.data?.genericType ? `~${node.data.genericType}~` : ''; + if (node.data?.members?.length) { + lines.push(`${nodeIndent}class ${node.id}${generic} {`); + if (node.data.annotation) lines.push(`${nodeIndent} <<${node.data.annotation}>>`); + for (const member of node.data.members) { + const visibility = VISIBILITY_SYMBOLS[member.visibility] ?? '+'; + const type = member.type ? (member.isMethod ? ` ${member.type}` : `${member.type} `) : ''; + lines.push(member.isMethod + ? `${nodeIndent} ${visibility}${member.name}${type}` + : `${nodeIndent} ${visibility}${type}${member.name}`); } - lines.push(` }`); + lines.push(`${nodeIndent}}`); } else { - // Bare class declaration - const generic = node.data?.genericType ? `~${node.data.genericType}~` : ''; - lines.push(` class ${node.id}${generic}`); - if (node.data?.annotation) { - lines.push(` <<${node.data.annotation}>> ${node.id}`); - } + lines.push(`${nodeIndent}class ${node.id}${generic}`); + if (node.data?.annotation) lines.push(`${nodeIndent}<<${node.data.annotation}>> ${node.id}`); } + for (const note of node.data?.notes ?? []) lines.push(`${nodeIndent}note for ${node.id} "${escapeMermaidLabel(note)}"`); + if (node.style) lines.push(`${nodeIndent}style ${node.id} ${Object.entries(node.style).map(([key, value]) => `${key}:${value}`).join(',')}`); + for (const name of node.data?.classes ?? []) lines.push(`${nodeIndent}cssClass "${node.id}" ${name}`); } + for (const note of graph.data?.notes ?? []) lines.push(` note "${escapeMermaidLabel(note)}"`); + for (const [name, properties] of Object.entries(graph.data?.classDefs ?? {})) lines.push(` classDef ${name} ${Object.entries(properties).map(([key, value]) => `${key}:${value}`).join(',')}`); // Emit relationships for (const edge of graph.edges) { - const arrow = RELATION_TO_ARROW[edge.data?.relationType ?? 'association'] ?? '-->'; + const arrow = edge.data?.sourceMarker && edge.data.targetMarker + ? `${edge.data.sourceMarker}${edge.data.lineStyle === 'dashed' ? '..' : '--'}${edge.data.targetMarker}` + : RELATION_TO_ARROW[edge.data?.relationType ?? 'association'] ?? '-->'; const srcCard = edge.data?.sourceCardinality - ? `"${edge.data.sourceCardinality}" ` + ? ` "${escapeMermaidLabel(edge.data.sourceCardinality)}"` : ''; const tgtCard = edge.data?.targetCardinality - ? ` "${edge.data.targetCardinality}"` + ? ` "${escapeMermaidLabel(edge.data.targetCardinality)}"` : ''; const label = edge.label ? ` : ${escapeMermaidLabel(edge.label)}` : ''; lines.push( - ` ${srcCard}${edge.sourceId} ${arrow} ${edge.targetId}${tgtCard}${label}`, + ` ${edge.sourceId}${srcCard} ${arrow}${tgtCard} ${edge.targetId}${label}`, ); } diff --git a/src/formats/mermaid/document.ts b/src/formats/mermaid/document.ts new file mode 100644 index 0000000..8269d19 --- /dev/null +++ b/src/formats/mermaid/document.ts @@ -0,0 +1,178 @@ +import { createGraphDocumentConverter } from '../document'; +import { getGraphDocumentTextLines, getGraphDocumentTextPatch, type GraphDocumentTextSpan } from '../document/text'; +import { fromMermaidFlowchart, toMermaidFlowchart } from './flowchart'; +import { fromMermaidState, toMermaidState } from './state'; +import { fromMermaidSequence, toMermaidSequence } from './sequence'; +import { fromMermaidClass, toMermaidClass } from './class-diagram'; +import { fromMermaidER, toMermaidER } from './er-diagram'; +import { fromMermaidBlock, toMermaidBlock } from './block'; +import { fromMermaidMindmap, toMermaidMindmap } from './mindmap'; +import { fromMermaidIshikawa, toMermaidIshikawa } from './ishikawa'; +import { escapeMermaidLabel } from './shared'; + +export const mermaidFlowchartDocumentConverter = createGraphDocumentConverter({ + format: 'mermaid/flowchart', codec: { from: fromMermaidFlowchart, to: toMermaidFlowchart }, + project(source) { + const graph = fromMermaidFlowchart(source); + // Expanded syntax repeats the native label; expose one editable label field. + for (const node of graph.nodes) if (node.data.properties) delete node.data.properties.label; + return { graph }; + }, + patch(context) { + const spans: GraphDocumentTextSpan[] = []; + const nodesById = new Map(context.sourceGraph.nodes.map((node) => [node.id, node])); + for (const line of getGraphDocumentTextLines(context.source, '%%')) { + const match = line.text.match(/^([A-Za-z_][\w-]*)\s*(\[|\{|\()(.*)(\]|\}|\))$/); + if (!match || ({ '[': ']', '{': '}', '(': ')' } as Record)[match[2]] !== match[4]) continue; + const probe = fromMermaidFlowchart(`flowchart TD\n${line.text}`); + const node = nodesById.get(match[1]); + if (probe.nodes.length !== 1 || !node || probe.nodes[0].label !== node.label) continue; + const relative = line.text.indexOf(match[2]) + 1; + spans.push({ entity: 'node', id: node.id, start: line.start + relative, end: line.start + line.text.length - 1, encode: (value) => `"${escapeMermaidLabel(value)}"` }); + } + return getGraphDocumentTextPatch(context, spans); + }, +}); + +export const mermaidStateDocumentConverter = createGraphDocumentConverter({ + format: 'mermaid/state', codec: { from: fromMermaidState, to: toMermaidState }, + project(source) { + const graph = fromMermaidState(source); + for (const node of graph.nodes) delete node.data.description; + return { graph }; + }, + patch(context) { + const spans: GraphDocumentTextSpan[] = []; + const nodesById = new Map(context.sourceGraph.nodes.map((node) => [node.id, node])); + for (const line of getGraphDocumentTextLines(context.source, '%%')) { + const match = line.text.match(/^state\s+"([^"\n]*)"\s+as\s+([A-Za-z_][\w-]*)(?:\s*\{|\s+<<(?:choice|fork|join)>>)?$/); + if (!match) continue; + const node = nodesById.get(match[2]); + const probe = fromMermaidState(`stateDiagram-v2\n${line.text}${line.text.endsWith('{') ? '\n}' : ''}`); + if (!node || probe.nodes[0]?.label !== node.label) continue; + const relative = line.text.indexOf('"') + 1; + spans.push({ entity: 'node', id: node.id, start: line.start + relative, end: line.start + relative + match[1].length, encode: escapeMermaidLabel }); + } + return getGraphDocumentTextPatch(context, spans); + }, +}); + +export const mermaidSequenceDocumentConverter = createGraphDocumentConverter({ + format: 'mermaid/sequence', codec: { from: fromMermaidSequence, to: toMermaidSequence }, + project(source) { + const graph = fromMermaidSequence(source); + for (const node of graph.nodes) { + delete node.data.alias; + if (node.data.configuration) delete node.data.configuration.alias; + } + return { graph }; + }, + patch(context) { + const spans: GraphDocumentTextSpan[] = []; + const nodesById = new Map(context.sourceGraph.nodes.map((node) => [node.id, node])); + const edgesByTuple = new Map(); + for (const edge of context.sourceGraph.edges) { + if (edge.data.kind !== 'message') continue; + const key = JSON.stringify([edge.sourceId, edge.targetId, edge.label]); + const entries = edgesByTuple.get(key) ?? []; + entries.push(edge); edgesByTuple.set(key, entries); + } + for (const line of getGraphDocumentTextLines(context.source, '%%')) { + const participant = line.text.match(/^(?:participant|actor)\s+([A-Za-z_][\w-]*)\s+as\s+(.+)$/); + if (participant) { + const node = nodesById.get(participant[1]); + const probe = fromMermaidSequence(`sequenceDiagram\n${line.text}`); + if (node && probe.nodes[0]?.label === node.label) { + const relative = line.text.lastIndexOf(participant[2]); + spans.push({ entity: 'node', id: node.id, start: line.start + relative, end: line.start + line.text.length, encode: escapeMermaidLabel }); + } + continue; + } + const message = line.text.match(/^([A-Za-z_][\w-]*)(?:<<-->>|<<->>|-->>|->>|-->|->|--x|-x|--\)|-\))([A-Za-z_][\w-]*)\s*:\s*(.*)$/); + if (!message) continue; + const probe = fromMermaidSequence(`sequenceDiagram\n${line.text}`); + const candidates = edgesByTuple.get(JSON.stringify([message[1], message[2], probe.edges[0]?.label])) ?? []; + if (candidates.length !== 1) continue; + const relative = line.text.indexOf(':') + 1; + const whitespace = line.text.slice(relative).match(/^\s*/)![0].length; + spans.push({ entity: 'edge', id: candidates[0].id, start: line.start + relative + whitespace, end: line.start + line.text.length, encode: escapeMermaidLabel }); + } + return getGraphDocumentTextPatch(context, spans); + }, +}); + +// All source is retained even when the ordinary graph projection is partial. +// These formats currently have no verified Graph-to-source patch policy. +export const mermaidClassDocumentConverter = createGraphDocumentConverter({ format: 'mermaid/class', codec: { from: fromMermaidClass, to: toMermaidClass } }); +export const mermaidERDocumentConverter = createGraphDocumentConverter({ format: 'mermaid/er', codec: { from: fromMermaidER, to: toMermaidER } }); +export const mermaidBlockDocumentConverter = createGraphDocumentConverter({ format: 'mermaid/block', codec: { from: fromMermaidBlock, to: toMermaidBlock } }); +export const mermaidMindmapDocumentConverter = createGraphDocumentConverter({ format: 'mermaid/mindmap', codec: { from: fromMermaidMindmap, to: toMermaidMindmap } }); +export const mermaidIshikawaDocumentConverter = createGraphDocumentConverter({ format: 'mermaid/ishikawa', codec: { from: fromMermaidIshikawa, to: toMermaidIshikawa } }); + +import { fromMermaidArchitecture, toMermaidArchitecture } from './architecture'; +import { fromMermaidRequirement, toMermaidRequirement } from './requirement'; +import { fromMermaidSankey, toMermaidSankey } from './sankey'; +import { fromMermaidGitGraph, toMermaidGitGraph } from './git-graph'; +import { fromMermaidC4, toMermaidC4 } from './c4'; +import { fromMermaidUseCase, toMermaidUseCase } from './use-case'; +import { unescapeMermaidId } from './shared'; +import { getMermaidProfileText, toMermaidProfileText } from './profile-shared'; + +export const mermaidArchitectureDocumentConverter = createGraphDocumentConverter({ + format: 'mermaid/architecture', codec: { from: fromMermaidArchitecture, to: toMermaidArchitecture }, + patch(context) { + const spans: GraphDocumentTextSpan[] = []; + for (const line of getGraphDocumentTextLines(context.source, '%%')) { + const match = line.text.match(/^(?:group|service)\s+([A-Za-z0-9_]+)(?:\([^)]*\))?\[([^\]]*)\]/); + if (!match) continue; + const start = line.text.indexOf('[') + 1; + spans.push({ entity: 'node', id: unescapeMermaidId(match[1]), start: line.start + start, end: line.start + start + match[2].length, encode: toMermaidProfileText }); + } + return getGraphDocumentTextPatch(context, spans); + }, +}); +export const mermaidRequirementDocumentConverter = createGraphDocumentConverter({ + format: 'mermaid/requirement', codec: { from: fromMermaidRequirement, to: toMermaidRequirement }, + patch(context) { + const spans: GraphDocumentTextSpan[] = []; let id: string | undefined; + for (const line of getGraphDocumentTextLines(context.source, '%%')) { + const declaration = line.text.match(/^(?:requirement|functionalRequirement|interfaceRequirement|performanceRequirement|physicalRequirement|designConstraint)\s+("[^"\n]*"|[^\s{}]+)\s*\{$/); + if (declaration) { id = getMermaidProfileText(declaration[1]); continue; } + if (line.text === '}') { id = undefined; continue; } + const field = line.text.match(/^text\s*:\s*(.*)$/); + if (id !== undefined && field) { + spans.push({ entity: 'node', id, start: line.start, end: line.start + line.text.length, encode: (value) => value ? `text: "${toMermaidProfileText(value)}"` : '' }); + } + } + return getGraphDocumentTextPatch(context, spans); + }, +}); +export const mermaidC4DocumentConverter = createGraphDocumentConverter({ + format: 'mermaid/c4', codec: { from: fromMermaidC4, to: toMermaidC4 }, + patch(context) { + const spans: GraphDocumentTextSpan[] = []; + const ids = new Set(context.sourceGraph.nodes.map((node) => node.id)); + for (const line of getGraphDocumentTextLines(context.source, '%%')) { + const match = line.text.match(/^([A-Za-z_]+)\(\s*([A-Za-z0-9_]+)\s*,\s*("[^"\n]*"|[^,)]*)/); + if (!match || match[1].startsWith('Rel') || !ids.has(unescapeMermaidId(match[2]))) continue; + const start = match[0].length - match[3].length; + spans.push({ entity: 'node', id: unescapeMermaidId(match[2]), start: line.start + start, end: line.start + start + match[3].length, encode: (value) => `"${toMermaidProfileText(value)}"` }); + } + return getGraphDocumentTextPatch(context, spans); + }, +}); +export const mermaidUseCaseDocumentConverter = createGraphDocumentConverter({ + format: 'mermaid/usecase', codec: { from: fromMermaidUseCase, to: toMermaidUseCase }, + patch(context) { + const spans: GraphDocumentTextSpan[] = []; + for (const line of getGraphDocumentTextLines(context.source, '%%')) { + const match = line.text.match(/^(?:actor\s+|systemBoundary\s+)?([A-Za-z0-9_]+)(?:\(("[^"\n]*"|[^()]*)\)|\[("[^"\n]*"|[^\[\]]*)\])/); + if (!match) continue; + const text = match[2] ?? match[3]; const start = match[0].length - text.length - 1; + spans.push({ entity: 'node', id: unescapeMermaidId(match[1]), start: line.start + start, end: line.start + start + text.length, encode: (value) => `"${toMermaidProfileText(value)}"` }); + } + return getGraphDocumentTextPatch(context, spans); + }, +}); +export const mermaidSankeyDocumentConverter = createGraphDocumentConverter({ format: 'mermaid/sankey', codec: { from: fromMermaidSankey, to: toMermaidSankey } }); +export const mermaidGitGraphDocumentConverter = createGraphDocumentConverter({ format: 'mermaid/gitgraph', codec: { from: fromMermaidGitGraph, to: toMermaidGitGraph } }); diff --git a/src/formats/mermaid/er-diagram.ts b/src/formats/mermaid/er-diagram.ts index f7ed284..8dc8c4f 100644 --- a/src/formats/mermaid/er-diagram.ts +++ b/src/formats/mermaid/er-diagram.ts @@ -3,6 +3,10 @@ import { createFormatConverter } from '../converter'; import { validateInput, prepareLines, + getMermaidPreamble, + type MermaidSourceData, + MERMAID_TO_DIRECTION, + DIRECTION_TO_MERMAID, escapeMermaidLabel, unescapeMermaidLabel, generateEdgeId, @@ -15,6 +19,7 @@ export interface ERNodeData { type: string; name: string; key?: 'PK' | 'FK' | 'UK'; + keys?: Array<'PK' | 'FK' | 'UK'>; comment?: string; }>; } @@ -25,7 +30,7 @@ export interface EREdgeData { identifying: boolean; } -export interface ERGraphData { +export interface ERGraphData extends MermaidSourceData { diagramType: 'erDiagram'; } @@ -110,7 +115,7 @@ const ER_LINE_RE = */ export function fromMermaidER(input: string): MermaidERGraph { validateInput(input, 'Mermaid ER'); - const { lines } = prepareLines(input); + const { lines, source } = prepareLines(input); const header = lines[0]?.trim(); if (!header || !header.startsWith('erDiagram')) { @@ -120,6 +125,7 @@ export function fromMermaidER(input: string): MermaidERGraph { const nodeMap = new Map(); const edges: EREdge[] = []; let edgeCounter = 0; + let direction: MermaidERGraph['direction']; function ensureNode(id: string): ERNode { if (!nodeMap.has(id)) { @@ -141,12 +147,15 @@ export function fromMermaidER(input: string): MermaidERGraph { for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; + const directionMatch = line.match(/^direction\s+(TB|TD|BT|LR|RL)$/); + if (directionMatch) { direction = MERMAID_TO_DIRECTION[directionMatch[1]]; continue; } // Entity attribute block: ENTITY { - const blockMatch = line.match(/^(\S+)\s*\{\s*$/); + const blockMatch = line.match(/^(\S+?)(?:\["?([^\]"]*)"?\])?\s*\{\s*$/); if (blockMatch) { currentEntity = blockMatch[1]; ensureNode(currentEntity); + if (blockMatch[2] !== undefined) nodeMap.get(currentEntity)!.label = unescapeMermaidLabel(blockMatch[2]); inBlock = true; continue; } @@ -160,7 +169,7 @@ export function fromMermaidER(input: string): MermaidERGraph { // Attribute: type name [PK|FK|UK] ["comment"] const attrMatch = line.match( - /^(\S+)\s+(\S+)(?:\s+(PK|FK|UK))?(?:\s+"([^"]*)")?$/, + /^(\S+)\s+(\S+)(?:\s+((?:PK|FK|UK)(?:\s*,\s*(?:PK|FK|UK))*))?(?:\s+"([^"]*)")?$/, ); if (attrMatch) { const node = nodeMap.get(currentEntity)!; @@ -171,8 +180,9 @@ export function fromMermaidER(input: string): MermaidERGraph { node.data.attributes.push({ type: attrMatch[1], name: attrMatch[2], - ...(attrMatch[3] && { key: attrMatch[3] as 'PK' | 'FK' | 'UK' }), - ...(attrMatch[4] && { comment: attrMatch[4] }), + ...(attrMatch[3] && { key: attrMatch[3].split(',')[0].trim() as 'PK' | 'FK' | 'UK' }), + ...(attrMatch[3]?.includes(',') && { keys: attrMatch[3].split(',').map((key) => key.trim() as 'PK' | 'FK' | 'UK') }), + ...(attrMatch[4] && { comment: unescapeMermaidLabel(attrMatch[4]) }), }); } continue; @@ -205,6 +215,8 @@ export function fromMermaidER(input: string): MermaidERGraph { } // Bare entity declaration + const aliasMatch = line.match(/^([^\s\[]+)\["?([^\]"]*)"?\]$/); + if (aliasMatch) { ensureNode(aliasMatch[1]).label = unescapeMermaidLabel(aliasMatch[2]); continue; } if (/^[A-Z_][\w]*$/i.test(line)) { ensureNode(line); } @@ -216,7 +228,8 @@ export function fromMermaidER(input: string): MermaidERGraph { initialNodeId: null, nodes: Array.from(nodeMap.values()), edges, - data: { diagramType: 'erDiagram' }, + data: { diagramType: 'erDiagram', ...(source && { source }) }, + ...(direction && { direction }), }; } @@ -230,18 +243,22 @@ export function fromMermaidER(input: string): MermaidERGraph { * // "erDiagram\n CUSTOMER ||--o{ ORDER : \"places\"\n ..." */ export function toMermaidER(graph: MermaidERGraph): string { - const lines: string[] = ['erDiagram']; + const lines: string[] = [...getMermaidPreamble(graph.data ?? {}), 'erDiagram']; + if (graph.direction) lines.push(` direction ${DIRECTION_TO_MERMAID[graph.direction]}`); // Emit entities with attributes for (const node of graph.nodes) { + const declaration = node.label && node.label !== node.id ? `${node.id}["${escapeMermaidLabel(node.label)}"]` : node.id; if (node.data?.attributes && node.data.attributes.length > 0) { - lines.push(` ${node.id} {`); + lines.push(` ${declaration} {`); for (const attr of node.data.attributes) { - const keyStr = attr.key ? ` ${attr.key}` : ''; + const keyStr = attr.keys?.length ? ` ${attr.keys.join(', ')}` : attr.key ? ` ${attr.key}` : ''; const commentStr = attr.comment ? ` "${escapeMermaidLabel(attr.comment)}"` : ''; lines.push(` ${attr.type} ${attr.name}${keyStr}${commentStr}`); } lines.push(` }`); + } else { + lines.push(` ${declaration}`); } } diff --git a/src/formats/mermaid/flowchart.ts b/src/formats/mermaid/flowchart.ts index 9de4704..a2fb851 100644 --- a/src/formats/mermaid/flowchart.ts +++ b/src/formats/mermaid/flowchart.ts @@ -1,9 +1,17 @@ import type { Graph, GraphNode, GraphEdge, GraphFormatConverter } from '../../types'; import { createFormatConverter } from '../converter'; +import { getEdgeMode } from '../../mode'; import { validateInput, prepareLines, + getMermaidPreamble, + type MermaidSourceData, + getMermaidProperties, + getMermaidStyle, + isMermaidEscaped, + toMermaidProperties, escapeMermaidLabel, + getMermaidIdDisplay, unescapeMermaidLabel, escapeMermaidId, unescapeMermaidId, @@ -33,6 +41,7 @@ export interface FlowchartClick { } export interface FlowchartNodeData { + properties?: Record; classes?: string[]; /** @deprecated Legacy hyperlink field. New parses populate `click` instead. */ link?: string; @@ -43,6 +52,9 @@ export interface FlowchartNodeData { } export interface FlowchartEdgeData { + properties?: Record; + explicitId?: boolean; + arrowToken?: string; stroke: 'normal' | 'dotted' | 'thick' | 'invisible'; arrowType: 'arrow' | 'none'; endMarker?: 'arrow' | 'circle' | 'cross'; @@ -57,7 +69,7 @@ export interface FlowchartEdgeData { linkStyle?: Record; } -export interface FlowchartGraphData { +export interface FlowchartGraphData extends MermaidSourceData { diagramType: 'flowchart'; classDefs?: Record>; /** Default `linkStyle default ...` applied to all edges, preserved verbatim. */ @@ -102,8 +114,9 @@ for (const [opener, closer, name] of SHAPE_OPENERS) { function parseNodeDecl(text: string): { id: string; - label: string; - shape: string; + label?: string; + shape?: string; + properties?: Record; className?: string; } | null { // Strip :::className suffix @@ -115,21 +128,13 @@ function parseNodeDecl(text: string): { } // Try @{ shape: ..., label: ... } expanded syntax - const atMatch = text.match(/^([a-zA-Z_][\w]*)@\{(.+)\}$/s); + const atMatch = text.match(/^([^\s@]+)@\{(.+)\}$/s); if (atMatch) { const id = atMatch[1]; const body = atMatch[2].trim(); - let shape = 'rectangle'; - let label = ''; - // Parse key: value pairs (simple, no nested objects) - for (const line of body.split(',')) { - const kv = line.match(/^\s*(\w+)\s*:\s*"?([^"]*)"?\s*$/); - if (kv) { - if (kv[1] === 'shape') shape = kv[2].trim(); - else if (kv[1] === 'label') label = kv[2].trim(); - } - } - return { id, label: getDecodedNodeLabel(label), shape, ...(className && { className }) }; + const properties = getMermaidProperties(body); + return { id, ...(properties.label !== undefined && { label: String(properties.label) }), + ...(properties.shape !== undefined && { shape: String(properties.shape) }), properties, ...(className && { className }) }; } // Try each shape pattern @@ -145,8 +150,8 @@ function parseNodeDecl(text: string): { } // Bare node ID (no brackets). Accept entity-encoded ids (`#NN;`) from // pre-release converter versions too. - if (/^[\w#;]+$/.test(text)) { - return { id: text, label: '', shape: 'rectangle', ...(className && { className }) }; + if (/^[\p{L}\p{N}_#;.-]+$/u.test(text)) { + return { id: text, ...(className && { className }) }; } return null; } @@ -166,7 +171,7 @@ function getTopLevelParts(input: string, separator: string): string[] { let quoted = false; for (let i = 0; i < input.length; i++) { const char = input[i]; - if (char === '"' && input[i - 1] !== '\\') quoted = !quoted; + if (char === '"' && !isMermaidEscaped(input, i)) quoted = !quoted; if (!quoted) { if ('[({'.includes(char)) depth++; if ('])}'.includes(char)) depth = Math.max(0, depth - 1); @@ -190,20 +195,13 @@ function getTopLevelArrows(line: string): { start: number; end: number; arrow: s let quoted = false; for (let i = 0; i < line.length; i++) { const char = line[i]; - if (char === '"' && line[i - 1] !== '\\') quoted = !quoted; + if (char === '"' && !isMermaidEscaped(line, i)) quoted = !quoted; if (quoted) continue; if ('[({'.includes(char)) depth++; if ('])}'.includes(char)) depth = Math.max(0, depth - 1); if (depth !== 0) continue; - let arrow = ''; - for (let length = Math.min(5, line.length - i); length >= 2; length--) { - const candidate = line.slice(i, i + length); - if (EDGE_ARROWS.some(([pattern]) => pattern.test(candidate))) { - arrow = candidate; - break; - } - } + const arrow = line.slice(i).match(/^(?:<|o|x)?(?:-{2,}[>ox]?|-\.+-[>ox]?|={2,}[>ox]?|~{3,})/)?.[0] ?? ''; if (!arrow) continue; let end = i + arrow.length; let label: string | undefined; @@ -247,6 +245,18 @@ interface EdgeArrowInfo { startMarker?: 'arrow' | 'circle' | 'cross'; bidirectional: boolean; label: string; + arrowToken?: string; + edgeId?: string; +} + +function getFlowchartArrowInfo(arrow: string): Omit | undefined { + const original = EDGE_ARROWS.find(([pattern]) => pattern.test(arrow)); + if (original) return { ...original[1], arrowToken: arrow }; + if (!/^(?:[ox]?|-\.+-[>ox]?|={2,}[>ox]?|~{3,})$/.test(arrow)) return undefined; + return { stroke: arrow.includes('~') ? 'invisible' : arrow.includes('=') ? 'thick' : arrow.includes('.') ? 'dotted' : 'normal', + arrowType: /[>ox]$/.test(arrow) ? 'arrow' : 'none', endMarker: arrow.endsWith('o') ? 'circle' : arrow.endsWith('x') ? 'cross' : 'arrow', + ...(arrow.startsWith('o') ? { startMarker: 'circle' as const } : arrow.startsWith('x') ? { startMarker: 'cross' as const } : arrow.startsWith('<') ? { startMarker: 'arrow' as const } : {}), + bidirectional: arrow.startsWith('<') && arrow.endsWith('>'), arrowToken: arrow }; } // Arrow patterns ordered longest-first for greedy match @@ -287,6 +297,19 @@ function findEdge(line: string): { info: EdgeArrowInfo; rest: string; } | null { + // The scanner isolates complete connectors outside quoted shapes. This + // avoids treating additional minimum-length dashes as part of the source ID. + const scanned = getTopLevelArrows(line).find((entry) => entry.arrow !== '--'); + if (scanned && !/^.+\s+--\s+.+\s+-->/.test(line)) { + const info = getFlowchartArrowInfo(scanned.arrow); + if (info) { + let sourceText = line.slice(0, scanned.start).trim(); + const edgeIdMatch = sourceText.match(/^(.*?)\s+([^\s@]+)@$/); + if (edgeIdMatch) sourceText = edgeIdMatch[1]; + return { sourceText, targetText: line.slice(scanned.end).trim(), + info: { ...info, label: scanned.label?.trim() ?? '', ...(edgeIdMatch && { edgeId: edgeIdMatch[2] }) }, rest: '' }; + } + } // Strategy: scan for arrow patterns in the line // First try the pipe-label form: A -->|label| B const pipeArrow = getTopLevelArrows(line).find((arrow) => arrow.label !== undefined); @@ -361,8 +384,8 @@ function parseClick(rest: string): FlowchartClick | undefined { if (m) { return { kind: 'href', - target: m[1], - ...(m[2] && { tooltip: m[2] }), + target: unescapeMermaidLabel(m[1]), + ...(m[2] && { tooltip: unescapeMermaidLabel(m[2]) }), ...(m[3] && { linkTarget: m[3] }), }; } @@ -373,7 +396,7 @@ function parseClick(rest: string): FlowchartClick | undefined { kind: 'callback', target: callMatch[1].trim(), explicitCall: true, - ...(callMatch[2] && { tooltip: callMatch[2] }), + ...(callMatch[2] && { tooltip: unescapeMermaidLabel(callMatch[2]) }), }; } const bareMatch = rest.match(/^(\S+)(?:\s+"([^"]*)")?\s*$/); @@ -381,7 +404,7 @@ function parseClick(rest: string): FlowchartClick | undefined { return { kind: 'callback', target: bareMatch[1], - ...(bareMatch[2] && { tooltip: bareMatch[2] }), + ...(bareMatch[2] && { tooltip: unescapeMermaidLabel(bareMatch[2]) }), }; } return undefined; @@ -401,7 +424,9 @@ function parseClick(rest: string): FlowchartClick | undefined { */ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { validateInput(input, 'Mermaid flowchart'); - const { lines, directives } = prepareLines(input); + const prepared = prepareLines(input); + const { lines, directives } = prepared; + let source = prepared.source; // Parse header const header = lines[0]?.trim(); @@ -415,6 +440,7 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { const nodeMap = new Map(); const edges: FlowchartEdge[] = []; + const edgeProperties = new Map>(); const classDefs: Record> = {}; const classAssignments: Record = {}; // linkStyle lines collected during the pass, resolved against edges afterwards @@ -440,8 +466,11 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { } const node = nodeMap.get(id)!; // Update label/shape if provided and node was auto-created - if (label && !node.label) node.label = label; - if (shape && shape !== 'rectangle' && !node.shape) (node as any).shape = shape; + if (label !== undefined) node.label = label; + if (shape) { + if (shape === 'rectangle' || shape === 'rect') delete node.shape; + else node.shape = shape; + } // Apply ::: inline class if (className) { if (!classAssignments[id]) classAssignments[id] = []; @@ -456,6 +485,19 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { for (const statement of statements) { const line = statement.trim(); if (!line) continue; + const propertyMatch = line.match(/^([^\s@]+)@\{([\s\S]+)\}$/); + const propertyEdge = propertyMatch && edges.find((edge) => edge.id === propertyMatch[1]); + if (propertyEdge && propertyMatch) { + propertyEdge.data.properties = { ...propertyEdge.data.properties, ...getMermaidProperties(propertyMatch[2]) }; + continue; + } + if (propertyMatch) { + const properties = getMermaidProperties(propertyMatch[2]); + if (Object.keys(properties).some((key) => ['animate', 'animation', 'curve'].includes(key))) { + edgeProperties.set(propertyMatch[1], { ...edgeProperties.get(propertyMatch[1]), ...properties }); + continue; + } + } // subgraph const subgraphMatch = line.match(/^subgraph\s+(\S+?)(?:\s*\[(.+)\])?\s*$/); @@ -492,11 +534,7 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { if (classDefMatch) { const className = classDefMatch[1]; const propsStr = classDefMatch[2]; - const props: Record = {}; - for (const pair of propsStr.split(',')) { - const [k, v] = pair.split(':').map((s) => s.trim()); - if (k && v) props[k] = v; - } + const props = getMermaidStyle(propsStr); classDefs[className] = props; continue; } @@ -520,16 +558,8 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { const propsStr = styleMatch[2]; ensureNode(nodeId); const node = nodeMap.get(nodeId)!; - const style: Record = {}; - for (const pair of propsStr.split(',')) { - const [k, v] = pair.split(':').map((s) => s.trim()); - if (k && v) { - if (k === 'fill') { - (node as any).color = v; - } - style[k] = v; - } - } + const style = getMermaidStyle(propsStr); + if (style.fill) node.color = style.fill; if (Object.keys(style).length > 0) { (node as any).style = { ...((node as any).style ?? {}), ...style }; } @@ -541,11 +571,7 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { // correct across later graph mutation. const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/); if (linkStyleMatch) { - const props: Record = {}; - for (const pair of linkStyleMatch[2].split(',')) { - const [k, v] = pair.split(':').map((s) => s.trim()); - if (k && v) props[k] = v; - } + const props = getMermaidStyle(linkStyleMatch[2]); if (linkStyleMatch[1].trim() === 'default') { linkStyles.push({ indices: 'default', props }); } else { @@ -588,6 +614,7 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { const sourceDecl = parseNodeDecl(edgeResult.sourceText); if (sourceDecl) { ensureNode(sourceDecl.id, sourceDecl.label, sourceDecl.shape, sourceDecl.className); + if (sourceDecl.properties) nodeMap.get(sourceDecl.id)!.data.properties = { ...nodeMap.get(sourceDecl.id)!.data.properties, ...sourceDecl.properties }; } // Parse target — might have more edges chained @@ -595,6 +622,7 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { let targetId: string; if (targetDecl) { ensureNode(targetDecl.id, targetDecl.label, targetDecl.shape, targetDecl.className); + if (targetDecl.properties) nodeMap.get(targetDecl.id)!.data.properties = { ...nodeMap.get(targetDecl.id)!.data.properties, ...targetDecl.properties }; targetId = targetDecl.id; remaining = ''; // consumed } else { @@ -620,15 +648,19 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { const sourceId = sourceDecl ? sourceDecl.id : edgeResult.sourceText; ensureNode(sourceId); - const edgeId = generateEdgeId(sourceId, targetId, edgeCounter++); const info = edgeResult.info; + const edgeId = info.edgeId ?? generateEdgeId(sourceId, targetId, edgeCounter); + edgeCounter++; edges.push({ type: 'edge', id: edgeId, sourceId, targetId, label: info.label ? unescapeMermaidLabel(info.label) : '', + mode: info.bidirectional ? 'bidirectional' : info.arrowType === 'none' ? 'undirected' : 'directed', data: { + ...(info.edgeId && { explicitId: true }), + ...(info.arrowToken && { arrowToken: info.arrowToken }), stroke: info.stroke, arrowType: info.arrowType, ...(info.endMarker !== 'arrow' && { endMarker: info.endMarker }), @@ -644,6 +676,7 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { const nodeDecl = parseNodeDecl(line); if (nodeDecl) { ensureNode(nodeDecl.id, nodeDecl.label, nodeDecl.shape, nodeDecl.className); + if (nodeDecl.properties) nodeMap.get(nodeDecl.id)!.data.properties = { ...nodeMap.get(nodeDecl.id)!.data.properties, ...nodeDecl.properties }; continue; } @@ -657,6 +690,7 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { } } } + ((source ??= {}).unknownStatements ??= []).push(line); } // Apply class assignments to node data @@ -704,9 +738,14 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { id: unescapeMermaidId(node.id), parentId: node.parentId == null ? node.parentId : unescapeMermaidId(node.parentId), })); - const decodedEdges = edges.map((edge) => ({ + for (const [id, properties] of edgeProperties) { + const edge = edges.find((entry) => entry.id === id); + if (edge) edge.data.properties = { ...edge.data.properties, ...properties }; + else ((source ??= {}).unknownStatements ??= []).push(`${id}@{ ${toMermaidProperties(properties)} }`); + } + const decodedEdges = edges.map((edge, index) => ({ ...edge, - id: unescapeMermaidId(edge.id), + id: edge.data.explicitId ? unescapeMermaidId(edge.id) : generateEdgeId(unescapeMermaidId(edge.sourceId), unescapeMermaidId(edge.targetId), index), sourceId: unescapeMermaidId(edge.sourceId), targetId: unescapeMermaidId(edge.targetId), })); @@ -719,6 +758,7 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { edges: decodedEdges, data: { diagramType: 'flowchart', + ...(source && { source }), ...(Object.keys(classDefs).length > 0 && { classDefs }), ...(defaultLinkStyle && { defaultLinkStyle }), ...(directives.init && { init: directives.init }), @@ -740,7 +780,7 @@ export function toMermaidFlowchart(graph: MermaidFlowchartGraph): string { const dir = DIRECTION_TO_MERMAID[graph.direction ?? 'down'] ?? 'TD'; const gd = graph.data; - const lines: string[] = []; + const lines: string[] = getMermaidPreamble(graph.data ?? {}); // Preserved %%{init}%% directives re-emitted at the very top. const initLine = directiveToString('init', gd?.init); if (initLine) lines.push(initLine); @@ -771,53 +811,49 @@ export function toMermaidFlowchart(graph: MermaidFlowchartGraph): string { } // Recursive node/subgraph writer - function writeNodes(parentId: string | null, indent: string) { - const children = childrenMap.get(parentId) ?? []; - for (const node of children) { - const mermaidId = escapeMermaidId(node.id); - const displayLabel = node.label || (mermaidId === node.id ? '' : node.id); - if (isParent.has(node.id)) { - // Emit as subgraph - const label = displayLabel ? `[${escapeMermaidLabel(displayLabel)}]` : ''; - lines.push(`${indent}subgraph ${mermaidId}${label}`); - // Emit direction if set on this subgraph - if (node.data?.direction) { - const subDir = DIRECTION_TO_MERMAID[node.data.direction] ?? 'TD'; - lines.push(`${indent} direction ${subDir}`); - } - writeNodes(node.id, indent + ' '); - lines.push(`${indent}end`); + const stack: Array<{ node?: FlowchartNode; indent: string }> = (childrenMap.get(null) ?? []).slice().reverse().map((node) => ({ node, indent: ' ' })); + while (stack.length) { + const { node, indent } = stack.pop()!; + if (!node) { lines.push(`${indent}end`); continue; } + const mermaidId = escapeMermaidId(node.id); + const displayLabel = node.label || (mermaidId === node.id ? '' : getMermaidIdDisplay(node.id)); + if (isParent.has(node.id)) { + const label = displayLabel ? `[${escapeMermaidLabel(displayLabel)}]` : ''; + lines.push(`${indent}subgraph ${mermaidId}${label}`); + if (node.data?.direction) lines.push(`${indent} direction ${DIRECTION_TO_MERMAID[node.data.direction] ?? 'TD'}`); + stack.push({ indent }); + for (const child of (childrenMap.get(node.id) ?? []).slice().reverse()) stack.push({ node: child, indent: indent + ' ' }); + } else { + const shape = node.shape ?? 'rectangle'; + if (node.data?.properties || !SHAPE_TO_BRACKETS[shape]) { + const properties = { ...(node.data?.properties ?? {}), + ...((node.shape || node.data?.properties?.shape !== undefined) && { shape }), label: node.label ?? getMermaidIdDisplay(node.id) }; + lines.push(`${indent}${mermaidId}@{ ${toMermaidProperties(properties)} }`); } else { - // Emit as node - const shape = (node as any).shape ?? 'rectangle'; const brackets = SHAPE_TO_BRACKETS[shape] ?? ['[', ']']; - const label = displayLabel - ? `${brackets[0]}${escapeMermaidLabel(displayLabel)}${brackets[1]}` - : ''; + const label = displayLabel ? `${brackets[0]}${escapeMermaidLabel(displayLabel)}${brackets[1]}` : ''; lines.push(`${indent}${mermaidId}${label}`); } } } - writeNodes(null, ' '); + for (const node of graph.nodes) { + if (node.style && Object.keys(node.style).length) lines.push(` style ${escapeMermaidId(node.id)} ${Object.entries(node.style).map(([key, value]) => `${key}:${value}`).join(',')}`); + } // Emit edges for (const edge of graph.edges) { const d = edge.data; let arrow: string; - if (d?.stroke === 'invisible') { + const mode = edge.mode ?? (graph.mode !== 'directed' ? getEdgeMode(graph, edge) : d?.bidirectional ? 'bidirectional' : d?.arrowType === 'none' ? 'undirected' : getEdgeMode(graph, edge)); + if (mode === 'undirected') arrow = d?.stroke === 'thick' ? '===' : d?.stroke === 'dotted' ? '-.-' : d?.stroke === 'invisible' ? '~~~' : '---'; + else if (mode === 'bidirectional') arrow = d?.stroke === 'thick' ? '<==>' : d?.stroke === 'dotted' ? '<-.->' : '<-->'; + else if (d?.stroke === 'invisible') { arrow = '~~~'; - } else if (d?.bidirectional) { - arrow = - d.stroke === 'thick' - ? '<==>' - : d.stroke === 'dotted' - ? '<-.->' - : '<-->'; } else if (d?.stroke === 'thick') { - arrow = d.arrowType === 'none' ? '===' : '==>'; + arrow = '==>'; } else if (d?.stroke === 'dotted') { - arrow = d.arrowType === 'none' ? '-.-' : '-.->'; + arrow = '-.->'; } else { // Start marker prefix let prefix = ''; @@ -826,7 +862,14 @@ export function toMermaidFlowchart(graph: MermaidFlowchartGraph): string { if (d?.endMarker === 'circle') arrow = `${prefix}--o`; else if (d?.endMarker === 'cross') arrow = `${prefix}--x`; - else arrow = d?.arrowType === 'none' ? `${prefix}---` : `${prefix}-->`; + else arrow = `${prefix}-->`; + } + if (d?.arrowToken) { + const authored = getFlowchartArrowInfo(d.arrowToken); + const authoredMode = authored?.bidirectional ? 'bidirectional' : authored?.arrowType === 'none' ? 'undirected' : 'directed'; + if (authoredMode === mode && authored?.stroke === d.stroke + && authored.arrowType === d.arrowType && authored.endMarker === (d.endMarker ?? 'arrow') + && authored.startMarker === d.startMarker) arrow = d.arrowToken; } let labelStr = ''; @@ -834,8 +877,9 @@ export function toMermaidFlowchart(graph: MermaidFlowchartGraph): string { labelStr = `|${escapeMermaidLabel(edge.label)}|`; } lines.push( - ` ${escapeMermaidId(edge.sourceId)} ${arrow}${labelStr} ${escapeMermaidId(edge.targetId)}`, + ` ${escapeMermaidId(edge.sourceId)} ${d?.explicitId ? `${escapeMermaidId(edge.id)}@` : ''}${arrow}${labelStr} ${escapeMermaidId(edge.targetId)}`, ); + if (d?.properties) lines.push(` ${escapeMermaidId(edge.id)}@{ ${toMermaidProperties(d.properties)} }`); } // Emit class assignments diff --git a/src/formats/mermaid/git-graph.ts b/src/formats/mermaid/git-graph.ts new file mode 100644 index 0000000..155f117 --- /dev/null +++ b/src/formats/mermaid/git-graph.ts @@ -0,0 +1,120 @@ +import type { Graph, GraphNode, GraphEdge, GraphFormatConverter } from '../../types'; +import { getEdgeMode } from '../../mode'; +import { createFormatConverter } from '../converter'; +import { generateEdgeId, getMermaidPreamble, getMermaidDisplayText, type MermaidSourceData, DIRECTION_TO_MERMAID, MERMAID_TO_DIRECTION } from './shared'; +import { getMermaidProfile, getMermaidProfileGraph, getMermaidProfileText, toMermaidProfileText } from './profile-shared'; +export interface GitGraphNodeData { kind: 'commit' | 'merge' | 'cherry-pick'; branch: string; commitType: 'NORMAL' | 'REVERSE' | 'HIGHLIGHT'; tags?: string[] } +export interface GitGraphEdgeData { kind: 'parent' } +export type GitGraphAction = { kind: 'branch'; name: string; order?: number } | { kind: 'checkout'; name: string } | { kind: 'commit'; nodeId: string } | { kind: 'merge'; branch: string; nodeId: string } | { kind: 'cherry-pick'; target: string; parent?: string; nodeId: string }; +export interface GitGraphData extends MermaidSourceData { diagramType: 'gitGraph'; actions: GitGraphAction[]; mainBranch?: string } +export type MermaidGitGraph = Graph; +function getGitGraphIdentity(value: string): string { + const text = value.trim(); return getMermaidDisplayText(text.startsWith('"') && text.endsWith('"') ? text.slice(1, -1) : text); +} +function toGitGraphIdentity(value: string): string { + getMermaidDisplayText(value); + if (typeof value !== 'string' || !value || /["\x00-\x1f\x7f]/.test(value)) throw new Error('Mermaid GitGraph: identity is outside the native quoted-token profile'); + return `"${value}"`; +} +function getGitGraphAttributes(text: string) { + const values: Record = {}; + const expression = /\s*(id|type|tag|parent)\s*:\s*("[^"\n]*"|[A-Za-z0-9_]+)\s*/gy; + let index = 0; + while (index < text.length) { + expression.lastIndex = index; const match = expression.exec(text); + if (!match) throw new Error(`Mermaid GitGraph: unsupported attributes ${text.slice(index)}`); + const key = match[1]; const list = values[key] ?? []; list.push(key === 'tag' ? getMermaidProfileText(match[2]) : getGitGraphIdentity(match[2])); values[key] = list; index = expression.lastIndex; + } + for (const key of ['id', 'type', 'parent']) if (values[key]?.length > 1) throw new Error(`Mermaid GitGraph: duplicate ${key}`); + return values; +} +export function fromMermaidGitGraph(input: string): MermaidGitGraph { + const { lines, source, directives } = getMermaidProfile(input, /^gitGraph(?:\s+(?:LR|TB|BT))?\s*:?$/, 'GitGraph'); + const yamlBranch = source?.frontmatter?.match(/mainBranchName:\s*(?:"([^"]+)"|'([^']+)'|([^\s#]+))/); + const mainBranch = directives.init?.gitGraph?.mainBranchName ?? yamlBranch?.slice(1).find((value) => value !== undefined) ?? 'main'; + if (typeof mainBranch !== 'string' || !mainBranch) throw new Error('Mermaid GitGraph: invalid main branch'); + getMermaidDisplayText(mainBranch); + const branches = new Map([[mainBranch, null]]); let branch = mainBranch; + const nodes: GraphNode[] = []; const edges: GraphEdge[] = []; const actions: GitGraphAction[] = []; const byId = new Map>(); + for (const raw of lines.slice(1)) { + const line = raw.trim(); + const operation = line.match(/^(branch|checkout|switch)\s+("[^"\n]*"|[^\s]+)(?:\s+order:\s*(-?\d+))?$/); + if (operation) { + const name = getGitGraphIdentity(operation[2]); + if (operation[1] === 'branch') { + if (branches.has(name)) throw new Error(`Mermaid GitGraph: duplicate branch ${name}`); + branches.set(name, branches.get(branch)!); branch = name; actions.push({ kind: 'branch', name, ...(operation[3] !== undefined && { order: Number(operation[3]) }) }); + } else { + if (!branches.has(name) || operation[3] !== undefined) throw new Error('Mermaid GitGraph: invalid checkout'); + branch = name; actions.push({ kind: 'checkout', name }); + } + continue; + } + const commit = line.match(/^(commit|merge|cherry-pick)(?:\s+(.*))?$/); + if (!commit) throw new Error(`Mermaid GitGraph: unsupported statement ${line}`); + const kind = commit[1] as GitGraphNodeData['kind']; let tail = commit[2] ?? ''; let mergeBranch: string | undefined; + if (kind === 'merge') { + const target = tail.match(/^("[^"\n]*"|[^\s]+)(?:\s+(.*))?$/); + if (!target) throw new Error('Mermaid GitGraph: merge branch required'); + mergeBranch = getGitGraphIdentity(target[1]); tail = target[2] ?? ''; + if (mergeBranch === branch || !branches.has(mergeBranch) || branches.get(mergeBranch) == null || branches.get(branch) == null || branches.get(mergeBranch) === branches.get(branch)) throw new Error('Mermaid GitGraph: invalid merge'); + } + const attributes = getGitGraphAttributes(tail); const commitType = attributes.type?.[0] ?? 'NORMAL'; + if (!['NORMAL', 'REVERSE', 'HIGHLIGHT'].includes(commitType)) throw new Error('Mermaid GitGraph: invalid commit type'); + if (kind !== 'cherry-pick' && attributes.parent) throw new Error('Mermaid GitGraph: parent is only valid for cherry-pick'); + const target = kind === 'cherry-pick' ? attributes.id?.[0] : undefined; + if (kind === 'cherry-pick') { + const node = target ? byId.get(target) : undefined; + if (!node || node.data.branch === branch || !branches.get(branch) || attributes.type) throw new Error('Mermaid GitGraph: invalid cherry-pick target/current branch'); + const parents = edges.filter((edge) => edge.sourceId === target).map((edge) => edge.targetId); + if (node.data.kind === 'merge' && !attributes.parent?.[0] || attributes.parent && !parents.includes(attributes.parent[0])) throw new Error('Mermaid GitGraph: merge cherry-pick requires an immediate parent'); + } + let id = kind !== 'cherry-pick' && attributes.id ? attributes.id[0] : `git_${nodes.length}`; + if (kind === 'cherry-pick' || !attributes.id) { let suffix = 1; while (byId.has(id)) id = `git_${nodes.length}_${suffix++}`; } + if (!id || byId.has(id)) throw new Error(`Mermaid GitGraph: duplicate/empty commit ID ${id}`); + const node: GraphNode = { type: 'node', id, label: id, parentId: null, initialNodeId: null, data: { kind, branch, commitType: commitType as GitGraphNodeData['commitType'], ...(attributes.tag?.length && { tags: attributes.tag }) } }; + const parents = [branches.get(branch), ...(mergeBranch ? [branches.get(mergeBranch)] : target ? [target] : [])].filter((value): value is string => value != null); + for (const parent of parents) edges.push({ type: 'edge', id: generateEdgeId(id, parent, edges.length), sourceId: id, targetId: parent, label: null, mode: 'directed', data: { kind: 'parent' } }); + nodes.push(node); byId.set(id, node); branches.set(branch, id); + actions.push(kind === 'commit' ? { kind, nodeId: id } : kind === 'merge' ? { kind, nodeId: id, branch: mergeBranch! } : { kind, nodeId: id, target: target!, ...(attributes.parent && { parent: attributes.parent[0] }) }); + } + const direction = lines[0].match(/\b(LR|TB|BT)\b/)?.[1]; + return { ...getMermaidProfileGraph(nodes, edges, { diagramType: 'gitGraph' as const, actions, ...(mainBranch !== 'main' && { mainBranch }), ...(source && { source }) }), ...(direction && { direction: MERMAID_TO_DIRECTION[direction] }) }; +} +export function toMermaidGitGraph(graph: MermaidGitGraph): string { + if (!Array.isArray(graph.data?.actions)) throw new Error('Mermaid GitGraph: ordered actions required; arbitrary DAGs are not native histories'); + const mainBranch = graph.data.mainBranch ?? 'main'; + if (typeof mainBranch !== 'string' || !mainBranch) throw new Error('Mermaid GitGraph: invalid main branch'); + getMermaidDisplayText(mainBranch); + if (graph.direction === 'left') throw new Error('Mermaid GitGraph: RL direction unsupported'); + const init = graph.data.source?.directives?.init ?? {}; + const override = mainBranch !== 'main' || init.gitGraph?.mainBranchName !== undefined || graph.data.source?.frontmatter?.includes('mainBranchName:'); + const data = override ? { ...graph.data, source: { ...graph.data.source, directives: { ...graph.data.source?.directives, init: { ...init, gitGraph: { ...init.gitGraph, mainBranchName: mainBranch } } } } } : graph.data; + const lines = [...getMermaidPreamble(data), `gitGraph${graph.direction ? ` ${graph.direction === 'down' ? 'TB' : DIRECTION_TO_MERMAID[graph.direction]}` : ''}`]; + const oldMainBranch = graph.data.source?.directives?.init?.gitGraph?.mainBranchName ?? graph.data.source?.frontmatter?.match(/mainBranchName:\s*(?:"([^"]+)"|'([^']+)'|([^\s#]+))/)?.slice(1).find((value) => value !== undefined) ?? 'main'; + const declaredBranches = new Set(); + const nodes = new Map(graph.nodes.map((node) => [node.id, node])); const consumed = new Set(); + for (const action of graph.data.actions) { + if (action.kind === 'branch' || action.kind === 'checkout') { + if (action.kind === 'branch' && action.order !== undefined && !Number.isSafeInteger(action.order)) throw new Error('Mermaid GitGraph: invalid branch order'); + const name = action.kind === 'checkout' && action.name === oldMainBranch && !declaredBranches.has(action.name) ? mainBranch : action.name; + if (action.kind === 'branch') declaredBranches.add(action.name); + lines.push(` ${action.kind} ${toGitGraphIdentity(name)}${action.kind === 'branch' && action.order !== undefined ? ` order: ${action.order}` : ''}`); continue; + } + const node = nodes.get(action.nodeId); + if (!node || consumed.has(node.id) || node.label != null && node.label !== node.id || node.parentId != null) throw new Error('Mermaid GitGraph: stale action reference or unsupported commit label/hierarchy'); + consumed.add(node.id); + const type = node.data?.commitType ?? 'NORMAL'; + let command = action.kind === 'merge' ? `merge ${toGitGraphIdentity(action.branch)}` : action.kind; + command += action.kind === 'cherry-pick' ? ` id: ${toGitGraphIdentity(action.target)}${action.parent !== undefined ? ` parent: ${toGitGraphIdentity(action.parent)}` : ''}` : ` id: ${toGitGraphIdentity(node.id)}`; + if (action.kind !== 'cherry-pick' && type !== 'NORMAL') command += ` type: ${type}`; + for (const tag of node.data?.tags ?? []) command += ` tag: "${toMermaidProfileText(tag)}"`; + lines.push(` ${command}`); + } + if (consumed.size !== graph.nodes.length || graph.edges.some((edge) => getEdgeMode(graph, edge) !== 'directed')) throw new Error('Mermaid GitGraph: history does not cover live commits/modes'); + const result = lines.join('\n'); const parsed = fromMermaidGitGraph(result); + const expected = new Set(graph.edges.map((edge) => JSON.stringify([edge.sourceId, edge.targetId]))); const actual = new Set(parsed.edges.map((edge) => JSON.stringify([edge.sourceId, edge.targetId]))); + if (expected.size !== graph.edges.length || expected.size !== actual.size || [...expected].some((key) => !actual.has(key)) || parsed.nodes.some((node) => (nodes.get(node.id)?.data?.branch !== node.data.branch && !(mainBranch !== oldMainBranch && node.data.branch === mainBranch && nodes.get(node.id)?.data?.branch === oldMainBranch)) || nodes.get(node.id)?.data?.kind !== node.data.kind)) throw new Error('Mermaid GitGraph: live DAG/branch edits conflict with ordered actions'); + return result; +} +export const mermaidGitGraphConverter: GraphFormatConverter = createFormatConverter(toMermaidGitGraph, fromMermaidGitGraph); diff --git a/src/formats/mermaid/index.ts b/src/formats/mermaid/index.ts index 81b41b4..092ff4d 100644 --- a/src/formats/mermaid/index.ts +++ b/src/formats/mermaid/index.ts @@ -1,4 +1,5 @@ // Sequence diagram +export type { MermaidSourceData } from './shared'; export { fromMermaidSequence, toMermaidSequence, @@ -10,6 +11,8 @@ export type { SequenceEdgeData, SequenceGraphData, SequenceBlock, + SequenceNote, + SequenceEvent, } from './sequence'; // Flowchart @@ -103,3 +106,29 @@ export type { IshikawaEdgeData, IshikawaGraphData, } from './ishikawa'; + +export { + mermaidFlowchartDocumentConverter, + mermaidSequenceDocumentConverter, + mermaidStateDocumentConverter, + mermaidClassDocumentConverter, + mermaidERDocumentConverter, + mermaidBlockDocumentConverter, + mermaidMindmapDocumentConverter, + mermaidIshikawaDocumentConverter, +} from './document'; + +// Additional native graph profiles +export { fromMermaidArchitecture, toMermaidArchitecture, mermaidArchitectureConverter } from './architecture'; +export type { ArchitectureNodeData, ArchitectureEdgeData, ArchitectureGraphData, MermaidArchitectureGraph } from './architecture'; +export { fromMermaidRequirement, toMermaidRequirement, mermaidRequirementConverter } from './requirement'; +export type { RequirementKind, RequirementNodeData, RequirementEdgeData, RequirementGraphData, MermaidRequirementGraph } from './requirement'; +export { fromMermaidSankey, toMermaidSankey, mermaidSankeyConverter } from './sankey'; +export type { SankeyGraphData, MermaidSankeyGraph, MermaidSankeyOptions } from './sankey'; +export { fromMermaidGitGraph, toMermaidGitGraph, mermaidGitGraphConverter } from './git-graph'; +export type { GitGraphNodeData, GitGraphEdgeData, GitGraphData, GitGraphAction, MermaidGitGraph } from './git-graph'; +export { fromMermaidC4, toMermaidC4, mermaidC4Converter } from './c4'; +export type { C4DiagramType, C4NodeData, C4EdgeData, C4GraphData, MermaidC4Graph } from './c4'; +export { fromMermaidUseCase, toMermaidUseCase, mermaidUseCaseConverter } from './use-case'; +export type { UseCaseNodeData, UseCaseEdgeData, UseCaseGraphData, MermaidUseCaseGraph } from './use-case'; +export { mermaidArchitectureDocumentConverter, mermaidRequirementDocumentConverter, mermaidSankeyDocumentConverter, mermaidGitGraphDocumentConverter, mermaidC4DocumentConverter, mermaidUseCaseDocumentConverter } from './document'; diff --git a/src/formats/mermaid/ishikawa.ts b/src/formats/mermaid/ishikawa.ts index 80f1d13..8d06f72 100644 --- a/src/formats/mermaid/ishikawa.ts +++ b/src/formats/mermaid/ishikawa.ts @@ -3,7 +3,10 @@ import { createFormatConverter } from '../converter'; import { validateInput, prepareLines, + getMermaidPreamble, + type MermaidSourceData, escapeMermaidLabel, + unescapeMermaidLabel, generateEdgeId, } from './shared'; @@ -15,7 +18,7 @@ export interface IshikawaNodeData { export interface IshikawaEdgeData {} -export interface IshikawaGraphData { +export interface IshikawaGraphData extends MermaidSourceData { diagramType: 'ishikawa'; } @@ -41,7 +44,7 @@ type IshikawaNode = GraphNode; */ export function fromMermaidIshikawa(input: string): MermaidIshikawaGraph { validateInput(input, 'Mermaid Ishikawa'); - const { lines } = prepareLines(input); + const { lines, source } = prepareLines(input); const header = lines[0]?.trim(); if (!header || !header.startsWith('ishikawa-beta')) { @@ -59,7 +62,7 @@ export function fromMermaidIshikawa(input: string): MermaidIshikawaGraph { if (!rawLine.trim()) continue; const indent = rawLine.length - rawLine.trimStart().length; - const label = rawLine.trim(); + const label = unescapeMermaidLabel(rawLine.trim()); const id = `ish_${nodeCounter++}`; while (stack.length > 0 && stack[stack.length - 1].indent >= indent) { @@ -97,7 +100,7 @@ export function fromMermaidIshikawa(input: string): MermaidIshikawaGraph { initialNodeId: null, nodes, edges, - data: { diagramType: 'ishikawa' }, + data: { diagramType: 'ishikawa', ...(source && { source }) }, }; } @@ -111,7 +114,7 @@ export function fromMermaidIshikawa(input: string): MermaidIshikawaGraph { * // "ishikawa-beta\nProblem\n Cause" */ export function toMermaidIshikawa(graph: MermaidIshikawaGraph): string { - const lines: string[] = ['ishikawa-beta']; + const lines: string[] = [...getMermaidPreamble(graph.data ?? {}), 'ishikawa-beta']; const childrenMap = new Map(); for (const node of graph.nodes) { @@ -121,15 +124,12 @@ export function toMermaidIshikawa(graph: MermaidIshikawaGraph): string { childrenMap.set(parentId, children); } - const addIshikawaNodes = (parentId: string | null, depth: number) => { - for (const node of childrenMap.get(parentId) ?? []) { - const indent = ' '.repeat(depth); - lines.push(`${indent}${escapeMermaidLabel(node.label ?? node.id)}`); - addIshikawaNodes(node.id, depth + 1); - } - }; - - addIshikawaNodes(null, 0); + const stack: Array<{ node: IshikawaNode; depth: number }> = (childrenMap.get(null) ?? []).slice().reverse().map((node) => ({ node, depth: 0 })); + while (stack.length) { + const { node, depth } = stack.pop()!; + lines.push(`${' '.repeat(depth)}${escapeMermaidLabel(node.label ?? node.id)}`); + for (const child of (childrenMap.get(node.id) ?? []).slice().reverse()) stack.push({ node: child, depth: depth + 1 }); + } return lines.join('\n'); } diff --git a/src/formats/mermaid/mindmap.ts b/src/formats/mermaid/mindmap.ts index 2dc9a0f..f13b7cb 100644 --- a/src/formats/mermaid/mindmap.ts +++ b/src/formats/mermaid/mindmap.ts @@ -3,13 +3,20 @@ import { createFormatConverter } from '../converter'; import { validateInput, prepareLines, + getMermaidPreamble, + type MermaidSourceData, escapeMermaidLabel, + unescapeMermaidLabel, + escapeMermaidId, + unescapeMermaidId, generateEdgeId, } from './shared'; // --- Types --- export interface MindmapNodeData { + authoredId?: boolean; + classes?: string[]; /** * Icon class from `::icon(fa fa-book)` syntax (stores the inner text, e.g. * `"fa fa-book"`). Parsed and re-emitted on its own indented line. @@ -19,7 +26,7 @@ export interface MindmapNodeData { export interface MindmapEdgeData {} -export interface MindmapGraphData { +export interface MindmapGraphData extends MermaidSourceData { diagramType: 'mindmap'; } @@ -42,17 +49,19 @@ for (const [opener, closer, name] of SHAPE_PATTERNS) { SHAPE_TO_BRACKETS[name] = [opener, closer]; } -function parseNodeText(text: string): { label: string; shape?: string } { +function parseNodeText(text: string): { id?: string; label: string; shape?: string } { for (const [opener, closer, shapeName] of SHAPE_PATTERNS) { - if (text.startsWith(opener) && text.endsWith(closer)) { + const index = text.indexOf(opener); + if (index >= 0 && text.endsWith(closer)) { return { - label: text.slice(opener.length, text.length - closer.length).trim(), + ...(index > 0 && { id: unescapeMermaidId(text.slice(0, index).trim()) }), + label: unescapeMermaidLabel(text.slice(index + opener.length, text.length - closer.length).trim().replace(/^"([\s\S]*)"$/, '$1')), shape: shapeName, }; } } // Bare text — default shape - return { label: text.trim() }; + return { label: unescapeMermaidLabel(text.trim()) }; } // --- Parser --- @@ -71,7 +80,7 @@ function parseNodeText(text: string): { label: string; shape?: string } { */ export function fromMermaidMindmap(input: string): MermaidMindmapGraph { validateInput(input, 'Mermaid mindmap'); - const { lines } = prepareLines(input); + const { lines, source } = prepareLines(input); const header = lines[0]?.trim(); if (!header || !header.startsWith('mindmap')) { @@ -93,6 +102,11 @@ export function fromMermaidMindmap(input: string): MermaidMindmapGraph { // Measure indentation (spaces) const indent = rawLine.length - rawLine.trimStart().length; const content = rawLine.trim(); + const classMatch = content.match(/^:::([\w ,-]+)$/); + if (classMatch && stack.length) { + nodeMap.get(stack[stack.length - 1].id)!.data.classes = classMatch[1].split(/[\s,]+/).filter(Boolean); + continue; + } // ::icon(fa fa-book) — parse and store in nodeData.icon let icon: string | undefined; @@ -112,8 +126,10 @@ export function fromMermaidMindmap(input: string): MermaidMindmapGraph { continue; } - const { label, shape } = parseNodeText(cleanContent); - const id = `mm_${nodeCounter++}`; + const parsed = parseNodeText(cleanContent); + const { label, shape } = parsed; + let id = parsed.id ?? `mm_${nodeCounter++}`; + while (nodeMap.has(id) && !parsed.id) id = `mm_${nodeCounter++}`; const node: MindmapNode = { type: 'node', @@ -122,6 +138,7 @@ export function fromMermaidMindmap(input: string): MermaidMindmapGraph { initialNodeId: null, label, data: { + ...(parsed.id && { authoredId: true }), ...(icon && { icon }), }, ...(shape && { shape }), @@ -158,7 +175,7 @@ export function fromMermaidMindmap(input: string): MermaidMindmapGraph { initialNodeId: null, nodes: Array.from(nodeMap.values()), edges, - data: { diagramType: 'mindmap' }, + data: { diagramType: 'mindmap', ...(source && { source }) }, }; } @@ -172,7 +189,7 @@ export function fromMermaidMindmap(input: string): MermaidMindmapGraph { * // "mindmap\n Root\n Child A\n ..." */ export function toMermaidMindmap(graph: MermaidMindmapGraph): string { - const lines: string[] = ['mindmap']; + const lines: string[] = [...getMermaidPreamble(graph.data ?? {}), 'mindmap']; // Build children map const childrenMap = new Map(); @@ -182,29 +199,22 @@ export function toMermaidMindmap(graph: MermaidMindmapGraph): string { childrenMap.get(pid)!.push(node); } - function writeNode(nodeId: string | null, depth: number) { - const children = childrenMap.get(nodeId) ?? []; - for (const child of children) { - const indent = ' '.repeat(depth + 1); - const shape = (child as any).shape; - const brackets = shape ? SHAPE_TO_BRACKETS[shape] : undefined; - const label = escapeMermaidLabel(child.label ?? child.id); - const text = brackets - ? `${brackets[0]}${label}${brackets[1]}` - : label; - - let extra = ''; - if (child.data?.icon) { - extra = `\n${' '.repeat(depth + 2)}::icon(${child.data.icon})`; - } - - lines.push(`${indent}${text}${extra}`); - writeNode(child.id, depth + 1); - } + const stack: Array<{ node: MindmapNode; depth: number }> = (childrenMap.get(null) ?? []).slice().reverse().map((node) => ({ node, depth: 0 })); + while (stack.length) { + const { node: child, depth } = stack.pop()!; + const nodeIndent = ' '.repeat(depth + 1); + const brackets = child.shape ? SHAPE_TO_BRACKETS[child.shape] : undefined; + const label = escapeMermaidLabel(child.label ?? child.id); + const named = child.data?.authoredId || !/^mm_\d+$/.test(child.id); + const text = brackets + ? `${named ? escapeMermaidId(child.id) : ''}${brackets[0]}${label}${brackets[1]}` + : named ? `${escapeMermaidId(child.id)}[${label}]` : label; + lines.push(`${nodeIndent}${text}`); + if (child.data?.icon) lines.push(`${' '.repeat(depth + 2)}::icon(${child.data.icon})`); + if (child.data?.classes?.length) lines.push(`${' '.repeat(depth + 2)}:::${child.data.classes.join(' ')}`); + for (const node of (childrenMap.get(child.id) ?? []).slice().reverse()) stack.push({ node, depth: depth + 1 }); } - writeNode(null, 0); - return lines.join('\n'); } diff --git a/src/formats/mermaid/profile-shared.ts b/src/formats/mermaid/profile-shared.ts new file mode 100644 index 0000000..689eab8 --- /dev/null +++ b/src/formats/mermaid/profile-shared.ts @@ -0,0 +1,76 @@ +import type { Graph, GraphNode } from '../../types'; +import { prepareLines, validateInput, escapeMermaidLabel, unescapeMermaidLabel, type MermaidSourceData } from './shared'; + +export function getMermaidProfile(input: string, header: RegExp, name: string) { + validateInput(input, `Mermaid ${name}`); + const prepared = prepareLines(input); + if (!header.test(prepared.lines[0]?.trim() ?? '')) throw new Error(`Mermaid ${name}: invalid header`); + return prepared; +} +export function getMermaidProfileText(value: string): string { + const text = value.trim(); + return unescapeMermaidLabel(text.startsWith('"') && text.endsWith('"') ? text.slice(1, -1) : text); +} +export function toMermaidProfileText(value: string): string { + return escapeMermaidLabel(value).replace(/[()[\]{}%]/g, (char) => `#${char.charCodeAt(0)};`).replace(/^ +| +$/g, (spaces) => '#32;'.repeat(spaces.length)); +} +export function getMermaidProfileArguments(value: string): string[] { + const parts: string[] = []; + let start = 0; let quote = false; let depth = 0; + for (let index = 0; index < value.length; index++) { + if (value[index] === '"') quote = !quote; + if (quote) continue; + if ('([{'.includes(value[index])) depth++; + if (')]}'.includes(value[index])) depth--; + if (value[index] === ',' && depth === 0) { parts.push(value.slice(start, index).trim()); start = index + 1; } + } + if (quote || depth) throw new Error('Mermaid: unbalanced argument syntax'); + parts.push(value.slice(start).trim()); + return parts; +} +export function getMermaidProfileGraph(nodes: Graph['nodes'], edges: Graph['edges'], data: G): Graph { + const ids = new Set(); + for (const node of nodes) { + if (ids.has(node.id)) throw new Error(`Mermaid: duplicate node ${node.id}`); + ids.add(node.id); + } + for (const node of nodes) if (node.parentId != null && !ids.has(node.parentId)) throw new Error(`Mermaid: missing parent ${node.parentId}`); + for (const edge of edges) if (!ids.has(edge.sourceId) || !ids.has(edge.targetId)) throw new Error(`Mermaid: missing relationship endpoint ${edge.id}`); + return { id: '', mode: 'directed', initialNodeId: null, nodes, edges, data }; +} +export function getMermaidProfileOrderedNodes(nodes: GraphNode[]): GraphNode[] { + const ids = new Map(nodes.map((node) => [node.id, node])); + if (ids.size !== nodes.length) throw new Error('Mermaid: duplicate node IDs'); + const children = new Map[]>(); + for (const node of nodes) { + const parent = node.parentId ?? null; + if (parent !== null && !ids.has(parent)) throw new Error(`Mermaid: missing parent ${parent}`); + const siblings = children.get(parent) ?? []; siblings.push(node); children.set(parent, siblings); + } + const result: GraphNode[] = []; + const stack = [...(children.get(null) ?? [])].reverse(); + while (stack.length) { const node = stack.pop()!; result.push(node); stack.push(...[...(children.get(node.id) ?? [])].reverse()); } + if (result.length !== nodes.length) throw new Error('Mermaid: cyclic hierarchy'); + return result; +} + +/** Reject non-JSON cell contents rather than letting JSON.stringify drop fields. */ +export function getMermaidProfileJSON(value: unknown): string { + const active = new Set(); const stack: Array<{ value: any; exit?: boolean }> = [{ value }]; + while (stack.length) { + const item = stack.pop()!; const entry = item.value; + if (item.exit) { active.delete(entry); continue; } + if (entry === null || typeof entry === 'string' || typeof entry === 'boolean' || typeof entry === 'number' && Number.isFinite(entry)) continue; + if (!entry || typeof entry !== 'object' || !Array.isArray(entry) && ![Object.prototype, null].includes(Object.getPrototypeOf(entry)) || Object.getOwnPropertySymbols(entry).length) throw new Error('Mermaid: JSON node requires plain finite JSON'); + if (active.has(entry)) throw new Error('Mermaid: cyclic JSON node'); + const keys = Object.keys(Object.getOwnPropertyDescriptors(entry)).filter((key) => !(Array.isArray(entry) && key === 'length')); + if (Array.isArray(entry) && keys.length !== entry.length) throw new Error('Mermaid: sparse JSON array'); + active.add(entry); stack.push({ value: entry, exit: true }); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(entry, key)!; + if (!descriptor.enumerable || !('value' in descriptor) || Array.isArray(entry) && !/^(?:0|[1-9]\d*)$/.test(key)) throw new Error('Mermaid: non-JSON property'); + stack.push({ value: descriptor.value }); + } + } + return JSON.stringify(value); +} diff --git a/src/formats/mermaid/requirement.ts b/src/formats/mermaid/requirement.ts new file mode 100644 index 0000000..3d23dcf --- /dev/null +++ b/src/formats/mermaid/requirement.ts @@ -0,0 +1,94 @@ +import type { Graph, GraphNode, GraphEdge, GraphFormatConverter } from '../../types'; +import { createFormatConverter } from '../converter'; +import { getEdgeMode } from '../../mode'; +import { generateEdgeId, getMermaidPreamble, type MermaidSourceData, getMermaidStyle, MERMAID_TO_DIRECTION, DIRECTION_TO_MERMAID } from './shared'; +import { getMermaidProfile, getMermaidProfileGraph, getMermaidProfileText, toMermaidProfileText } from './profile-shared'; + +export type RequirementKind = 'requirement' | 'functionalRequirement' | 'interfaceRequirement' | 'performanceRequirement' | 'physicalRequirement' | 'designConstraint' | 'element'; +export interface RequirementNodeData { kind: RequirementKind; requirementId?: string; risk?: 'low' | 'medium' | 'high'; verifyMethod?: 'analysis' | 'inspection' | 'test' | 'demonstration'; elementType?: string; docRef?: string; classes?: string[] } +export interface RequirementEdgeData { relationship: 'contains' | 'copies' | 'derives' | 'satisfies' | 'verifies' | 'refines' | 'traces' } +export interface RequirementGraphData extends MermaidSourceData { diagramType: 'requirement'; classDefs?: Array<{ name: string; style: string }> } +export type MermaidRequirementGraph = Graph; +const KINDS = ['requirement', 'functionalRequirement', 'interfaceRequirement', 'performanceRequirement', 'physicalRequirement', 'designConstraint', 'element']; +const RELATIONS = ['contains', 'copies', 'derives', 'satisfies', 'verifies', 'refines', 'traces']; +const NAME = '(?:"[^"\\n]*"|[^\\s{}]+?)'; + +export function fromMermaidRequirement(input: string): MermaidRequirementGraph { + const { lines, source } = getMermaidProfile(input, /^requirementDiagram$/, 'Requirement'); + const nodes: GraphNode[] = []; const edges: GraphEdge[] = []; + const classDefs: NonNullable = []; + let current: GraphNode | undefined; let direction: Graph['direction']; + for (const raw of lines.slice(1)) { + const line = raw.trim(); + if (current) { + if (line === '}') { current = undefined; continue; } + const field = line.match(/^(id|text|risk|verifymethod|type|docref)\s*:\s*(.*)$/); + if (!field) throw new Error(`Mermaid Requirement: unsupported field ${line}`); + const value = getMermaidProfileText(field[2]); + if (!value) throw new Error('Mermaid Requirement: native field strings cannot be empty; omit empty fields'); + if (current.data.kind === 'element' && !['type', 'docref'].includes(field[1]) || current.data.kind !== 'element' && ['type', 'docref'].includes(field[1])) throw new Error('Mermaid Requirement: invalid field for node kind'); + if (field[1] === 'risk' && !['low', 'medium', 'high'].includes(value.toLowerCase())) throw new Error('Mermaid Requirement: unknown risk'); + if (field[1] === 'verifymethod' && !['analysis', 'inspection', 'test', 'demonstration'].includes(value.toLowerCase())) throw new Error('Mermaid Requirement: unknown verification'); + if (field[1] === 'text') current.label = value; + else Object.assign(current.data, { [{ id: 'requirementId', risk: 'risk', verifymethod: 'verifyMethod', type: 'elementType', docref: 'docRef' }[field[1]]!]: ['risk', 'verifymethod'].includes(field[1]) ? value.toLowerCase() : value }); + continue; + } + const declaration = line.match(new RegExp(`^(${KINDS.join('|')})\\s+(${NAME})(?::::([A-Za-z0-9_,]+))?\\s*\\{\\s*$`)); + if (declaration) { + const id = getMermaidProfileText(declaration[2]); + current = { type: 'node', id, parentId: null, initialNodeId: null, label: declaration[1] === 'element' ? id : '', data: { kind: declaration[1] as RequirementKind, ...(declaration[3] && { classes: declaration[3].split(',') }) } }; + nodes.push(current); continue; + } + const relationship = line.match(new RegExp(`^(${NAME})\\s+(-\\s*(${RELATIONS.join('|')})\\s*->|<-\\s*(${RELATIONS.join('|')})\\s*-)\\s+(${NAME})$`)); + if (relationship) { + const reverse = !!relationship[4]; + const sourceId = getMermaidProfileText(reverse ? relationship[5] : relationship[1]); const targetId = getMermaidProfileText(reverse ? relationship[1] : relationship[5]); + const kind = (relationship[3] ?? relationship[4]) as RequirementEdgeData['relationship']; + edges.push({ type: 'edge', id: generateEdgeId(sourceId, targetId, edges.length), sourceId, targetId, label: kind, mode: 'directed', data: { relationship: kind } }); continue; + } + const definition = line.match(/^classDef\s+([A-Za-z0-9_,]+)\s+(.+)$/); + if (definition) { for (const name of definition[1].split(',')) classDefs.push({ name, style: definition[2] }); continue; } + const decoration = line.match(/^(style|class)\s+([A-Za-z0-9_,]+)\s+(.+)$/); + if (decoration) { + for (const id of decoration[2].split(',')) { + const node = nodes.find((node) => node.id === getMermaidProfileText(id)); + if (!node) throw new Error('Mermaid Requirement: missing style target'); + if (decoration[1] === 'style') node.style = getMermaidStyle(decoration[3]); else node.data.classes = decoration[3].split(','); + } + continue; + } + const dir = line.match(/^direction\s+(TB|BT|LR|RL)$/); + if (dir) { direction = MERMAID_TO_DIRECTION[dir[1]]; continue; } + throw new Error(`Mermaid Requirement: unsupported statement ${line}`); + } + if (current) throw new Error('Mermaid Requirement: unclosed definition'); + return { ...getMermaidProfileGraph(nodes, edges, { diagramType: 'requirement' as const, ...(source && { source }), ...(classDefs.length && { classDefs }) }), ...(direction && { direction }) }; +} +export function toMermaidRequirement(graph: MermaidRequirementGraph): string { + const lines = [...getMermaidPreamble(graph.data ?? {}), 'requirementDiagram']; + if (graph.direction) lines.push(` direction ${DIRECTION_TO_MERMAID[graph.direction]}`); + for (const node of graph.nodes) { + const kind = node.data?.kind ?? 'requirement'; + if (!node.id || !KINDS.includes(kind) || node.parentId != null || node.ports?.length) throw new Error('Mermaid Requirement: invalid kind/hierarchy'); + lines.push(` ${kind} "${toMermaidProfileText(node.id)}"${node.data?.classes?.length ? `:::${node.data.classes.join(',')}` : ''} {`); + if (kind === 'element') { + if (node.label != null && node.label !== node.id) throw new Error('Mermaid Requirement: element display name is its identity'); + if (node.data?.elementType !== undefined) lines.push(` type: "${toMermaidProfileText(node.data.elementType)}"`); + if (node.data?.docRef !== undefined) lines.push(` docref: "${toMermaidProfileText(node.data.docRef)}"`); + } else { + if (node.data?.requirementId !== undefined) lines.push(` id: "${toMermaidProfileText(node.data.requirementId)}"`); + if (node.label) lines.push(` text: "${toMermaidProfileText(node.label)}"`); + if (node.data?.risk !== undefined) lines.push(` risk: ${node.data.risk}`); + if (node.data?.verifyMethod !== undefined) lines.push(` verifymethod: ${node.data.verifyMethod}`); + } + lines.push(' }'); + } + for (const edge of graph.edges) { + if (getEdgeMode(graph, edge) !== 'directed' || !RELATIONS.includes(edge.data?.relationship) || edge.sourcePort || edge.targetPort || edge.label != null && edge.label !== edge.data.relationship) throw new Error('Mermaid Requirement: unsupported relation/mode'); + lines.push(` "${toMermaidProfileText(edge.sourceId)}" - ${edge.data.relationship} -> "${toMermaidProfileText(edge.targetId)}"`); + } + for (const definition of graph.data?.classDefs ?? []) lines.push(` classDef ${definition.name} ${definition.style}`); + for (const node of graph.nodes) if (node.style) lines.push(` style ${node.id} ${Object.entries(node.style).map(([key, value]) => `${key}:${value}`).join(',')}`); + const result = lines.join('\n'); fromMermaidRequirement(result); return result; +} +export const mermaidRequirementConverter: GraphFormatConverter = createFormatConverter(toMermaidRequirement, fromMermaidRequirement); diff --git a/src/formats/mermaid/sankey.ts b/src/formats/mermaid/sankey.ts new file mode 100644 index 0000000..a393822 --- /dev/null +++ b/src/formats/mermaid/sankey.ts @@ -0,0 +1,38 @@ +import type { Graph, GraphNode, GraphEdge, GraphFormatConverter } from '../../types'; +import { getEdgeMode } from '../../mode'; +import { createFormatConverter } from '../converter'; +import { getCSVTable, toCSVCell } from '../csv/table'; +import { generateEdgeId, getMermaidPreamble, type MermaidSourceData } from './shared'; +import { getMermaidProfile, getMermaidProfileGraph } from './profile-shared'; +export interface SankeyGraphData extends MermaidSourceData { diagramType: 'sankey' } +export type MermaidSankeyGraph = Graph; +export interface MermaidSankeyOptions { defaultWeight?: number } +export function fromMermaidSankey(input: string): MermaidSankeyGraph { + const { lines, source } = getMermaidProfile(input, /^sankey(?:-beta)?$/, 'Sankey'); + const nodes = new Map>(); const edges: GraphEdge[] = []; + if (lines.length > 1) { + const table = getCSVTable(`source,target,value\n${lines.slice(1).join('\n')}`, ','); + for (const row of table.rows) { + const sourceId = row[0].value.trim(); const targetId = row[1].value.trim(); const text = row[2].value.trim(); + if (/[^\x20-\x7e]/.test(sourceId + targetId) || !sourceId || !targetId || !/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(text) || !Number.isFinite(Number(text)) || Number(text) < 0) throw new Error('Mermaid Sankey: ASCII non-empty names and finite nonnegative weights required'); + for (const id of [sourceId, targetId]) if (!nodes.has(id)) nodes.set(id, { type: 'node', id, label: id, parentId: null, initialNodeId: null, data: null }); + edges.push({ type: 'edge', id: generateEdgeId(sourceId, targetId, edges.length), sourceId, targetId, label: null, mode: 'directed', weight: Number(text), data: null }); + } + } + return getMermaidProfileGraph([...nodes.values()], edges, { diagramType: 'sankey' as const, ...(source && { source }) }); +} +export function toMermaidSankey(graph: MermaidSankeyGraph, options: MermaidSankeyOptions = {}): string { + const lines = [...getMermaidPreamble(graph.data ?? {}), 'sankey-beta']; + const ids = new Set(graph.nodes.map((node) => node.id)); const used = new Set(); + for (const node of graph.nodes) if (!node.id || node.id.trim() !== node.id || node.parentId != null || node.label != null && node.label !== node.id || node.ports?.length) throw new Error('Mermaid Sankey: native names are identities; hierarchy/ports unsupported'); + for (const edge of graph.edges) { + const weight = edge.weight ?? options.defaultWeight ?? 1; + if (!ids.has(edge.sourceId) || !ids.has(edge.targetId) || getEdgeMode(graph, edge) !== 'directed' || !Number.isFinite(weight) || weight < 0 || edge.sourcePort || edge.targetPort || edge.label) throw new Error('Mermaid Sankey: unsupported endpoint/mode/weight/port'); + if (/[^\x20-\x7e]/.test(edge.sourceId + edge.targetId)) throw new Error('Mermaid Sankey12.1: native names must be printable ASCII'); + used.add(edge.sourceId); used.add(edge.targetId); + lines.push([edge.sourceId, edge.targetId, String(weight)].map((text) => toCSVCell(text, ',')).join(',')); + } + if (used.size !== ids.size) throw new Error('Mermaid Sankey: isolated nodes are unrepresentable'); + return lines.join('\n'); +} +export const mermaidSankeyConverter: GraphFormatConverter = createFormatConverter(toMermaidSankey, fromMermaidSankey); diff --git a/src/formats/mermaid/sequence.ts b/src/formats/mermaid/sequence.ts index 4e49834..34f1493 100644 --- a/src/formats/mermaid/sequence.ts +++ b/src/formats/mermaid/sequence.ts @@ -1,8 +1,12 @@ import type { Graph, GraphNode, GraphEdge, GraphFormatConverter } from '../../types'; import { createFormatConverter } from '../converter'; +import { getEdgeMode } from '../../mode'; import { validateInput, prepareLines, + getMermaidPreamble, + type MermaidSourceData, + getMermaidProperties, escapeMermaidLabel, unescapeMermaidLabel, generateEdgeId, @@ -10,15 +14,30 @@ import { // --- Types --- +export interface SequenceNote { + /** Stable identity for ordered events; shared by convenience copies on participants. */ + id?: string; + position: 'left' | 'right' | 'over'; + text: string; + over?: string[]; +} + export interface SequenceNodeData { actorType: 'participant' | 'actor' | 'boundary' | 'control' | 'entity' | 'database' | 'collections' | 'queue'; alias?: string; created?: boolean; destroyed?: boolean; - notes?: Array<{ position: 'left' | 'right' | 'over'; text: string; over?: string[] }>; + notes?: SequenceNote[]; box?: { title?: string; color?: string }; + configuration?: Record; } +export type SequenceEvent = ( + | { kind: 'note'; nodeId: string; noteId?: string; /** Legacy documents only. */ noteIndex?: number } + | { kind: 'create'; nodeId: string } + | { kind: 'destroy'; nodeId: string } +) & { beforeEdgeId?: string; afterEdgeId?: string; placement?: 'after'; beforeBlockOpen?: boolean }; + export interface SequenceEdgeData { kind: 'message' | 'activation' | 'deactivation'; stroke?: 'solid' | 'dotted'; @@ -67,9 +86,13 @@ export type SequenceBlock = | { type: 'break'; label: string; edgeIds: string[] } | { type: 'rect'; color: string; edgeIds: string[] }; -export interface SequenceGraphData { +export interface SequenceGraphData extends MermaidSourceData { diagramType: 'sequence'; autonumber?: boolean; + autonumberStart?: number; + autonumberIncrement?: number; + /** Events refer to live graph entities, never frozen message snapshots. */ + events?: SequenceEvent[]; blocks?: SequenceBlock[]; // TODO: actor links/menus (link Alice: Dashboard @ url) not yet supported // TODO: rect background highlighting partially supported via blocks @@ -154,7 +177,9 @@ const MESSAGE_RE = new RegExp( */ export function fromMermaidSequence(input: string): MermaidSequenceGraph { validateInput(input, 'Mermaid sequence'); - const { lines } = prepareLines(input); + const prepared = prepareLines(input); + const { lines } = prepared; + let source = prepared.source; // Validate header const header = lines[0]?.trim(); @@ -165,8 +190,15 @@ export function fromMermaidSequence(input: string): MermaidSequenceGraph { const nodeMap = new Map(); const edges: SequenceEdge[] = []; const blocks: SequenceBlock[] = []; + const events: SequenceEvent[] = []; + const pendingEvents: SequenceEvent[] = []; + const eventScopes = new Map(); + const edgeScopes = new Map(); let autonumber = false; + let autonumberStart: number | undefined; + let autonumberIncrement: number | undefined; let edgeCounter = 0; + let noteCounter = 0; let seqNum = 0; let currentBox: { title?: string; color?: string } | null = null; @@ -197,6 +229,16 @@ export function fromMermaidSequence(input: string): MermaidSequenceGraph { } function addEdge(edge: SequenceEdge): void { + const scope = getSequenceScope(); + edgeScopes.set(edge.id, scope); + for (const event of pendingEvents.splice(0)) { + event.beforeEdgeId = edge.id; + const eventScope = eventScopes.get(event); + if (eventScope !== scope) { + if (event.afterEdgeId && eventScope === edgeScopes.get(event.afterEdgeId)) event.placement = 'after'; + else event.beforeBlockOpen = true; + } + } edges.push(edge); // Add to current block if inside one if (blockStack.length > 0) { @@ -211,30 +253,47 @@ export function fromMermaidSequence(input: string): MermaidSequenceGraph { } } + function addEvent(event: SequenceEvent): void { + if (edges.length) event.afterEdgeId = edges[edges.length - 1].id; + events.push(event); + pendingEvents.push(event); + eventScopes.set(event, getSequenceScope()); + } + + function getSequenceScope(): string { + return blockStack.map((block) => `${block.type}:${block.label}:${block.branches?.length ?? 0}:${block.options?.length ?? 0}`).join('/'); + } + for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; // autonumber - if (line === 'autonumber') { + const autonumberMatch = line.match(/^autonumber(?:\s+([\d.]+)(?:\s+([\d.]+))?)?$/); + if (autonumberMatch) { autonumber = true; + if (autonumberMatch[1]) { autonumberStart = Number(autonumberMatch[1]); seqNum = autonumberStart - (autonumberMatch[2] ? Number(autonumberMatch[2]) : 1); } + if (autonumberMatch[2]) autonumberIncrement = Number(autonumberMatch[2]); continue; } // participant / actor / boundary / control / entity / database / collections / queue declaration const participantMatch = line.match( - /^(participant|actor|boundary|control|entity|database|collections|queue)\s+(\S+?)(?:\s+as\s+(.+))?$/, + /^(participant|actor|boundary|control|entity|database|collections|queue)\s+([^\s@]+)(?:@\{([\s\S]+)\})?(?:\s+as\s+(.+))?$/, ); if (participantMatch) { - const actorType = participantMatch[1] as SequenceNodeData['actorType']; + const configuration = participantMatch[3] ? getMermaidProperties(participantMatch[3]) : undefined; + const configuredType = configuration?.type; + const actorType = (typeof configuredType === 'string' && ['participant', 'actor', 'boundary', 'control', 'entity', 'database', 'collections', 'queue'].includes(configuredType) ? configuredType : participantMatch[1]) as SequenceNodeData['actorType']; const id = participantMatch[2]; - const alias = participantMatch[3]?.trim(); + const alias = participantMatch[4]?.trim() ?? (configuration?.alias === undefined ? undefined : String(configuration.alias)); ensureNode(id, actorType); const node = nodeMap.get(id)!; node.data.actorType = actorType; + if (configuration) node.data.configuration = configuration; if (alias) { - node.data.alias = alias; - node.label = alias; + node.data.alias = unescapeMermaidLabel(alias); + node.label = unescapeMermaidLabel(alias); } // Apply current box if inside one if (currentBox) { @@ -255,6 +314,7 @@ export function fromMermaidSequence(input: string): MermaidSequenceGraph { node.data.alias = alias; node.label = alias; } + addEvent({ kind: 'create', nodeId: id }); continue; } @@ -263,6 +323,7 @@ export function fromMermaidSequence(input: string): MermaidSequenceGraph { const id = destroyMatch[1]; ensureNode(id); nodeMap.get(id)!.data.destroyed = true; + addEvent({ kind: 'destroy', nodeId: id }); continue; } @@ -281,7 +342,7 @@ export function fromMermaidSequence(input: string): MermaidSequenceGraph { label: '', data: { kind: kind as 'activation' | 'deactivation', - ...(autonumber && { sequenceNumber: ++seqNum }), + ...(autonumber && { sequenceNumber: seqNum += autonumberIncrement ?? 1 }), }, }); continue; @@ -393,8 +454,9 @@ export function fromMermaidSequence(input: string): MermaidSequenceGraph { const posRaw = noteMatch[1].toLowerCase(); const position: 'left' | 'right' | 'over' = posRaw === 'left of' ? 'left' : posRaw === 'right of' ? 'right' : 'over'; const actorsPart = noteMatch[2].trim(); - const text = noteMatch[3].trim(); + const text = unescapeMermaidLabel(noteMatch[3].trim()); const actorIds = actorsPart.split(',').map((s) => s.trim()); + const noteId = `note_${noteCounter++}`; // Store note on each referenced actor for (const actorId of actorIds) { @@ -402,11 +464,13 @@ export function fromMermaidSequence(input: string): MermaidSequenceGraph { const node = nodeMap.get(actorId)!; if (!node.data.notes) node.data.notes = []; node.data.notes.push({ + id: noteId, position, text, ...(position === 'over' && actorIds.length > 1 ? { over: actorIds } : {}), }); } + addEvent({ kind: 'note', nodeId: actorIds[0], noteId }); continue; } // Also handle Note without colon (skip, no inline text) @@ -462,7 +526,7 @@ export function fromMermaidSequence(input: string): MermaidSequenceGraph { ...(arrowInfo.bidirectional && { bidirectional: true }), ...(centralSource && { centralSource: true }), ...(centralTarget && { centralTarget: true }), - ...(autonumber && { sequenceNumber: ++seqNum }), + ...(autonumber && { sequenceNumber: seqNum += autonumberIncrement ?? 1 }), }; addEdge({ @@ -471,6 +535,7 @@ export function fromMermaidSequence(input: string): MermaidSequenceGraph { sourceId, targetId, label: unescapeMermaidLabel(messageText), + mode: arrowInfo.bidirectional ? 'bidirectional' : 'directed', data, }); @@ -500,7 +565,7 @@ export function fromMermaidSequence(input: string): MermaidSequenceGraph { continue; } - // Ignore unrecognized lines (title, etc.) + ((source ??= {}).unknownStatements ??= []).push(line); } return { @@ -511,7 +576,11 @@ export function fromMermaidSequence(input: string): MermaidSequenceGraph { edges, data: { diagramType: 'sequence', + ...(source && { source }), ...(autonumber && { autonumber: true }), + ...(autonumberStart !== undefined && { autonumberStart }), + ...(autonumberIncrement !== undefined && { autonumberIncrement }), + ...(events.length && { events }), ...(blocks.length > 0 && { blocks }), }, }; @@ -608,66 +677,105 @@ const ARROW_MAP: Record> = { * // "sequenceDiagram\n participant Alice\n ..." */ export function toMermaidSequence(graph: MermaidSequenceGraph): string { - const lines: string[] = ['sequenceDiagram']; + const lines: string[] = [...getMermaidPreamble(graph.data ?? {}), 'sequenceDiagram']; const gd = graph.data; if (gd?.autonumber) { - lines.push(' autonumber'); + lines.push(` autonumber${gd.autonumberStart !== undefined ? ` ${gd.autonumberStart}` : ''}${gd.autonumberIncrement !== undefined ? ` ${gd.autonumberIncrement}` : ''}`); } - // Group nodes by box for emission - const boxGroups = new Map; nodes: typeof graph.nodes }>(); - const noBoxNodes: typeof graph.nodes = []; - for (const node of graph.nodes) { - const box = node.data?.box; - if (box) { - const key = JSON.stringify(box); - if (!boxGroups.has(key)) boxGroups.set(key, { box, nodes: [] }); - boxGroups.get(key)!.nodes.push(node); - } else { - noBoxNodes.push(node); - } - } + const events = gd?.events; + const eventNodes = new Map(graph.nodes.map((node) => [node.id, node])); + const eventCreates = new Set(events?.filter((event) => event.kind === 'create').map((event) => event.nodeId)); + const eventDestroys = new Set(events?.filter((event) => event.kind === 'destroy').map((event) => event.nodeId)); - function emitParticipant(node: typeof graph.nodes[0], indent: string) { + function getParticipantText(node: typeof graph.nodes[0]): string { const d = node.data; - const keyword = d?.actorType ?? 'participant'; - const alias = d?.alias ? ` as ${escapeMermaidLabel(d.alias)}` : ''; - if (d?.created) { - lines.push(`${indent}create ${keyword} ${node.id}${alias}`); - } else { - lines.push(`${indent}${keyword} ${node.id}${alias}`); - } + const actorType = d?.actorType ?? 'participant'; + const keyword = actorType === 'actor' ? 'actor' : 'participant'; + const configuration = { ...(d?.configuration ?? {}) }; + if (actorType !== 'participant' && actorType !== 'actor') configuration.type = actorType; + else delete configuration.type; + // Canonical label edits take precedence over the original JSON alias. + delete configuration.alias; + const aliasLabel = node.label != null && node.label !== node.id ? node.label : d?.alias; + const alias = aliasLabel ? ` as ${escapeMermaidLabel(aliasLabel)}` : ''; + const config = Object.keys(configuration).length ? `@{ ${JSON.stringify(configuration).slice(1, -1)} }` : ''; + return `${keyword} ${node.id}${config}${alias}`; } - // Emit boxed participants first, then unboxed - for (const { box, nodes: boxNodes } of boxGroups.values()) { - const colorStr = box.color ? ` ${box.color}` : ''; - const titleStr = box.title ? ` ${box.title}` : ''; - lines.push(` box${colorStr}${titleStr}`); - for (const node of boxNodes) { - emitParticipant(node, ' '); + // Consecutive groups preserve native participant order. + let activeBox = ''; + for (const node of graph.nodes) { + if (eventCreates.has(node.id)) continue; + const boxKey = node.data?.box ? JSON.stringify(node.data.box) : ''; + if (boxKey !== activeBox) { + if (activeBox) lines.push(' end'); + activeBox = boxKey; + if (node.data?.box) lines.push(` box${node.data.box.color ? ` ${node.data.box.color}` : ''}${node.data.box.title ? ` ${node.data.box.title}` : ''}`); } - lines.push(' end'); + const participantIndent = activeBox ? ' ' : ' '; + // Legacy Graphs containing lifecycle flags but no ordered events. + lines.push(`${participantIndent}${node.data?.created ? 'create ' : ''}${getParticipantText(node)}`); } - for (const node of noBoxNodes) { - emitParticipant(node, ' '); + if (activeBox) lines.push(' end'); + + function addNote(nodeId: string, note: NonNullable[number], noteIndent: string): void { + const participants = note.position === 'over' && note.over?.length ? note.over : [nodeId]; + if (participants.some((id) => !eventNodes.has(id))) throw new Error('Mermaid sequence: note references a missing participant'); + const position = note.position === 'left' ? 'left of' : note.position === 'right' ? 'right of' : 'over'; + lines.push(`${noteIndent}Note ${position} ${participants.join(',')}: ${escapeMermaidLabel(note.text)}`); } - // Emit notes after participant declarations - for (const node of graph.nodes) { - if (node.data?.notes) { - for (const note of node.data.notes) { - if (note.position === 'over' && note.over && note.over.length > 1) { - lines.push(` Note over ${note.over.join(',')}: ${escapeMermaidLabel(note.text)}`); - } else { - const posStr = note.position === 'left' ? 'left of' : note.position === 'right' ? 'right of' : 'over'; - lines.push(` Note ${posStr} ${node.id}: ${escapeMermaidLabel(note.text)}`); + if (!events) { + const spanningOwners = new Map(); + for (const node of graph.nodes) { + for (const note of node.data?.notes ?? []) { + if (note.over?.length) { + const key = JSON.stringify(note); + const owner = spanningOwners.get(key); + if (owner && owner !== node.id) continue; + spanningOwners.set(key, node.id); } + addNote(node.id, note, ' '); } } } + const liveEdgeIds = new Set(graph.edges.map((edge) => edge.id)); + const eventsBefore = new Map(); + const eventsAfter = new Map(); + const leadingEvents: SequenceEvent[] = []; + for (const event of events ?? []) { + if (!eventNodes.has(event.nodeId)) throw new Error(`Mermaid sequence: event participant "${event.nodeId}" not found`); + if (event.placement !== 'after' && event.beforeEdgeId && liveEdgeIds.has(event.beforeEdgeId)) { + const list = eventsBefore.get(event.beforeEdgeId) ?? []; + list.push(event); + eventsBefore.set(event.beforeEdgeId, list); + } else if (event.afterEdgeId && liveEdgeIds.has(event.afterEdgeId)) { + const list = eventsAfter.get(event.afterEdgeId) ?? []; + list.push(event); + eventsAfter.set(event.afterEdgeId, list); + } else if (event.beforeEdgeId || event.afterEdgeId) { + throw new Error('Mermaid sequence: event anchors no longer exist; update graph.data.events'); + } else leadingEvents.push(event); + } + + function addSequenceEvent(event: SequenceEvent, eventIndent: string): void { + const node = eventNodes.get(event.nodeId)!; + if (event.kind === 'create') { + if (node.data?.created !== false) lines.push(`${eventIndent}create ${getParticipantText(node)}`); + } else if (event.kind === 'destroy') { + if (node.data?.destroyed !== false) lines.push(`${eventIndent}destroy ${node.id}`); + } else { + const note = event.noteId ? node.data?.notes?.find((entry) => entry.id === event.noteId) + : event.noteIndex === undefined ? undefined : node.data?.notes?.[event.noteIndex]; + if (!note) throw new Error('Mermaid sequence: ordered note no longer exists; update graph.data.events'); + addNote(node.id, note, eventIndent); + } + } + for (const event of leadingEvents) addSequenceEvent(event, ' '); + // Build edge-to-block index for reconstructing block nesting const blocks = gd?.blocks ?? []; // Collect all edgeIds claimed by blocks (in order) so we can interleave @@ -791,6 +899,9 @@ export function toMermaidSequence(graph: MermaidSequenceGraph): string { const indent = () => ' '.repeat(depth); for (const edge of graph.edges) { + for (const event of eventsBefore.get(edge.id) ?? []) { + if (event.beforeBlockOpen) addSequenceEvent(event, indent()); + } // Emit block-open / branch events before this edge const befores = beforeEdge.get(edge.id); if (befores) { @@ -824,8 +935,11 @@ export function toMermaidSequence(graph: MermaidSequenceGraph): string { } } - const d = edge.data; - if (!d) continue; + for (const event of eventsBefore.get(edge.id) ?? []) { + if (!event.beforeBlockOpen) addSequenceEvent(event, indent()); + } + + const d = edge.data ?? { kind: 'message' as const }; if (d.kind === 'activation') { lines.push(`${indent()}activate ${edge.sourceId}`); @@ -836,7 +950,9 @@ export function toMermaidSequence(graph: MermaidSequenceGraph): string { const stroke = d.stroke ?? 'solid'; const arrowType = d.arrowType ?? 'filled'; let arrow: string; - if (d.bidirectional) { + const mode = edge.mode ?? (graph.mode !== 'directed' ? getEdgeMode(graph, edge) : d.bidirectional ? 'bidirectional' : getEdgeMode(graph, edge)); + if (mode === 'undirected') throw new Error('Mermaid sequence: undirected messages cannot be represented'); + if (mode === 'bidirectional') { arrow = stroke === 'dotted' ? '<<-->>' : '<<->>'; } else { arrow = ARROW_MAP[stroke]?.[arrowType] ?? '->>'; @@ -851,6 +967,8 @@ export function toMermaidSequence(graph: MermaidSequenceGraph): string { ); } + for (const event of eventsAfter.get(edge.id) ?? []) addSequenceEvent(event, indent()); + // Emit block-close events after this edge const afters = afterEdge.get(edge.id); if (afters) { @@ -861,6 +979,9 @@ export function toMermaidSequence(graph: MermaidSequenceGraph): string { } } + for (const node of graph.nodes) { + if (node.data?.destroyed && !eventDestroys.has(node.id)) lines.push(` destroy ${node.id}`); + } return lines.join('\n'); } diff --git a/src/formats/mermaid/shared.ts b/src/formats/mermaid/shared.ts index 7473dcf..a824de8 100644 --- a/src/formats/mermaid/shared.ts +++ b/src/formats/mermaid/shared.ts @@ -19,10 +19,24 @@ export const DIRECTION_TO_MERMAID: Record = { // --- String escaping --- +/** Native display strings require Unicode scalars; NUL HTML references normalize. */ +export function getMermaidDisplayText(value: string): string { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code === 0) throw new Error('Mermaid: NUL display text is unrepresentable'); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(++index); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new Error('Mermaid: display text must contain Unicode scalars'); + } else if (code >= 0xdc00 && code <= 0xdfff) throw new Error('Mermaid: display text must contain Unicode scalars'); + } + return value; +} + /** Escape a label for Mermaid output (quotes special chars). */ export function escapeMermaidLabel(s: string): string { + getMermaidDisplayText(s); const entities: Record = { - '\\': '\\\\', + '\\': '#92;', '#': '#35;', '"': '#quot;', ';': '#59;', @@ -38,9 +52,100 @@ export function escapeMermaidLabel(s: string): string { /** Unescape a Mermaid label back to plain text. */ export function unescapeMermaidLabel(s: string): string { - return s + return getMermaidDisplayText(s .replace(/#quot;/g, '"') - .replace(/#(\d+);/g, (_match, code) => String.fromCharCode(Number(code))); + .replace(/#(\d+);/g, (match, code) => { + const point = Number(code); + return Number.isSafeInteger(point) && point <= 0x10ffff ? String.fromCodePoint(point) : match; + })); +} + +export function isMermaidEscaped(input: string, index: number): boolean { + let count = 0; + while (index > 0 && input[--index] === '\\') count++; + return count % 2 === 1; +} + +/** Source annotations preserved independently of the editable graph projection. */ +export interface MermaidSourceData { + source?: { + comments?: string[]; + /** YAML is opaque; retaining it does not imply parsing its configuration. */ + frontmatter?: string; + directives?: Record; + /** Unsupported statements are retained for inspection, never projected as nodes. */ + unknownStatements?: string[]; + }; +} + +/** Split outside quoted strings, brackets and braces; optionally split whitespace. */ +export function getMermaidTokens(input: string, delimiter = ',', escapedQuotes = true): string[] { + const tokens: string[] = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < input.length; i++) { + const char = input[i]; + if (quote) { + if (char === quote && (!escapedQuotes || !isMermaidEscaped(input, i))) quote = ''; + continue; + } + if (char === '"' || char === "'" || char === '`') { quote = char; continue; } + if ('[{('.includes(char)) depth++; + if (']})'.includes(char)) depth = Math.max(0, depth - 1); + if (depth === 0 && !isMermaidEscaped(input, i) && (delimiter === ' ' ? /\s/.test(char) : char === delimiter)) { + const token = input.slice(start, i).trim(); + if (token) tokens.push(token); + start = i + 1; + } + } + const token = input.slice(start).trim(); + if (token) tokens.push(token); + return tokens; +} + +export function getMermaidStyle(input: string): Record { + const properties: Record = Object.create(null); + for (const token of getMermaidTokens(input)) { + const index = token.indexOf(':'); + if (index < 0) continue; + properties[token.slice(0, index).trim()] = token.slice(index + 1).trim().replace(/;$/, ''); + } + return properties; +} + +export function getMermaidProperties(input: string): Record { + const properties: Record = Object.create(null); + for (const token of getMermaidTokens(input)) { + const match = token.match(/^\s*["']?([\w-]+)["']?\s*:\s*([\s\S]*)$/); + if (!match) continue; + const value = match[2].trim(); + let text = value.replace(/^(["'])([\s\S]*)\1$/, '$2'); + if (value.startsWith('"') && value.endsWith('"')) { + try { text = JSON.parse(value); } catch { /* Flowchart strings also permit entity escaping. */ } + } + properties[match[1]] = value === 'true' ? true : value === 'false' ? false + : /^-?\d+(?:\.\d+)?$/.test(value) ? Number(value) + : unescapeMermaidLabel(text); + } + return properties; +} + +export function toMermaidProperties(properties: Record): string { + return Object.entries(properties).map(([key, value]) => `${key}: ${typeof value === 'string' + ? `"${escapeMermaidLabel(value)}"` : value}`).join(', '); +} + +export function getMermaidPreamble(data: MermaidSourceData & { init?: Record }): string[] { + const lines: string[] = []; + if (data.source?.frontmatter) lines.push(data.source.frontmatter); + for (const [key, value] of Object.entries(data.source?.directives ?? {})) { + if (key === 'init' && data.init) continue; + const directive = directiveToString(key, value); + if (directive) lines.push(directive); + } + for (const comment of data.source?.comments ?? []) lines.push(comment); + return lines; } const ENCODED_ID_PREFIX = '__stately_id_'; @@ -63,11 +168,16 @@ export function escapeMermaidId(s: string): string { return `${ENCODED_ID_PREFIX}${encoded}`; } +/** Display fallback for absent labels; unsafe scalar IDs remain safe native aliases. */ +export function getMermaidIdDisplay(id: string): string { + try { return getMermaidDisplayText(id); } catch { return escapeMermaidId(id); } +} + /** Decode a node id escaped by {@link escapeMermaidId} back to its raw form. */ export function unescapeMermaidId(s: string): string { if (s.startsWith(ENCODED_ID_PREFIX)) { const encoded = s.slice(ENCODED_ID_PREFIX.length); - if (encoded.length > 0 && encoded.length % 4 === 0 && /^[0-9a-f]+$/i.test(encoded)) { + if (encoded.length % 4 === 0 && /^[0-9a-f]*$/i.test(encoded)) { let decoded = ''; for (let index = 0; index < encoded.length; index += 4) { decoded += String.fromCharCode(Number.parseInt(encoded.slice(index, index + 4), 16)); @@ -100,17 +210,22 @@ export function stripComments(input: string): string { return input .split('\n') .map((line) => { - const idx = line.indexOf('%%'); + const idx = getMermaidCommentIndex(line); if (idx === -1) return line; - // Don't strip if inside a quoted string before the %% - const before = line.slice(0, idx); - const singleQuotes = (before.match(/"/g) || []).length; - if (singleQuotes % 2 !== 0) return line; // inside quotes return line.slice(0, idx); }) .join('\n'); } +function getMermaidCommentIndex(line: string): number { + let quoted = false; + for (let index = 0; index < line.length; index++) { + if (line[index] === '"' && !isMermaidEscaped(line, index)) quoted = !quoted; + if (!quoted && line.startsWith('%%', index)) return index; + } + return -1; +} + /** * Strip `%%{init: {...}}%%` directives and return them separately. * @@ -146,14 +261,27 @@ export function stripDirectives(input: string): { export function prepareLines(input: string): { lines: string[]; directives: Record; + source?: MermaidSourceData['source']; } { - const { directives, cleaned } = stripDirectives(input); + const frontmatterMatch = input.match(/^\s*(---\r?\n[\s\S]*?\r?\n---)(?:\r?\n|$)/); + const withoutFrontmatter = frontmatterMatch ? input.slice(frontmatterMatch[0].length) : input; + const { directives, cleaned } = stripDirectives(withoutFrontmatter); + const comments = cleaned.split('\n').flatMap((line) => { + const index = getMermaidCommentIndex(line); + if (index < 0) return []; + return [line.slice(index)]; + }); const stripped = stripComments(cleaned); const lines = stripped .split('\n') .map((l) => l.trimEnd()) .filter((l) => l.trim() !== ''); - return { lines, directives }; + const source = { + ...(frontmatterMatch && { frontmatter: frontmatterMatch[1] }), + ...(comments.length && { comments }), + ...(Object.keys(directives).length && { directives }), + }; + return { lines, directives, ...(Object.keys(source).length && { source }) }; } /** diff --git a/src/formats/mermaid/state.ts b/src/formats/mermaid/state.ts index 7222a8b..926a088 100644 --- a/src/formats/mermaid/state.ts +++ b/src/formats/mermaid/state.ts @@ -3,6 +3,8 @@ import { createFormatConverter } from '../converter'; import { validateInput, prepareLines, + getMermaidPreamble, + type MermaidSourceData, escapeMermaidLabel, unescapeMermaidLabel, generateEdgeId, @@ -28,7 +30,7 @@ export interface StateNodeData { export interface StateEdgeData {} -export interface StateGraphData { +export interface StateGraphData extends MermaidSourceData { diagramType: 'stateDiagram'; classDefs?: Record>; } @@ -72,7 +74,7 @@ function isParallelRegionNode( */ export function fromMermaidState(input: string): MermaidStateGraph { validateInput(input, 'Mermaid state'); - const { lines } = prepareLines(input); + const { lines, source } = prepareLines(input); const header = lines[0]?.trim(); if ( @@ -343,7 +345,7 @@ export function fromMermaidState(input: string): MermaidStateGraph { if (!node.data.notes) node.data.notes = []; node.data.notes.push({ position, - text, + text: unescapeMermaidLabel(text), format: inlineText && inlineText.length > 0 ? 'inline' : 'block', }); continue; @@ -476,6 +478,7 @@ export function fromMermaidState(input: string): MermaidStateGraph { edges, data: { diagramType: 'stateDiagram', + ...(source && { source }), ...(Object.keys(classDefs).length > 0 && { classDefs }), }, direction: graphDirection, @@ -492,7 +495,7 @@ export function fromMermaidState(input: string): MermaidStateGraph { * // "stateDiagram-v2\n [*] --> Idle\n ..." */ export function toMermaidState(graph: MermaidStateGraph): string { - const lines: string[] = ['stateDiagram-v2']; + const lines: string[] = [...getMermaidPreamble(graph.data ?? {}), 'stateDiagram-v2']; // Top-level direction if (graph.direction) { @@ -524,6 +527,37 @@ export function toMermaidState(graph: MermaidStateGraph): string { } if (graph.initialNodeId) referencedByEdge.add(graph.initialNodeId); + const edgesByScope = new Map(); + function getAncestorScopes(nodeId: string): Array { + const scopes: Array = []; + const visited = new Set(); + let parentId = nodesById.get(nodeId)?.parentId ?? null; + while (parentId !== null) { + if (visited.has(parentId)) throw new Error('Mermaid state: cyclic hierarchy cannot be emitted'); + visited.add(parentId); + scopes.push(parentId); + parentId = nodesById.get(parentId)?.parentId ?? null; + } + scopes.push(null); + return scopes; + } + for (const edge of graph.edges) { + const targets = new Set(getAncestorScopes(edge.targetId)); + const scope = getAncestorScopes(edge.sourceId).find((ancestor) => targets.has(ancestor)) ?? null; + const entries = edgesByScope.get(scope) ?? []; + entries.push(edge); edgesByScope.set(scope, entries); + } + function writeEdges(parentId: string | null, indent: string) { + for (const edge of edgesByScope.get(parentId) ?? []) { + const sourceNode = nodesById.get(edge.sourceId); + const targetNode = nodesById.get(edge.targetId); + const sourceId = sourceNode?.data?.isStart ? '[*]' : edge.sourceId; + const targetId = targetNode?.data?.isEnd ? '[*]' : edge.targetId; + const label = edge.label ? ` : ${escapeMermaidLabel(edge.label)}` : ''; + lines.push(`${indent}${sourceId} --> ${targetId}${label}`); + } + } + function writeNodes(parentId: string | null, indent: string) { const children = childrenMap.get(parentId) ?? []; for (const node of children) { @@ -597,6 +631,7 @@ export function toMermaidState(graph: MermaidStateGraph): string { } } } + if (parentId !== null) writeEdges(parentId, indent); } writeNodes(null, ' '); @@ -610,22 +645,8 @@ export function toMermaidState(graph: MermaidStateGraph): string { lines.push(` [*] --> ${graph.initialNodeId}`); } - // Emit transitions - for (const edge of graph.edges) { - let sourceId = edge.sourceId; - let targetId = edge.targetId; - - // Map start/end pseudo-nodes back to [*] - const sourceNode = graph.nodes.find((n) => n.id === sourceId); - const targetNode = graph.nodes.find((n) => n.id === targetId); - if (sourceNode?.data?.isStart) sourceId = '[*]'; - if (targetNode?.data?.isEnd) targetId = '[*]'; - - const label = edge.label - ? ` : ${escapeMermaidLabel(edge.label)}` - : ''; - lines.push(` ${sourceId} --> ${targetId}${label}`); - } + // Root transitions stay outside composite declarations. + writeEdges(null, ' '); // Emit classDefs const gd = graph.data; diff --git a/src/formats/mermaid/use-case.ts b/src/formats/mermaid/use-case.ts new file mode 100644 index 0000000..03c54d6 --- /dev/null +++ b/src/formats/mermaid/use-case.ts @@ -0,0 +1,172 @@ +import type { Graph, GraphNode, GraphEdge, GraphFormatConverter } from '../../types'; +import { getEdgeMode } from '../../mode'; +import { createFormatConverter } from '../converter'; +import { escapeMermaidId, unescapeMermaidId, generateEdgeId, getMermaidPreamble, getMermaidIdDisplay, getMermaidProperties, toMermaidProperties, getMermaidStyle, type MermaidSourceData, MERMAID_TO_DIRECTION, DIRECTION_TO_MERMAID } from './shared'; +import { getMermaidProfile, getMermaidProfileGraph, getMermaidProfileText, toMermaidProfileText, getMermaidProfileJSON } from './profile-shared'; +export interface UseCaseNodeData { kind: 'actor' | 'usecase' | 'boundary' | 'note' | 'json'; shape?: 'ellipse' | 'rect'; properties?: Record; stereotype?: string; classes?: string[]; noteTarget?: string; value?: Record } +export interface UseCaseEdgeData { relationship: 'association' | 'include' | 'extend' | 'generalization' | 'note'; operator?: string; minlen?: number; explicitId?: boolean; properties?: Record; classes?: string[] } +export interface UseCaseGraphData extends MermaidSourceData { diagramType: 'usecase'; classDefs?: Array<{ name: string; style: string }> } +export type MermaidUseCaseGraph = Graph; +const ID = '[A-Za-z0-9_]+'; +function getUseCaseProperties(text: string): Record { + const properties = getMermaidProperties(text); + if (Object.keys(properties).some((key) => !['type', 'icon', 'business', 'animate', 'animation'].includes(key))) throw new Error('Mermaid UseCase: unsupported metadata'); + if (properties.business !== undefined && typeof properties.business !== 'boolean' || properties.animate !== undefined && typeof properties.animate !== 'boolean') throw new Error('Mermaid UseCase: metadata requires booleans'); + return properties; +} +export function fromMermaidUseCase(input: string): MermaidUseCaseGraph { + const { lines, source } = getMermaidProfile(input, /^usecase-beta$/, 'UseCase'); + const nodes = new Map>(); const edges: GraphEdge[] = []; const classDefs: NonNullable = []; + let boundary: string | null = null; let direction: Graph['direction']; let noteCounter = 0; let relationCounter = 0; + const nativeIds = new Map(); + function getId(raw: string): string { const id = unescapeMermaidId(raw); if (nativeIds.has(id) && nativeIds.get(id) !== raw) throw new Error('Mermaid UseCase: ambiguous encoded native ID'); nativeIds.set(id, raw); return id; } + const reservedIds = new Set(lines.flatMap((line) => { const match = line.trim().match(/^(?:actor\s+|systemBoundary\s+|json\s+)?([A-Za-z0-9_]+)/); return match ? [unescapeMermaidId(match[1])] : []; })); + function getNode(id: string) { + if (!nodes.has(id)) nodes.set(id, { type: 'node', id, label: getMermaidIdDisplay(id), parentId: null, initialNodeId: null, data: { kind: 'usecase', shape: 'ellipse' } }); + return nodes.get(id)!; + } + for (let index = 1; index < lines.length; index++) { + const line = lines[index].trim(); + if (line === 'end') { if (boundary === null) throw new Error('Mermaid UseCase: unmatched end'); boundary = null; continue; } + const dir = line.match(/^direction\s+(TB|TD|BT|LR|RL)$/); + if (dir) { if (boundary !== null) throw new Error('Mermaid UseCase: direction inside boundary'); direction = MERMAID_TO_DIRECTION[dir[1]]; continue; } + const note = line.match(new RegExp(`^note\\s+for\\s+(${ID})\\s+("[^"\\n]*")$`)); + if (note) { + if (boundary !== null) throw new Error('Mermaid UseCase: note inside boundary'); + const target = getId(note[1]); let id = `__note_${noteCounter++}`; while (reservedIds.has(id) || nodes.has(id)) id = `__note_${noteCounter++}`; + nodes.set(id, { type: 'node', id, label: getMermaidProfileText(note[2]), parentId: null, initialNodeId: null, data: { kind: 'note', noteTarget: target } }); + edges.push({ type: 'edge', id: `note:${JSON.stringify([id, target])}`, sourceId: id, targetId: target, label: null, mode: 'undirected', data: { relationship: 'note' } }); continue; + } + const json = line.match(new RegExp(`^json\\s+(${ID})@\\{([\\s\\S]*)$`)); + if (json) { + if (boundary !== null) throw new Error('Mermaid UseCase: JSON node inside boundary'); + let body = `{${json[2]}`; + while (!body.trim().endsWith('}') && index + 1 < lines.length) body += `\n${lines[++index]}`; + let value: any; try { value = JSON.parse(body); } catch { throw new Error('Mermaid UseCase: invalid JSON object'); } + if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error('Mermaid UseCase: JSON node requires object'); + const id = getId(json[1]); if (nodes.has(id)) throw new Error('Mermaid UseCase: duplicate JSON ID'); + nodes.set(id, { type: 'node', id, label: getMermaidIdDisplay(id), parentId: null, initialNodeId: null, data: { kind: 'json', value } }); continue; + } + const include = line.match(new RegExp(`^(${ID})\\s+(?:(${ID})@)?(\\.\\.>|--\\|>)\\s*(?::\\s*(include|extend)\\s+)?(${ID})$`)); + const association = line.match(new RegExp(`^(${ID})\\s+(?:(${ID})@)?(--+>|<--+|--+|--o|o--|--x|x--)(?:\\s+(${ID})|\\s+(.+?)\\s+(--+>|<--+|--+|--o|o--|--x|x--|--\\|>)\\s+(${ID}))$`)); + if (include || association) { + if (boundary !== null) throw new Error('Mermaid UseCase: relationship inside boundary'); + const match = (include ?? association)!; + const rawOperator = include ? match[3] : association![6] ?? match[3]; + const relationship: UseCaseEdgeData['relationship'] = include ? match[3] === '--|>' ? 'generalization' : match[4] as 'include' | 'extend' : 'association'; + if (include && !relationship) throw new Error('Mermaid UseCase: dotted relation requires include/extend'); + const left = getId(match[1]); const right = getId(include ? match[5] : association![7] ?? association![4]); + const reverse = rawOperator.startsWith('<') || rawOperator.startsWith('o') || rawOperator.startsWith('x'); + const sourceId = reverse ? right : left; const targetId = reverse ? left : right; + getNode(sourceId); getNode(targetId); + const mode = /^--+$/.test(rawOperator) ? 'undirected' : 'directed'; + const id = match[2] ? getId(match[2]) : generateEdgeId(sourceId, targetId, relationCounter++); + if (edges.some((edge) => edge.id === id) || nodes.has(id)) throw new Error('Mermaid UseCase: duplicate relationship ID'); + edges.push({ type: 'edge', id, sourceId, targetId, label: association?.[5] ? getMermaidProfileText(association[5]) : null, mode, + data: { relationship, operator: rawOperator, ...(relationship === 'association' && !/[ox]/.test(rawOperator) && { minlen: rawOperator.replace(/[<>]/g, '').length - 1 }), ...(match[2] && { explicitId: true }) } }); continue; + } + const classDef = line.match(/^classDef\s+([A-Za-z0-9_,]+)\s+(.+)$/); + if (classDef) { if (boundary !== null) throw new Error('Mermaid UseCase: style inside boundary'); for (const name of classDef[1].split(',')) classDefs.push({ name, style: classDef[2] }); continue; } + const decoration = line.match(/^(class|style)\s+([A-Za-z0-9_,]+)\s+(.+)$/); + if (decoration) { + if (boundary !== null) throw new Error('Mermaid UseCase: style inside boundary'); + for (const rawId of decoration[2].split(',')) { + const id = getId(rawId); const entity = nodes.get(id) ?? edges.find((edge) => edge.id === id); + if (!entity) throw new Error('Mermaid UseCase: unknown style target'); + if (decoration[1] === 'class') entity.data.classes = decoration[3].split(','); else entity.style = getMermaidStyle(decoration[3]); + } + continue; + } + const metadata = line.match(new RegExp(`^(${ID})@\\{(.*)\\}$`)); + if (metadata) { + if (boundary !== null) throw new Error('Mermaid UseCase: metadata inside boundary'); + const id = getId(metadata[1]); const entity = nodes.get(id) ?? edges.find((edge) => edge.id === id); + if (!entity) throw new Error('Mermaid UseCase: unknown metadata target'); + entity.data.properties = { ...entity.data.properties, ...getUseCaseProperties(metadata[2]) }; continue; + } + const declaration = line.match(new RegExp(`^(actor\\s+|systemBoundary\\s+)?(${ID})(?:\\(("[^"\\n]*"|[^()]*)\\)|\\[("[^"\\n]*"|[^\\[\\]]*)\\])?(?:@\\{(.*?)\\})?(?:\\s*<<([^<>]*)>>)?(?::::([A-Za-z0-9_,]+))?$`)); + if (!declaration) throw new Error(`Mermaid UseCase: unsupported statement ${line}`); + const kind = declaration[1]?.startsWith('actor') ? 'actor' : declaration[1] ? 'boundary' : 'usecase'; + const id = getId(declaration[2]); + if (edges.some((edge) => edge.data.explicitId && edge.id === id)) throw new Error('Mermaid UseCase: node conflicts with explicit edge ID'); + const node = getNode(id); + if (kind === 'boundary' && boundary !== null || node.data.kind !== 'usecase' && node.data.kind !== kind) throw new Error('Mermaid UseCase: incompatible kind/nested boundary'); + if (node.parentId != null && node.parentId !== boundary) throw new Error('Mermaid UseCase: element has multiple boundaries'); + node.label = declaration[3] !== undefined || declaration[4] !== undefined ? getMermaidProfileText(declaration[3] ?? declaration[4]) : getMermaidIdDisplay(id); + node.parentId = kind === 'boundary' ? null : boundary; + node.data = { kind, ...(kind === 'usecase' && { shape: declaration[4] !== undefined ? 'rect' : 'ellipse' }), ...(declaration[5] && { properties: getUseCaseProperties(declaration[5]) }), ...(declaration[6] && { stereotype: declaration[6] }), ...(declaration[7] && { classes: declaration[7].split(',') }) }; + if (kind === 'boundary') boundary = id; + } + if (boundary !== null) throw new Error('Mermaid UseCase: unclosed boundary'); + const graph = getMermaidProfileGraph([...nodes.values()].sort((a, b) => Number(a.data.kind === 'note') - Number(b.data.kind === 'note')), [...edges].sort((a, b) => Number(a.data.relationship === 'note') - Number(b.data.relationship === 'note')), { diagramType: 'usecase' as const, ...(source && { source }), ...(classDefs.length && { classDefs }) }); + for (const edge of edges) { + const a = nodes.get(edge.sourceId)!.data.kind; const b = nodes.get(edge.targetId)!.data.kind; + if (edge.data.relationship === 'include' || edge.data.relationship === 'extend') { if (a !== 'usecase' || b !== 'usecase') throw new Error('Mermaid UseCase: include/extend require use cases'); } + if (edge.data.relationship === 'generalization' && (a !== b || !['actor', 'usecase'].includes(a))) throw new Error('Mermaid UseCase: generalization requires matching kinds'); + if (edge.data.relationship === 'note' && !['actor', 'usecase'].includes(b)) throw new Error('Mermaid UseCase: invalid note target'); + if (a === 'boundary' || b === 'boundary') throw new Error('Mermaid UseCase: boundary cannot be relationship endpoint'); + } + return { ...graph, ...(direction && { direction }) }; +} +export function toMermaidUseCase(graph: MermaidUseCaseGraph): string { + const lines = [...getMermaidPreamble(graph.data ?? {}), 'usecase-beta']; + if (graph.direction) lines.push(`direction ${DIRECTION_TO_MERMAID[graph.direction]}`); + const nodes = new Map(graph.nodes.map((node) => [node.id, node])); + function toDeclaration(node: GraphNode): string { + const kind = node.data?.kind ?? 'usecase'; const id = escapeMermaidId(node.id); const text = `"${toMermaidProfileText(node.label ?? getMermaidIdDisplay(node.id))}"`; + if (kind === 'json') { + if (node.label != null && node.label !== node.id || !node.data.value || typeof node.data.value !== 'object' || Array.isArray(node.data.value)) throw new Error('Mermaid UseCase: invalid JSON node value'); + return `json ${id}@${getMermaidProfileJSON(node.data.value)}`; + } + const suffix = `${node.data?.properties ? `@{ ${toMermaidProperties(node.data.properties)} }` : ''}${node.data?.stereotype ? ` <<${toMermaidProfileText(node.data.stereotype)}>>` : ''}${node.data?.classes?.length ? `:::${node.data.classes.join(',')}` : ''}`; + return `${kind === 'actor' ? 'actor ' : kind === 'boundary' ? 'systemBoundary ' : ''}${id}${kind === 'boundary' || node.data?.shape === 'rect' ? `[${text}]` : `(${text})`}${suffix}`; + } + const emitted = new Set(); + for (const node of graph.nodes) if (node.ports?.length) throw new Error('Mermaid UseCase: ports unsupported'); + const noteEdges = new Map(); + for (const edge of graph.edges) if (edge.data?.relationship === 'note') { const entries = noteEdges.get(edge.sourceId) ?? []; entries.push(edge); noteEdges.set(edge.sourceId, entries); } + for (const node of graph.nodes.filter((node) => node.parentId == null)) { + const kind = node.data?.kind ?? 'usecase'; + if (kind === 'note') continue; + lines.push(toDeclaration(node)); emitted.add(node.id); + if (kind === 'boundary') { + for (const child of graph.nodes.filter((child) => child.parentId === node.id)) { if (!['actor', 'usecase'].includes(child.data?.kind ?? 'usecase')) throw new Error('Mermaid UseCase: boundary only contains actors/usecases'); lines.push(` ${toDeclaration(child)}`); emitted.add(child.id); } + lines.push('end'); + } + } + for (const node of graph.nodes) { + if (node.data?.kind === 'note') { + if (!node.data.noteTarget || !nodes.has(node.data.noteTarget) || noteEdges.get(node.id)?.length !== 1) throw new Error('Mermaid UseCase: stale note target'); + lines.push(`note for ${escapeMermaidId(node.data.noteTarget)} "${toMermaidProfileText(node.label ?? '')}"`); emitted.add(node.id); + } + } + if (emitted.size !== graph.nodes.length) throw new Error('Mermaid UseCase: missing/cyclic/nested boundary'); + for (const edge of graph.edges) { + if (edge.data?.relationship === 'note') { if (nodes.get(edge.sourceId)?.data.noteTarget !== edge.targetId || getEdgeMode(graph, edge) !== 'undirected') throw new Error('Mermaid UseCase: inconsistent note edge'); continue; } + if (edge.sourcePort || edge.targetPort) throw new Error('Mermaid UseCase: ports unsupported'); + const mode = getEdgeMode(graph, edge); const relation = edge.data?.relationship ?? 'association'; + if (mode === 'bidirectional' || relation !== 'association' && mode !== 'directed') throw new Error('Mermaid UseCase: incompatible relationship mode'); + const id = edge.data?.explicitId ? `${escapeMermaidId(edge.id)}@` : ''; + const source = escapeMermaidId(edge.sourceId); const target = escapeMermaidId(edge.targetId); + if (relation === 'include' || relation === 'extend') lines.push(`${source} ${id}..> : ${relation} ${target}`); + else if (relation === 'generalization') lines.push(`${source} ${id}--|> ${target}`); + else { + const original = edge.data?.operator ?? '-->'; const reverse = /^[ 10000) throw new Error('Mermaid UseCase: invalid bounded edge length'); + const dashes = '-'.repeat(minlen + 1); + const operator = mode === 'undirected' ? dashes : marker ? `${dashes}${marker}` : `${dashes}>`; + const left = reverse && mode === 'directed' ? target : source; const right = reverse && mode === 'directed' ? source : target; + const arrow = reverse && mode === 'directed' ? marker ? `${marker}${dashes}` : `<${dashes}` : operator; + lines.push(`${left} ${id}${edge.label != null ? `-- "${toMermaidProfileText(edge.label)}" ${arrow}` : arrow} ${right}`); + } + if (edge.data?.properties) { if (!id) throw new Error('Mermaid UseCase: edge metadata requires explicit ID'); lines.push(`${escapeMermaidId(edge.id)}@{ ${toMermaidProperties(edge.data.properties)} }`); } + } + for (const definition of graph.data?.classDefs ?? []) lines.push(`classDef ${definition.name} ${definition.style}`); + for (const entity of [...graph.nodes, ...graph.edges]) { + if (entity.data?.classes?.length && entity.type === 'edge') lines.push(`class ${escapeMermaidId(entity.id)} ${entity.data.classes.join(',')}`); + if (entity.style) lines.push(`style ${escapeMermaidId(entity.id)} ${Object.entries(entity.style).map(([key, value]) => `${key}:${value}`).join(',')}`); + } + const result = lines.join('\n'); fromMermaidUseCase(result); return result; +} +export const mermaidUseCaseConverter: GraphFormatConverter = createFormatConverter(toMermaidUseCase, fromMermaidUseCase); diff --git a/src/formats/networkx/README.md b/src/formats/networkx/README.md new file mode 100644 index 0000000..214b4cf --- /dev/null +++ b/src/formats/networkx/README.md @@ -0,0 +1,29 @@ +# NetworkX node-link JSON + +Dependency-free conversion of NetworkX node-link JSON. Python objects and arbitrary hashable Python IDs are outside the adapter's JSON scalar profile. + + +```ts +import { toNetworkXNodeLink, fromNetworkXNodeLink, networkxDocumentConverter } from '@statelyai/graph/networkx'; +const source = toNetworkXNodeLink(graph); // current `edges` collection +const legacy = toNetworkXNodeLink(graph, { edgeKey: 'links' }); +const restored = fromNetworkXNodeLink(source); +const document = networkxDocumentConverter.from(source); +const unchanged = networkxDocumentConverter.to(document); +``` + +`networkxConverter` exposes `.to(graph)` and `.from(source)`. Fresh output is a multigraph with string node IDs and string edge keys. NetworkX has one global edge direction; genuine mixed/bidirectional Graph modes throw. Import accepts exactly one `edges` or legacy `links` array. Bare export defaults to current `edges`; document patches retain the original collection name. + +Native IDs and multigraph keys support strings, finite numbers and booleans. Foreign IDs receive typed, collision-free Graph string IDs: numeric `1` and string `'1'` remain distinct; Python-equivalent `true`, `1` and `1.0` share identity and duplicate declarations reject. Null, array/tuple and object identities have no bare projection. Multiedge identity includes endpoints and typed key, with endpoint order normalized for undirected graphs. Missing keys follow NetworkX's integer allocation; edge reorder materializes those keys when required to retain their identity. Missing endpoints and duplicate equivalent tuple keys throw. + +Native representable labels, geometry, styles and weights map Graph fields. Other attributes, including Graph-looking fields with incompatible types, remain user data. Graph data in fresh exports occupies a separate `data` attribute; native data edits update it directly. Graph-only optional fields/presence use versioned `__statelyai` metadata with a NetworkX origin. + + +Imported native provenance preserves source IDs, keys, unknown fields and surviving entity attributes through JSON persistence and Graph reorder/insert/delete. Live native edits take precedence. Use `{ metadata: 'native' }` on import or `{ nativeCarrier: false }` on export to disable automatic convention interpretation. Exact lookalike private payloads require an explicit override; this is not a collision-free provenance guarantee. + +Documents retain exact original scalar-ID JSON, ordering, collection names and unknown attributes. Unprojectable JSON identities are retained with `graph: null` and diagnostics. Bounded label/data/style/geometry/weight edits patch native entities by stable identity and verify the resulting Graph projection. Structural, mode, parent, port and ambiguous edits reject. `.getUpdate(document)` returns diagnostics; source edits survive unless they conflict with requested changes. The supported semantic profile is partial. + +Bare export preserves an imported simple/multigraph profile. Compatible node/edge insertion retains full Graph identities against the final native IDs; parallel insertion into a simple graph rejects. Retargeted foreign edges retain their Graph ID through identity metadata while their actual native endpoints change. Unknown attributes and existing private-looking properties stay intact; metadata uses a free numbered key when needed. + + +Native proof uses pinned Python NetworkX 3.7 `node_link_graph`/`node_link_data`: native flags, scalar IDs, per-pair keys, loops, parallels, legacy links, data edits and native defaults. [Official node-link API](https://networkx.org/documentation/stable/reference/readwrite/generated/networkx.readwrite.json_graph.node_link_data.html). diff --git a/src/formats/networkx/index.ts b/src/formats/networkx/index.ts new file mode 100644 index 0000000..1ede4f1 --- /dev/null +++ b/src/formats/networkx/index.ts @@ -0,0 +1,140 @@ +import type { Graph } from '../../types'; +import { createFormatConverter } from '../converter'; +import { createGraphDocumentConverter } from '../document'; +import { createNativeCarrier, getNativeCarrier, getCarrierData, getClonedJSON, getNativeDocumentGraph, type FormatImportOptions, type FormatExportOptions } from '../preservation'; +import { getAttributeRecord, getNativeAttributes, getUpdatedAttributes, getNodeBase, getEdgeBase, getRootBase, getValidatedGraph, getUniformMode, getAttributeDocumentPatch, getNativeMetadata, getRebasedAttributes } from '../structured-json'; + +export type NetworkXID = string | number | boolean; +export interface NetworkXNodeLinkJSON { + directed: boolean; + multigraph: boolean; + graph: Record; + nodes: Array<{ id: NetworkXID; [attribute: string]: any }>; + edges?: Array<{ source: NetworkXID; target: NetworkXID; key?: NetworkXID; [attribute: string]: any }>; + links?: Array<{ source: NetworkXID; target: NetworkXID; key?: NetworkXID; [attribute: string]: any }>; +} +export interface NetworkXImportOptions extends FormatImportOptions {} +export interface NetworkXExportOptions extends FormatExportOptions { edgeKey?: 'edges' | 'links' } + +/** Python numeric/bool equality is part of native identity; strings remain distinct. */ +function getIdentity(id: unknown): string { + if (typeof id === 'string') return JSON.stringify(['string', id]); + if (typeof id === 'boolean') return JSON.stringify(['number', Number(id)]); + if (typeof id === 'number' && Number.isFinite(id)) return JSON.stringify(['number', id]); + throw new Error('NetworkX: IDs and multigraph keys must be finite numbers, booleans or strings; null/tuple/object IDs are unsupported'); +} +function getEdges(source: NetworkXNodeLinkJSON): NonNullable { + if (Object.hasOwn(source, 'edges') && Object.hasOwn(source, 'links')) throw new Error('NetworkX: ambiguous edges and links collections'); + const edges = source.edges ?? source.links; + if (!Array.isArray(edges)) throw new Error('NetworkX: expected an edges or links array'); + return edges; +} +function getAttributes(record: Record, keys: string[]): Record { + return Object.fromEntries(Object.entries(record).filter(([key]) => !keys.includes(key))); +} +function getValidatedSource(source: NetworkXNodeLinkJSON): Array<{ identity: string; key?: NetworkXID }> { + if (!source || !Array.isArray(source.nodes) || !source.graph || typeof source.graph !== 'object' || Array.isArray(source.graph) || typeof source.directed !== 'boolean' || typeof source.multigraph !== 'boolean') throw new Error('NetworkX: expected native directed/multigraph flags, graph attributes and nodes'); + const nodes = new Set(); + for (const node of source.nodes) { + const identity = getIdentity(node.id); + if (nodes.has(identity)) throw new Error('NetworkX: duplicate Python-equivalent node IDs'); + nodes.add(identity); + } + const pairs = new Map>(); + return getEdges(source).map((edge) => { + const endpoints = [getIdentity(edge.source), getIdentity(edge.target)]; + if (endpoints.some((id) => !nodes.has(id))) throw new Error('NetworkX: missing edge endpoint'); + if (!source.directed) endpoints.sort(); + const pair = JSON.stringify(endpoints); + const keys = pairs.get(pair) ?? new Set(); + let key = edge.key; + if (source.multigraph && key === undefined) { + let generated = keys.size; + while (keys.has(getIdentity(generated))) generated++; + key = generated; + } + const identity = source.multigraph ? getIdentity(key) : 'simple'; + if (keys.has(identity)) throw new Error('NetworkX: duplicate native edge tuple/key'); + keys.add(identity); pairs.set(pair, keys); + return { identity: JSON.stringify([pair, identity]), ...(source.multigraph && { key }) }; + }); +} + +/** Import the JSON scalar-ID profile without conflating numeric and string identities. */ +export function fromNetworkXNodeLink(source: NetworkXNodeLinkJSON, options: NetworkXImportOptions = {}): Graph { + const identities = getValidatedSource(source); + const mode = source.directed ? 'directed' : 'undirected'; + const nodes = source.nodes.map((node) => getAttributeRecord(getAttributes(node, ['id']), getNodeBase(`networkx:${getIdentity(node.id)}`), 'node', 'networkx', options.metadata === 'native')); + const ids = new Map(source.nodes.map((node, index) => [getIdentity(node.id), nodes[index].id])); + const graph: Graph = { ...getAttributeRecord(source.graph, getRootBase(mode), 'graph', 'networkx', options.metadata === 'native'), nodes, + edges: getEdges(source).map((edge, index) => getAttributeRecord(getAttributes(edge, source.multigraph ? ['source', 'target', 'key'] : ['source', 'target']), getEdgeBase(`networkx:${identities[index].identity}`, ids.get(getIdentity(edge.source))!, ids.get(getIdentity(edge.target))!), 'edge', 'networkx', options.metadata === 'native')) }; + const rootMetadata = options.metadata === 'native' ? undefined : getNativeMetadata(source.graph, 'networkx', 'graph'); + if (rootMetadata && rootMetadata.mapped.mode !== mode) for (const edge of graph.edges) delete edge.mode; + getValidatedGraph(graph, 'NetworkX'); + graph.data = createNativeCarrier(graph.data, 'networkx', source, graph); + return graph; +} + +function toRawNetworkXNodeLink(graph: Graph, edgeKey: 'edges' | 'links'): NetworkXNodeLinkJSON { + getValidatedGraph(graph, 'NetworkX'); + const mode = getUniformMode(graph, 'NetworkX'); + const { nodes, edges, ...root } = graph; + const source: NetworkXNodeLinkJSON = { directed: mode === 'directed', multigraph: true, graph: getNativeAttributes(root, 'graph', 'networkx', getRootBase(mode)), + nodes: nodes.map((node) => ({ ...getNativeAttributes(node, 'node', 'networkx', getNodeBase(`networkx:${getIdentity(node.id)}`)), id: node.id })) }; + source[edgeKey] = edges.map((edge) => { + const endpoints = [getIdentity(edge.sourceId), getIdentity(edge.targetId)]; + if (mode === 'undirected') endpoints.sort(); + const identity = JSON.stringify([JSON.stringify(endpoints), getIdentity(edge.id)]); + return { ...getNativeAttributes(edge, 'edge', 'networkx', getEdgeBase(`networkx:${identity}`, edge.sourceId, edge.targetId)), source: edge.sourceId, target: edge.targetId, key: edge.id }; + }); + getValidatedSource(source); return source; +} + +/** Current `edges` by default; opt into legacy `links`. Mixed/bidirectional modes throw. */ +export function toNetworkXNodeLink(graph: Graph, options: NetworkXExportOptions = {}): NetworkXNodeLinkJSON { + const edgeKey = options.edgeKey ?? 'edges'; + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, 'networkx'); + if (!carrier) return toRawNetworkXNodeLink(graph, edgeKey); + const current = { ...graph, data: getCarrierData(graph.data, 'networkx') }; + const before = carrier.baseline as Graph; + const source = getClonedJSON(carrier.native) as NetworkXNodeLinkJSON; + const sourceEdges = getEdges(source); + const sourceIdentities = getValidatedSource(source); + const raw = toRawNetworkXNodeLink(current, edgeKey); + const nodes = new Map(before.nodes.map((node, index) => [node.id, { node, native: source.nodes[index] }])); + const nativeIDs = new Map(current.nodes.map((node) => [node.id, nodes.get(node.id)?.native.id ?? node.id])); + const edges = new Map(before.edges.map((edge, index) => [edge.id, { edge, native: sourceEdges[index], index }])); + source.nodes = current.nodes.map((node, index) => { + const old = nodes.get(node.id); + return old ? { ...getUpdatedAttributes(getAttributes(old.native, ['id']), old.node, node, 'node', 'networkx', getNodeBase(`networkx:${getIdentity(old.native.id)}`)), id: old.native.id } : raw.nodes[index]; + }); + const updated = current.edges.map((edge, index) => { + const old = edges.get(edge.id); + if (!old) { + const record = { ...raw[edgeKey]![index], source: nativeIDs.get(edge.sourceId)!, target: nativeIDs.get(edge.targetId)! }; + if (!source.multigraph) delete record.key; + return record; + } + const attrs = getUpdatedAttributes(getAttributes(old.native, source.multigraph ? ['source', 'target', 'key'] : ['source', 'target']), old.edge, edge, 'edge', 'networkx', getEdgeBase(`networkx:${sourceIdentities[old.index].identity}`, edge.sourceId, edge.targetId)); + return { ...attrs, source: nativeIDs.get(edge.sourceId)!, target: nativeIDs.get(edge.targetId)!, ...(source.multigraph && (Object.hasOwn(old.native, 'key') || index !== old.index) && { key: Object.hasOwn(old.native, 'key') ? old.native.key : sourceIdentities[old.index].key }) }; + }); + delete source.edges; delete source.links; source[edgeKey] = updated; + const { nodes: _beforeNodes, edges: _beforeEdges, ...beforeRoot } = before; + const { nodes: _nodes, edges: _edges, ...root } = current; + source.graph = getUpdatedAttributes(source.graph, beforeRoot, root, 'graph', 'networkx', getRootBase(getUniformMode(current, 'NetworkX'))) ?? {}; + source.directed = getUniformMode(current, 'NetworkX') === 'directed'; + const finalIdentities = getValidatedSource(source); + source[edgeKey] = source[edgeKey]!.map((record, index) => ({ + ...getRebasedAttributes(getAttributes(record, source.multigraph ? ['source', 'target', 'key'] : ['source', 'target']), current.edges[index], 'edge', 'networkx', getEdgeBase(`networkx:${finalIdentities[index].identity}`, current.edges[index].sourceId, current.edges[index].targetId)), + source: record.source, target: record.target, ...(source.multigraph && Object.hasOwn(record, 'key') && { key: record.key }), + })); + return source; +} + +export const networkxConverter = createFormatConverter(toNetworkXNodeLink, fromNetworkXNodeLink); +/** Exact original scalar-ID JSON, with strict known-field edits by verified identity. */ +export const networkxDocumentConverter = createGraphDocumentConverter({ + format: 'networkx', codec: { from: fromNetworkXNodeLink, to: (graph) => toNetworkXNodeLink(graph, { nativeCarrier: false }) }, + project: (source: NetworkXNodeLinkJSON) => ({ graph: getNativeDocumentGraph(fromNetworkXNodeLink(source), 'networkx', source) }), + patch: (context) => getAttributeDocumentPatch(context, 'networkx', (graph) => toNetworkXNodeLink({ ...graph, data: createNativeCarrier(graph.data, 'networkx', context.source, context.sourceGraph) }, { edgeKey: context.source.links ? 'links' : 'edges' })), +}); diff --git a/src/formats/pajek/README.md b/src/formats/pajek/README.md new file mode 100644 index 0000000..86afe0f --- /dev/null +++ b/src/formats/pajek/README.md @@ -0,0 +1,72 @@ +# Pajek .net + + + +```ts +import { + fromPajek, toPajek, pajekDocumentConverter, +} from '@statelyai/graph/pajek'; + +const graph = fromPajek(text); +const output = toPajek(graph); // unchanged imported text +const fresh = toPajek(graph, { nativeCarrier: false }); +const document = pajekDocumentConverter.from(text); +if (document.graph) document.graph.nodes[0].label = 'Updated'; +const edited = pajekDocumentConverter.to(document); +``` + +Dependency-free. `pajekConverter`, `createPajekDocumentConverter(options)`, +`PajekImportOptions` and `PajekExportOptions` are also exported. + + + +Supports one ordinary `*Vertices N` set and uniform `*Arcs` or `*Edges` sections, +optional `*Network`, comments, vertex labels, numeric weights, edge `l` labels, +parallel edges and isolates. Integer vertex identity is independent of labels; +repeated labels remain distinct vertices. Missing vertex definitions create +unlabelled isolates. Native row attributes remain ordered token data. + +Mixed sections, temporal/bipartite/project/matrix/list dialects, invalid references +and ambiguous numeric vertex-label/geometry syntax reject projection. Source strings are +bounded to 16 × 1024² UTF-16 code units and vertex count to one million. Fresh visible strings require +Unicode scalar single-line values without embedded quotes or control characters +(other than TAB). Backslashes remain literal; C readers do not provide portable +escaped-quote semantics. Missing/null vertex labels omit the native label; explicit +empty strings emit `""`. Null is a Graph distinction, not a native nullable string. + +Fresh output stores versioned per-entity metadata in **one comment header before +`*Vertices`**. Vertex entries bind to integer identity; edge entries bind to endpoint +pair plus occurrence. This preserves Graph IDs, hierarchy, ports, geometry and user +data without inserting comments into fixed-count vertex or edge records. Legacy +adjacent metadata comments remain importable. Recorded native baselines ensure live +labels, weights and section direction override stale private fields. Unknown native +attributes added to primitive user data use a discriminated wrapper without +replacing that data. `{ metadata: 'native' }` disables interpreting private markers. +Arbitrary private-marker lookalikes require explicit import policy. + + + +Document no-op output retains **exact source text**, including comments and +unsupported dialects, after JSON persistence; original file encoding lies outside this string API. +Unsupported projection has +`graph: null` and diagnostics. Safe patches update literal vertex/edge labels or +insert missing labels locally; independent source comments/attributes survive. +Same-field conflicts reject. Structure, weights, data, geometry, styles and null +label requests require explicit regeneration and loss diagnostics through +`.getUpdate(document, { mode: 'regenerate' })`. Strict `.to` never silently flattens +an unsupported dialect or returns stale source. + +Foreign edges use endpoint/occurrence identity. Independent changes to an unkeyed +edge's attributes/topology alongside a requested edge-label edit reject as +ambiguous. Header metadata is also occurrence-bound; it cannot supply stable +external edge identity across arbitrary parallel-edge reordering. + + + +Actual igraph 1.0.0 C/Python reads validate directed/undirected topology, repeated +labels, isolates, parallel edges, comments and numeric weights. NetworkX 3.7 tests +cover the unique-labelled vertex subset: its Pajek reader identifies nodes by +label and reads a fixed count of physical vertex rows. It cannot certify repeated +label identity, missing-label semantics, comments inside vertex records or mixed +section semantics. These independent readers prove the tested bounded dialect, +not complete Pajek project compatibility. diff --git a/src/formats/pajek/index.ts b/src/formats/pajek/index.ts new file mode 100644 index 0000000..6c74a21 --- /dev/null +++ b/src/formats/pajek/index.ts @@ -0,0 +1,210 @@ +import { createFormatData } from '../xml-preservation'; +import type { Graph } from '../../types'; +import { createFormatConverter } from '../converter'; +import { createGraphDocumentConverter, type GraphDocumentPatchContext, type GraphDocumentPatchResult } from '../document'; +import { createMetadata, getMetadata, getRestoredRecord, createNativeCarrier, getNativeCarrier, getCarrierData, getClonedJSON, getNativeDocumentGraph, isJSONEqual, type FormatImportOptions, type FormatExportOptions } from '../preservation'; +import { getValidatedGraph, getUniformMode } from '../structured-json'; +import { getEditedSource, getLabelChangeError, getUnsupported, type TextEdit } from '../xml-document'; + +export interface PajekImportOptions extends FormatImportOptions {} +export interface PajekExportOptions extends FormatExportOptions {} +interface Token { value: string; start: number; end: number; quoted: boolean } +interface Row { tokens: Token[]; start: number; end: number; metadata?: ReturnType } +interface Parsed { count: number; mode: 'directed' | 'undirected'; name: string; nodes: Map; edges: Row[]; rootMetadata?: ReturnType; insertion: number } +const FORMAT = 'pajek', PREFIX = '% @statelyai/graph/pajek '; +function getRecord(mapped: any, metadata?: ReturnType): any { + const record = getRestoredRecord(mapped, metadata); + if (metadata && !isJSONEqual(mapped.data, metadata.mapped.data)) record.data = createFormatData(metadata.original.data, FORMAT, { native: mapped.data }); + return record; +} + +function getInput(source: string, options: PajekImportOptions) { return { source, options: getClonedJSON(options) }; } +function getTokens(line: string, start: number): Token[] { + const tokens: Token[] = []; let cursor = 0; + while (cursor < line.length) { + if (/\s/.test(line[cursor])) { cursor++; continue; } + const begin = cursor, quoted = line[cursor] === '"'; + if (quoted) { + cursor++; const valueStart = cursor; + while (cursor < line.length && line[cursor] !== '"') cursor++; + if (cursor === line.length) throw new Error('Pajek: unterminated quoted token'); + tokens.push({ value: line.slice(valueStart, cursor), start: start + begin, end: start + ++cursor, quoted }); + if (cursor < line.length && !/\s/.test(line[cursor])) throw new Error('Pajek: quoted token must have an unambiguous boundary'); + } else { + while (cursor < line.length && !/\s/.test(line[cursor])) cursor++; + const value = line.slice(begin, cursor); + if (value.includes('"')) throw new Error('Pajek: quotes inside native labels are unsupported'); + tokens.push({ value, start: start + begin, end: start + cursor, quoted }); + } + } + return tokens; +} +function getInteger(token: Token | undefined, count: number): number { + if (!token || token.quoted || !/^\d+$/.test(token.value)) throw new Error('Pajek: integer identity required'); + const id = Number(token.value); + if (!Number.isSafeInteger(id) || id < 1 || id > count) throw new Error('Pajek: vertex reference outside declared range'); + return id; +} +function getParsed(source: string, native: boolean): Parsed { + if (source.length > 16 * 1024 * 1024) throw new Error('Pajek: input exceeds 16MiB'); + const result: Parsed = { count: -1, mode: 'undirected', name: '', nodes: new Map(), edges: [], insertion: source.length }; + let position = 0, section = '', edgeMode = '', pending: ReturnType; + let capsule: any; + const nodeMetadata = new Map>(), edgeMetadata = new Map>(); + for (const line of source.split(/\r\n|\r|\n/)) { + const start = position, end = start + line.length; + position = end + (source.slice(end, end + 2) === '\r\n' ? 2 : 1); + if (!line.trim()) continue; + if (line.trimStart().startsWith('%')) { + if (!native && line.startsWith(PREFIX + 'document ')) { + let value: any; try { value = JSON.parse(line.slice((PREFIX + 'document ').length)); } catch { /* Opaque comment. */ } + if (value?.kind === '@statelyai/graph/pajek' && value.version === 1 && Array.isArray(value.nodes) && Array.isArray(value.edges)) { + if (capsule || result.rootMetadata) throw new Error('Pajek: ambiguous root metadata'); + capsule = value; result.rootMetadata = getMetadata(value.graph, `${FORMAT}.graph`); + for (const entry of value.nodes) { const metadata = getMetadata(entry.metadata, `${FORMAT}.node`); if (metadata && Number.isSafeInteger(entry.id) && entry.id > 0) { if (nodeMetadata.has(entry.id)) throw new Error('Pajek: ambiguous vertex metadata'); nodeMetadata.set(entry.id, metadata); } } + for (const entry of value.edges) { const metadata = getMetadata(entry.metadata, `${FORMAT}.edge`); if (metadata && typeof entry.key === 'string') { if (edgeMetadata.has(entry.key)) throw new Error('Pajek: ambiguous edge metadata'); edgeMetadata.set(entry.key, metadata); } } + } + continue; + } + if (!native && line.startsWith(PREFIX)) { + const index = line.indexOf(' ', PREFIX.length), kind = line.slice(PREFIX.length, index); + if (index > 0 && ['graph', 'node', 'edge'].includes(kind)) { + try { + const metadata = getMetadata(JSON.parse(line.slice(index + 1)), `${FORMAT}.${kind}`); + if (metadata) { + if (kind === 'graph') { if (result.rootMetadata) throw new Error('Pajek: ambiguous root metadata'); result.rootMetadata = metadata; } + else { if (pending) throw new Error('Pajek: ambiguous entity metadata'); pending = metadata; } + } + } catch (error) { if (String(error).includes('Pajek:')) throw error; } + } + } + continue; + } + const tokens = getTokens(line, start); + if (tokens[0].value.startsWith('*')) { + if (pending) throw new Error('Pajek: metadata has no adjacent entity'); + section = tokens[0].value.toLowerCase(); + if (section === '*network') { result.name = tokens.slice(1).map((token) => token.value).join(' '); continue; } + if (section === '*vertices') { + if (result.count >= 0 || tokens.length !== 2 || !/^\d+$/.test(tokens[1].value)) throw new Error('Pajek: only one ordinary vertex set is supported'); + result.count = Number(tokens[1].value); + if (!Number.isSafeInteger(result.count) || result.count > 1_000_000) throw new Error('Pajek: invalid or oversized vertex count'); + } else if (section === '*arcs' || section === '*edges') { + if (tokens.length !== 1 || result.count < 0 || edgeMode && edgeMode !== section) throw new Error('Pajek: mixed directed/undirected sections are unsupported'); + if (!edgeMode) result.insertion = start; + edgeMode = section; result.mode = section === '*arcs' ? 'directed' : 'undirected'; + } else throw new Error(`Pajek: unsupported section ${section}; matrix/list/project/temporal profiles require native source operations`); + continue; + } + if (tokens.some((token) => !token.quoted && /[\[\]]/.test(token.value))) throw new Error('Pajek: temporal annotations are unsupported'); + const row: Row = { tokens, start, end, ...(pending && { metadata: pending }) }; pending = undefined; + if (section === '*vertices') { + const id = getInteger(tokens[0], result.count); + if (result.nodes.has(id)) throw new Error('Pajek: duplicate vertex definition'); + if (tokens[1] && !tokens[1].quoted && Number.isFinite(Number(tokens[1].value))) throw new Error('Pajek: ambiguous numeric vertex label/geometry'); + if (row.metadata && row.metadata.format !== `${FORMAT}.node`) throw new Error('Pajek: metadata entity mismatch'); + result.nodes.set(id, row); + } else if (section === '*arcs' || section === '*edges') { + getInteger(tokens[0], result.count); getInteger(tokens[1], result.count); + if (row.metadata && row.metadata.format !== `${FORMAT}.edge`) throw new Error('Pajek: metadata entity mismatch'); + result.edges.push(row); + } else throw new Error('Pajek: unexpected content outside a supported section'); + } + if (result.count < 0 || pending) throw new Error('Pajek: missing vertices or unbound metadata'); + for (const [id, row] of result.nodes) if (nodeMetadata.has(id)) { if (row.metadata) throw new Error('Pajek: ambiguous vertex metadata'); row.metadata = nodeMetadata.get(id); } + const occurrences = new Map(); + for (const row of result.edges) { const key = getEdgeKey(row, result.mode, occurrences); if (edgeMetadata.has(key)) { if (row.metadata) throw new Error('Pajek: ambiguous edge metadata'); row.metadata = edgeMetadata.get(key); } } + return result; +} +function getEdgeKey(row: Row, mode: string, occurrences: Map): string { + const ends = mode === 'undirected' ? [row.tokens[0].value, row.tokens[1].value].sort() : [row.tokens[0].value, row.tokens[1].value]; + const pair = JSON.stringify(ends), occurrence = occurrences.get(pair) ?? 0; occurrences.set(pair, occurrence + 1); + return JSON.stringify([pair, occurrence]); +} +function getEdgeFields(row: Row) { + let cursor = 2, weight: number | undefined; + if (row.tokens[cursor] && !row.tokens[cursor].quoted && Number.isFinite(Number(row.tokens[cursor].value))) weight = Number(row.tokens[cursor++].value); + const attrs = row.tokens.slice(cursor), labels: Token[] = []; + for (let index = 0; index < attrs.length; index += 2) { if (!attrs[index + 1]) throw new Error('Pajek: native edge attributes require key/value pairs'); if (attrs[index].value.toLowerCase() === 'l') labels.push(attrs[index + 1]); } + if (labels.length > 1) throw new Error('Pajek: repeated native edge label is ambiguous'); + return { weight, label: labels[0], attributes: attrs.filter((_, index) => attrs[index - index % 2].value.toLowerCase() !== 'l').map((token) => token.value) }; +} +/** Integer vertex identity is independent of optional, possibly repeated labels. */ +export function fromPajek(source: string, options: PajekImportOptions = {}): Graph { + const parsed = getParsed(source, options.metadata === 'native'), nodes: Graph['nodes'] = []; + for (let id = 1; id <= parsed.count; id++) { + const row = parsed.nodes.get(id); + nodes.push(getRecord({ type: 'node', id: String(id), parentId: null, initialNodeId: null, label: row?.tokens[1]?.value ?? null, data: { attributes: row?.tokens.slice(2).map((token) => token.value) ?? [] } }, row?.metadata) as Graph['nodes'][number]); + } + const reserved = new Set(parsed.edges.flatMap((row) => typeof row.metadata?.original.id === 'string' ? [row.metadata.original.id] : [])); + const occurrences = new Map(); + const edges = parsed.edges.map((row) => { + const sourceId = nodes[getInteger(row.tokens[0], parsed.count) - 1].id, targetId = nodes[getInteger(row.tokens[1], parsed.count) - 1].id; + const ends = parsed.mode === 'undirected' ? [row.tokens[0].value, row.tokens[1].value].sort() : [row.tokens[0].value, row.tokens[1].value]; + const pair = JSON.stringify([parsed.mode, ...ends]), occurrence = occurrences.get(pair) ?? 0; occurrences.set(pair, occurrence + 1); + let id = typeof row.metadata?.mapped.id === 'string' ? row.metadata.mapped.id : `pajek:edge:${JSON.stringify([pair, occurrence])}`; + if (!row.metadata) { while (reserved.has(id)) id += '_'; reserved.add(id); } + const fields = getEdgeFields(row); + return getRecord({ type: 'edge', id, sourceId, targetId, mode: parsed.mode, label: fields.label?.value ?? null, ...(fields.weight !== undefined && { weight: fields.weight }), data: { attributes: fields.attributes } }, row.metadata) as Graph['edges'][number]; + }); + const root = getRecord({ id: parsed.name, mode: parsed.mode, initialNodeId: null, data: undefined }, parsed.rootMetadata); + const graph = { ...root, nodes, edges } as Graph; getValidatedGraph(graph, FORMAT); + graph.data = createNativeCarrier(graph.data, FORMAT, getInput(source, options), graph); return graph; +} +function getQuoted(value: string): string { + for (const character of value) { const code = character.codePointAt(0)!; if (character === '"' || code < 32 && code !== 9 || code === 127 || code >= 0xd800 && code <= 0xdfff) throw new Error('Pajek: label must be a Unicode scalar single-line string without embedded quotes/control characters'); } + return `"${value}"`; +} +function toRawPajek(graph: Graph): string { + getValidatedGraph(graph, FORMAT); const mode = getUniformMode(graph, FORMAT), ids = new Map(graph.nodes.map((node, index) => [node.id, index + 1])); + const nodeLines = graph.nodes.map((node, index) => `${index + 1}${node.label == null ? '' : ` ${getQuoted(node.label)}`}`); + const edgeLines = graph.edges.map((edge) => { const weight = edge.weight ?? 1; if (!Number.isFinite(weight)) throw new Error('Pajek: finite edge weights required'); return `${ids.get(edge.sourceId)} ${ids.get(edge.targetId)} ${weight}${edge.label == null ? '' : ` l ${getQuoted(edge.label)}`}`; }); + const name = graph.id ? `*Network ${getQuoted(graph.id)}\n` : ''; + const bare = `${name}*Vertices ${graph.nodes.length}\n${nodeLines.join('\n')}${nodeLines.length ? '\n' : ''}${mode === 'directed' ? '*Arcs' : '*Edges'}\n${edgeLines.join('\n')}${edgeLines.length ? '\n' : ''}`; + const mapped = fromPajek(bare, { metadata: 'native' }), { nodes: _nodes, edges: _edges, ...root } = graph; + const { nodes: _mn, edges: _me, ...mappedRoot } = mapped; mappedRoot.data = getCarrierData(mapped.data, FORMAT); + const parsed = getParsed(bare, true), occurrences = new Map(); + const capsule = { kind: '@statelyai/graph/pajek', version: 1, graph: createMetadata(`${FORMAT}.graph`, root, mappedRoot), + nodes: graph.nodes.map((node, index) => ({ id: index + 1, metadata: createMetadata(`${FORMAT}.node`, node, mapped.nodes[index]) })), + edges: graph.edges.map((edge, index) => ({ key: getEdgeKey(parsed.edges[index], mode, occurrences), metadata: createMetadata(`${FORMAT}.edge`, edge, mapped.edges[index]) })) }; + const lines = [PREFIX + 'document ' + JSON.stringify(capsule), ...(name ? [name.trimEnd()] : []), `*Vertices ${graph.nodes.length}`, ...nodeLines, mode === 'directed' ? '*Arcs' : '*Edges', ...edgeLines]; + return lines.join('\n') + '\n'; +} +function getPajekPatch(context: GraphDocumentPatchContext, options: PajekImportOptions): GraphDocumentPatchResult { + const invalid = getLabelChangeError(context.changes); if (invalid) return invalid; + const parsed = getParsed(context.source, options.metadata === 'native'), graph = fromPajek(context.source, options); + const nodes = new Map(graph.nodes.map((node, index) => [node.id, index + 1])), edges = new Map(graph.edges.map((edge, index) => [edge.id, parsed.edges[index]])); + const edits: TextEdit[] = []; + for (const change of context.changes) { + const text = getQuoted(change.after as string); + if (change.entity === 'node') { + const id = nodes.get(change.id!); if (id === undefined) return getUnsupported(change, 'Pajek: vertex identity unavailable'); + const row = parsed.nodes.get(id), token = row?.tokens[1]; + edits.push(token ? { start: token.start, end: token.end, text } : row ? { start: row.tokens[0].end, end: row.tokens[0].end, text: ` ${text}` } : { start: parsed.insertion, end: parsed.insertion, text: `${id} ${text}\n` }); + } else { + const row = edges.get(change.id!); if (!row) return getUnsupported(change, 'Pajek: edge row unavailable'); + if (!row.metadata) { + const baseline = fromPajek(context.baselineSource, options), index = baseline.edges.findIndex((edge) => edge.id === change.id), before = getParsed(context.baselineSource, options.metadata === 'native').edges[index]; + const comparable = (entry: Row) => { const label = getEdgeFields(entry).label; return entry.tokens.filter((token) => token !== label).map((token) => token.value); }; + if (!before || !isJSONEqual(comparable(before), comparable(row))) return getUnsupported(change, 'Pajek: native edge identity is ambiguous after independent attribute/topology edits; use explicit entity metadata or edit source'); + } + const token = getEdgeFields(row).label; + edits.push(token ? { start: token.start, end: token.end, text } : { start: row.end, end: row.end, text: ` l ${text}` }); + } + } + return { ok: true, source: getEditedSource(context.source, edits) }; +} +/** Imported source is exact; regeneration is explicit via nativeCarrier:false. */ +export function toPajek(graph: Graph, options: PajekExportOptions = {}): string { + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, FORMAT); + if (!carrier) return toRawPajek(graph); + const input = carrier.native as ReturnType, converter = createPajekDocumentConverter(input.options), document = converter.from(input.source); + document.graph = { ...graph, data: getCarrierData(graph.data, FORMAT) }; return converter.to(document); +} +export function createPajekDocumentConverter(options: PajekImportOptions = {}) { + return createGraphDocumentConverter({ format: FORMAT, codec: { from: (source: string) => fromPajek(source, options), to: (graph: Graph) => toPajek(graph, { nativeCarrier: false }) }, + project: (source: string) => ({ graph: getNativeDocumentGraph(fromPajek(source, options), FORMAT, getInput(source, options)), diagnostics: [{ code: 'projection-partial' as const, severity: 'warning' as const, message: 'Uniform Pajek .net profile; native attributes/comments remain source state, mixed/temporal/matrix/project sections are unavailable' }] }), + patch: (context) => getPajekPatch(context, options) }); +} +export const pajekConverter = createFormatConverter(toPajek, fromPajek); +export const pajekDocumentConverter = createPajekDocumentConverter(); diff --git a/src/formats/plantuml/README.md b/src/formats/plantuml/README.md new file mode 100644 index 0000000..389c13a --- /dev/null +++ b/src/formats/plantuml/README.md @@ -0,0 +1,26 @@ +# PlantUML + +Dependency-free component/class graph subset. Default dialect is `component`; `{ dialect: 'class' }` enables class, interface, enum and abstract class declarations. Activity, state and sequence execution semantics are outside this projection. + + +```ts +import { toPlantUML, fromPlantUML, createPlantUMLDocumentConverter } from '@statelyai/graph/plantuml'; +const source = toPlantUML(graph, { dialect: 'class' }); +const restored = fromPlantUML(source, { dialect: 'class' }); +const converter = createPlantUMLDocumentConverter({ dialect: 'class' }); +const document = converter.from(source); +document.graph!.nodes[0].label = 'Changed'; +const updated = converter.to(document); +``` + +`plantumlConverter` exposes `.to(graph)` and `.from(source)`. `plantumlDocumentConverter` uses component scope; the factory chooses class scope. Packages/namespaces map hierarchy. Binary connectors map directed, undirected and bidirectional relationships; reversed connectors normalize endpoint multiplicities. Class members, stereotypes and connector spelling are native data. Aliased class/enum/abstract-class declarations require quoted display names; verified unquoted component/interface/package aliases are accepted. Fresh output uses qualified stable aliases and Smetana layout; no runtime PlantUML/JVM dependency is imported. + +Fresh node and relationship display labels encode literal quotes, backslashes, markup punctuation and HTML entities using numeric display entities verified against native SVG. LF produces multiple display lines. Empty display names, C0 controls except LF, DEL and lone surrogates reject; Graph JSON data may still contain those strings. Native escaped quotes retain their displayed backslash rather than assuming JSON escape rules. Relationship labels are single-line text; apostrophes that start comments reject. Rich escaped bracket declarations are outside the supported parser profile; use quoted aliases. + + +A versioned comment sidecar retains JSON-domain Graph IDs, data, ports, geometry and optional-field presence. Native readers see component/class topology and mapped display fields; Graph-only fields remain metadata. Live native labels and relationships take precedence over baselines; ambiguous relationship insertion/retargeting rejects. Private comments follow a documented convention, not collision-free provenance. Import `{ metadata: 'native' }` treats them as ordinary comments; export `{ nativeCarrier: false }` explicitly regenerates source from the Graph. + +Documents retain exact original source, line endings, comments, class bodies, grouping, style directives and opaque unsupported dialects through no-op and JSON persistence. Unsupported projections use `graph: null`. Strict single-line node/relationship label patches keep unknown source text and stable aliases, then verify the new Graph projection. Structural, members, style, geometry and data edits reject with diagnostics; no stale source replay hides requested edits. `.getUpdate(document)` returns diagnostics. Native-source bare exports use the same verified patch path. Multiline/empty label patches require explicit regeneration or another representation. + + +Native proof uses the pinned official PlantUML 1.2026.8 compiler with Java 21: local SVG entity/endpoint identities, hierarchy, display encoding, multiplicities and class XMI members/generalization (`--xmi:star`; default XMI omits relationships). The compiler/JVM are development-only validation tools. [Component syntax](https://plantuml.com/component-diagram) and [class syntax](https://plantuml.com/class-diagram). diff --git a/src/formats/plantuml/index.ts b/src/formats/plantuml/index.ts new file mode 100644 index 0000000..019b407 --- /dev/null +++ b/src/formats/plantuml/index.ts @@ -0,0 +1,219 @@ +import type { Graph } from '../../types'; +import { createFormatConverter } from '../converter'; +import { createGraphDocumentConverter, type GraphDocumentPatchContext, type GraphDocumentPatchResult } from '../document'; +import { createNativeCarrier, getNativeCarrier, getCarrierData, getNativeDocumentGraph, getClonedJSON, isJSONEqual, getEditedData, type FormatImportOptions, type FormatExportOptions } from '../preservation'; +import { getValidatedGraph, isNativeMetadataRecord } from '../structured-json'; + +export type PlantUMLDialect = 'component' | 'class'; +export interface PlantUMLImportOptions extends FormatImportOptions { dialect?: PlantUMLDialect } +export interface PlantUMLExportOptions extends FormatExportOptions { dialect?: PlantUMLDialect } +interface PlantUMLSidecar { kind: '@statelyai/graph/plantuml'; version: 1; dialect: PlantUMLDialect; graph: Graph; baseline: Graph; aliases: Array<{ alias: string; id: string }> } +interface PlantUMLNodeSource { alias: string; node: Graph['nodes'][number]; line: number; token: string; tokenStart: number; hasAlias: boolean; members: number[] } +interface PlantUMLEdgeSource { edge: Graph['edges'][number]; line: number; labelStart?: number } +interface PlantUMLSource { lines: string[]; endings: string[]; graph: Graph; nodes: PlantUMLNodeSource[]; edges: PlantUMLEdgeSource[]; sidecar?: PlantUMLSidecar; sidecarLine?: number; dialect: PlantUMLDialect } +const SIDECAR = "' @statelyai/graph/plantuml "; +const ARROWS = ['-->', '<--', '--', '<-->', '..>', '<..', '..', '--|>', '<|--', '*--', 'o--', '--*', '--o']; + +function getVisibleLine(line: string): string { + let quoted = false, escaped = false; + for (let index = 0; index < line.length; index++) { + const char = line[index]; if (escaped) { escaped = false; continue; } + if (char === '\\' && quoted) { escaped = true; continue; } if (char === '"') quoted = !quoted; + if (char === "'" && !quoted) return line.slice(0, index); + } + return line; +} +function getLabel(token: string, quoted = token.startsWith('"') && token.endsWith('"')): string { + const body = quoted ? token.slice(1, -1) : token; let decoded = ''; + for (let index = 0; index < body.length; index++) { + if (body[index] !== '\\' || index + 1 === body.length) { decoded += body[index]; continue; } + const next = body[++index]; decoded += next === 'n' ? '\n' : next === '\\' ? '\\' : `\\${next}`; + } + return decoded.replace(/&(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);/g, (entity) => { + const named: Record = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" }; + if (named[entity]) return named[entity]; const number = entity.startsWith('&#x') ? Number.parseInt(entity.slice(3, -1), 16) : Number(entity.slice(2, -1)); + return Number.isInteger(number) && number >= 0 && number <= 0x10ffff ? String.fromCodePoint(number) : entity; + }); +} +function getEncodedLabel(label: string): string { + if (/[\u0000-\u0009\u000b-\u001f\u007f]|[\ud800-\udfff]/u.test(label)) throw new Error('PlantUML: C0/control/non-scalar display labels are outside this profile'); + return JSON.stringify(label.replace(/[&<>"\\*~_^{}\[\]#=]/g, (character) => `&#${character.codePointAt(0)};`)); +} +function getArrowMode(arrow: string): { mode: Graph['mode']; reverse: boolean } { + const left = arrow.startsWith('<'), right = arrow.endsWith('>'); + return { mode: left && right ? 'bidirectional' : left || right ? 'directed' : 'undirected', reverse: left && !right }; +} +function isPlantUMLSidecar(value: any): value is PlantUMLSidecar { + if (value?.kind !== '@statelyai/graph/plantuml' || value.version !== 1 || !['component', 'class'].includes(value.dialect) || !Array.isArray(value.aliases)) return false; + return [value.graph, value.baseline].every((graph) => graph && Array.isArray(graph.nodes) && Array.isArray(graph.edges) && isNativeMetadataRecord(graph, 'graph') && graph.nodes.every((node: any) => isNativeMetadataRecord(node, 'node')) && graph.edges.every((edge: any) => isNativeMetadataRecord(edge, 'edge'))) && value.aliases.every((item: any) => item && typeof item.alias === 'string' && typeof item.id === 'string'); +} +function getParsedPlantUML(text: string, options: PlantUMLImportOptions = {}): PlantUMLSource { + if (typeof text !== 'string') throw new Error('PlantUML: expected text'); + const lines: string[] = [], endings: string[] = []; const regex = /([^\r\n]*)(\r\n|\r|\n|$)/g; let match: RegExpExecArray | null; + while ((match = regex.exec(text)) && (match[0].length || match.index < text.length)) { lines.push(match[1]); endings.push(match[2]); if (!match[2]) break; } + const nodes: PlantUMLNodeSource[] = [], edges: PlantUMLEdgeSource[] = []; const byAlias = new Map(), parents: string[] = []; + let active: PlantUMLNodeSource | undefined, blockComment = false, opaqueDepth = 0, started = false, ended = false, inferred: PlantUMLDialect | undefined, sidecar: PlantUMLSidecar | undefined, sidecarLine: number | undefined, direction: Graph['direction']; + for (let index = 0; index < lines.length; index++) { + const original = lines[index], trimmed = original.trim(); + if (blockComment) { if (trimmed.includes("'/")) blockComment = false; continue; } + if (trimmed.startsWith("/'")) { if (!trimmed.slice(2).includes("'/")) blockComment = true; continue; } + if (trimmed.startsWith("'")) { + if (options.metadata !== 'native' && trimmed.startsWith(SIDECAR)) { let value: any; try { value = JSON.parse(trimmed.slice(SIDECAR.length)); } catch { continue; } if (isPlantUMLSidecar(value)) { sidecar = value; sidecarLine = index; } } + continue; + } + const line = getVisibleLine(original).trim(); if (!line) continue; + if (opaqueDepth) { opaqueDepth += (line.match(/\{/g)?.length ?? 0) - (line.match(/\}/g)?.length ?? 0); continue; } + if (/^@startuml(?:\s+\S+)?$/.test(line)) { if (started) throw new Error('PlantUML: multiple diagrams are outside this profile'); started = true; continue; } + if (line === '@enduml') { if (active || parents.length) throw new Error('PlantUML: unclosed declaration'); ended = true; continue; } + if (!started || ended) throw new Error('PlantUML: source outside one @startuml/@enduml diagram'); + if (active) { + if (line === '}') { active = undefined; continue; } + if (/[{}]/.test(line) || /^[!@]/.test(line)) throw new Error('PlantUML: executable/nested class body is outside the class member profile'); + (active.node.data.members as string[]).push(line); active.members.push(index); continue; + } + if (line === '}') { if (!parents.length) throw new Error('PlantUML: unmatched group close'); parents.pop(); continue; } + if (line === '!pragma layout smetana') continue; + if (/^(?:skinparam|hide|show|title|caption|header|footer|scale)\b/.test(line)) { opaqueDepth = (line.match(/\{/g)?.length ?? 0) - (line.match(/\}/g)?.length ?? 0); continue; } + if (line === 'left to right direction') { direction = 'right'; continue; } if (line === 'top to bottom direction') { direction = 'down'; continue; } + const declaration = /^(component|class|interface|enum|abstract\s+class|package|namespace)\s+("(?:\\.|[^"\\])*"|[A-Za-z_$][\w.$]*)(?:\s+as\s+([A-Za-z_$][\w.$]*))?(?:\s+<<([^>]*)>>)?(?:\s+(#[A-Za-z0-9]+))?\s*(\{)?\s*$/.exec(line); + const bracket = /^\[(.*?)\](?:\s+as\s+([A-Za-z_$][\w.$]*))?(?:\s+<<([^>]*)>>)?(?:\s+(#[A-Za-z0-9]+))?$/.exec(line); + if (declaration || bracket) { + if (declaration?.[3] && ['class', 'enum', 'abstract class'].includes(declaration[1].replace(/\s+/, ' ')) && !declaration[2].startsWith('"')) throw new Error('PlantUML: aliased declarations require a quoted native display name'); + if (bracket && /[\\&<>"~*]/.test(bracket[1])) throw new Error('PlantUML: rich/escaped bracket labels are outside this profile; quoted aliased declarations are supported'); + const kind = bracket ? 'component' : declaration![1].replace(/\s+/, ' '), label = bracket ? bracket[1] : getLabel(declaration![2]); + if (!label) throw new Error('PlantUML: empty native display names are outside this profile'); + const alias = bracket ? bracket[2] ?? label : declaration![3] ?? label; const stereotypes = bracket ? bracket[3] : declaration![4]; const color = bracket ? bracket[4] : declaration![5]; + const group = kind === 'package' || kind === 'namespace'; const dialect = kind === 'component' ? 'component' : group ? undefined : 'class'; + if (dialect && inferred && inferred !== dialect) throw new Error('PlantUML: mixed component/class declarations are outside the selected dialect'); if (dialect) inferred = dialect; + if (byAlias.has(alias)) throw new Error('PlantUML: duplicate native alias'); + const node: Graph['nodes'][number] = { type: 'node', id: alias, label, data: { kind, ...(stereotypes !== undefined && { stereotypes }), ...(!group && kind !== 'component' && { members: [] }) }, ...(parents.length && { parentId: parents.at(-1)! }), ...(color && { color }) }; + const token = bracket ? `[${label}]` : declaration![2], indent = original.length - original.trimStart().length; + const record = { alias, node, line: index, token, tokenStart: original.indexOf(token, indent + (bracket ? 0 : declaration![1].length)), hasAlias: Boolean(bracket ? bracket[2] : declaration![3]), members: [] as number[] }; nodes.push(record); byAlias.set(alias, node); + if (group) { if (!declaration![6]) throw new Error('PlantUML: groups require an explicit block'); parents.push(alias); } + else if (declaration?.[6]) { if (kind === 'component') throw new Error('PlantUML: nested component blocks are outside this profile'); active = record; } + continue; + } + const relationship = /^([A-Za-z_$][\w.$]*)(?:\s+"([^"]*)")?\s+(<\|--|--\|>|<\.\.|\.\.>|<-->|<--|-->|--|\.\.|\*--|o--|--\*|--o)(?:\s+"([^"]*)")?\s+([A-Za-z_$][\w.$]*)(?:\s*:\s*(.*))?$/.exec(line); + if (relationship) { + const arrow = getArrowMode(relationship[3]); const sourceId = arrow.reverse ? relationship[5] : relationship[1], targetId = arrow.reverse ? relationship[1] : relationship[5]; + const sourceMultiplicity = arrow.reverse ? relationship[4] : relationship[2], targetMultiplicity = arrow.reverse ? relationship[2] : relationship[4]; + const edge: Graph['edges'][number] = { type: 'edge', id: `e${edges.length}`, sourceId, targetId, mode: arrow.mode, label: relationship[6] === undefined ? null : getLabel(relationship[6]), data: { relationship: relationship[3], ...(sourceMultiplicity !== undefined && { sourceMultiplicity }), ...(targetMultiplicity !== undefined && { targetMultiplicity }) } }; + edges.push({ edge, line: index, ...(relationship[6] !== undefined && { labelStart: original.indexOf(':') + 1 }) }); continue; + } + throw new Error(`PlantUML: unsupported component/class statement on line ${index + 1}`); + } + if (!started || !ended || active || parents.length || blockComment || opaqueDepth) throw new Error('PlantUML: incomplete scoped diagram'); + const dialect = options.dialect ?? sidecar?.dialect ?? inferred ?? 'component'; if (!['component', 'class'].includes(dialect) || (inferred && inferred !== dialect)) throw new Error('PlantUML: selected dialect does not match declarations'); + const graph: Graph = { id: '', mode: 'directed', data: { dialect }, nodes: nodes.map((record) => record.node), edges: edges.map((record) => record.edge), ...(direction && { direction }) }; getValidatedGraph(graph, 'PlantUML'); + return { lines, endings, graph, nodes, edges, sidecar, sidecarLine, dialect }; +} + +function getPlantUMLGraph(parsed: PlantUMLSource): Graph { + const metadata = parsed.sidecar; if (!metadata) return getClonedJSON(parsed.graph); + const aliases = new Map(metadata.aliases.map((item) => [item.alias, item.id])); if (aliases.size !== metadata.aliases.length) throw new Error('PlantUML: duplicate sidecar alias'); + const originalNodes = new Map(metadata.graph.nodes.map((node) => [node.id, node])), beforeNodes = new Map(metadata.baseline.nodes.map((node) => [node.id, node])); + const { nodes: _nodes, edges: _edges, ...root } = getClonedJSON(metadata.graph); const graph: Graph = { ...root, nodes: [], edges: [] }; + if (parsed.graph.direction !== metadata.baseline.direction) { if (parsed.graph.direction) graph.direction = parsed.graph.direction; else delete graph.direction; } + for (const native of parsed.graph.nodes) { + const id = aliases.get(native.id) ?? native.id, before = beforeNodes.get(native.id), original = originalNodes.get(id); + if (!before || !original) { graph.nodes.push({ ...getClonedJSON(native), id, ...(native.parentId && { parentId: aliases.get(native.parentId) ?? native.parentId }) }); continue; } + const node = getClonedJSON(original); if (native.label !== before.label) node.label = native.label; + if (native.parentId !== before.parentId) { if (native.parentId === undefined) delete node.parentId; else node.parentId = native.parentId === null ? null : aliases.get(native.parentId) ?? native.parentId; } + if (native.color !== before.color) { if (native.color === undefined) delete node.color; else node.color = native.color; } + if (!isJSONEqual(native.data, before.data) && original.data && typeof original.data === 'object' && !Array.isArray(original.data)) node.data = getEditedData(original.data, before.data, native.data); + graph.nodes.push(node); + } + if (isJSONEqual(parsed.graph.nodes.map((node) => node.id), metadata.baseline.nodes.map((node) => node.id))) { + const projected = new Map(graph.nodes.map((node) => [node.id, node])); graph.nodes = metadata.graph.nodes.map((node) => projected.get(node.id)!); + } + const keys = new Set(); + for (let index = 0; index < parsed.graph.edges.length; index++) { + const native = parsed.graph.edges[index], before = metadata.baseline.edges[index], original = metadata.graph.edges[index]; + if (!before || !original || native.sourceId !== before.sourceId || native.targetId !== before.targetId || native.data.relationship !== before.data.relationship) throw new Error('PlantUML: changed/inserted native relationships have ambiguous source identity'); + const edge = getClonedJSON(original); if (native.label !== before.label) edge.label = native.label; + if (native.mode !== before.mode) edge.mode = native.mode; if (!isJSONEqual(native.data, before.data) && original.data && typeof original.data === 'object' && !Array.isArray(original.data)) edge.data = getEditedData(original.data, before.data, native.data); + graph.edges.push(edge); keys.add(edge.id); + } + if (parsed.graph.edges.length !== metadata.graph.edges.length || keys.size !== graph.edges.length) throw new Error('PlantUML: changed native relationship structure is unsupported'); + getValidatedGraph(graph, 'PlantUML'); return graph; +} + +/** Honest component/class binary graph projection; executable diagram dialects throw. */ +export function fromPlantUML(text: string, options: PlantUMLImportOptions = {}): Graph { + const parsed = getParsedPlantUML(text, options), graph = getPlantUMLGraph(parsed); graph.data = createNativeCarrier(graph.data, 'plantuml', text, graph); return graph; +} + +function getDisplayLabel(node: Graph['nodes'][number]): string { + const label = node.label ?? node.id; if (!label) throw new Error('PlantUML: empty display names are unsupported by the native class/component grammar'); return label; +} +function toRawPlantUML(graph: Graph, options: PlantUMLExportOptions): string { + getValidatedGraph(graph, 'PlantUML'); const dialect = options.dialect ?? 'component'; if (!['component', 'class'].includes(dialect)) throw new Error('PlantUML: unsupported dialect'); + const aliases = new Map(graph.nodes.map((node, index) => [node.id, `g${index}`])); const byParent = new Map(); const ids = new Set(graph.nodes.map((node) => node.id)); + for (const node of graph.nodes) { const parent = node.parentId ?? null; if (parent !== null && !ids.has(parent)) throw new Error('PlantUML: missing parent'); const siblings = byParent.get(parent) ?? []; siblings.push(node); byParent.set(parent, siblings); } + const lines = ['@startuml', '!pragma layout smetana', ...(graph.direction === 'right' ? ['left to right direction'] : [])]; + const stack: Array<{ node?: Graph['nodes'][number]; close?: boolean }> = [...(byParent.get(null) ?? [])].reverse().map((node) => ({ node })); const seen = new Set(); + while (stack.length) { + const item = stack.pop()!; if (item.close) { lines.push('}'); continue; } const node = item.node!; if (seen.has(node.id)) throw new Error('PlantUML: cyclic hierarchy'); seen.add(node.id); + const children = byParent.get(node.id) ?? [], kind = children.length ? 'package' : dialect === 'class' && ['class', 'interface', 'enum', 'abstract class'].includes(node.data?.kind) ? node.data.kind : dialect; + const stereotypes = typeof node.data?.stereotypes === 'string' && !/[<>\r\n]/.test(node.data.stereotypes) ? ` <<${node.data.stereotypes}>>` : ''; + const color = node.color && /^(?:#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?|red|blue|green|black|white|gray|yellow|orange|purple)$/.test(node.color) ? ` ${node.color.startsWith('#') ? node.color : `#${node.color}`}` : ''; + const declaration = `${kind} ${getEncodedLabel(getDisplayLabel(node))} as ${aliases.get(node.id)}${stereotypes}${color}`; + if (children.length) { lines.push(`${declaration} {`); stack.push({ close: true }, ...children.slice().reverse().map((child) => ({ node: child }))); } + else if (dialect === 'class' && Array.isArray(node.data?.members) && node.data.members.length) { lines.push(`${declaration} {`); for (const member of node.data.members) { if (typeof member !== 'string' || /[\r\n{}]/.test(member) || /^[!@']/.test(member.trim())) throw new Error('PlantUML: unsupported class member'); lines.push(` ${member}`); } lines.push('}'); } + else lines.push(declaration); + } + if (seen.size !== graph.nodes.length) throw new Error('PlantUML: cyclic hierarchy'); + for (const edge of graph.edges) { + const mode = edge.mode ?? graph.mode; let arrow = mode === 'directed' ? '-->' : mode === 'undirected' ? '--' : mode === 'bidirectional' ? '<-->' : undefined; if (!arrow) throw new Error('PlantUML: invalid edge mode'); + if (typeof edge.data?.relationship === 'string' && ARROWS.includes(edge.data.relationship) && getArrowMode(edge.data.relationship).mode === mode) arrow = String(edge.data.relationship); + const reverse = getArrowMode(arrow).reverse, left = reverse ? edge.targetId : edge.sourceId, right = reverse ? edge.sourceId : edge.targetId; + const sourceMultiplicity = reverse ? edge.data?.targetMultiplicity : edge.data?.sourceMultiplicity, targetMultiplicity = reverse ? edge.data?.sourceMultiplicity : edge.data?.targetMultiplicity; + if ([sourceMultiplicity, targetMultiplicity].some((value) => value !== undefined && (typeof value !== 'string' || /["\r\n]/.test(value)))) throw new Error('PlantUML: invalid relationship multiplicity'); + if (edge.label !== undefined && edge.label !== null) getEncodedLabel(edge.label); + if (edge.label !== undefined && edge.label !== null && /[\r\n']/.test(edge.label)) throw new Error('PlantUML: multiline/comment-starting link labels are outside this profile'); + lines.push(`${aliases.get(left)}${sourceMultiplicity === undefined ? '' : ` "${sourceMultiplicity}"`} ${arrow}${targetMultiplicity === undefined ? '' : ` "${targetMultiplicity}"`} ${aliases.get(right)}${edge.label ? ` : ${JSON.parse(getEncodedLabel(edge.label))}` : ''}`); + } + lines.push('@enduml'); const body = `${lines.join('\n')}\n`, baseline = getParsedPlantUML(body, { dialect, metadata: 'native' }).graph; + const sidecar: PlantUMLSidecar = { kind: '@statelyai/graph/plantuml', version: 1, dialect, graph: getClonedJSON(graph), baseline, aliases: [...aliases].map(([id, alias]) => ({ alias, id })) }; + lines.splice(1, 0, `${SIDECAR}${JSON.stringify(sidecar)}`); return `${lines.join('\n')}\n`; +} + +function getPlantUMLPatch(context: GraphDocumentPatchContext, dialect: PlantUMLDialect): GraphDocumentPatchResult { + const parsed = getParsedPlantUML(context.source, { dialect }); + const aliases = new Map(parsed.sidecar?.aliases.map((item) => [item.id, item.alias])); + const nodes = new Map(context.graph.nodes.map((node) => [node.id, node])), nodeSources = new Map(parsed.nodes.map((record) => [record.alias, record])); + const edges = new Map(context.graph.edges.map((edge) => [edge.id, edge])), positions = new Map(context.sourceGraph.edges.map((edge, index) => [edge.id, index])); + for (const change of context.changes) { + if (change.entity === 'node' && change.field === 'label') { + const node = nodes.get(change.id!)!, alias = aliases.get(node.id) ?? node.id, record = nodeSources.get(alias); + if (!record || !node.label || /[\r\n]/.test(node.label)) return { ok: false, diagnostics: [{ code: 'unsupported-native-edit', severity: 'error', message: 'A node label edit requires one nonempty single-line declaration' }] }; + if (record.token.startsWith('[') && /[\\&<>"\]~*]/.test(node.label)) return { ok: false, diagnostics: [{ code: 'unsupported-native-edit', severity: 'error', message: 'Rich bracket labels require explicit regeneration with a quoted alias' }] }; + if (!record.hasAlias && !/^[A-Za-z_$][\w.$]*$/.test(record.alias)) return { ok: false, diagnostics: [{ code: 'unsupported-native-edit', severity: 'error', message: 'An implicit non-identifier display name has no stable editable native alias' }] }; + const offset = record.tokenStart; + const replacement = `${record.token.startsWith('[') ? `[${node.label}]` : getEncodedLabel(node.label)}${record.hasAlias ? '' : ` as ${record.alias}`}`; + parsed.lines[record.line] = parsed.lines[record.line].slice(0, offset) + replacement + parsed.lines[record.line].slice(offset + record.token.length); + } else if (change.entity === 'edge' && change.field === 'label') { + const index = positions.get(change.id!)!, record = parsed.edges[index], edge = edges.get(change.id!)!; + if (!record || edge.label === undefined || edge.label === null || /[\r\n']/.test(edge.label)) return { ok: false, diagnostics: [{ code: 'unsupported-native-edit', severity: 'error', message: 'A relationship label edit requires single-line native text' }] }; + const original = parsed.lines[record.line], visible = getVisibleLine(original), suffix = original.slice(visible.length); + if (record.labelStart === undefined) parsed.lines[record.line] = `${visible.trimEnd()} : ${JSON.parse(getEncodedLabel(edge.label))}${visible.match(/\s*$/)![0]}${suffix}`; + else { const section = visible.slice(record.labelStart); parsed.lines[record.line] = `${original.slice(0, record.labelStart)}${section.match(/^\s*/)![0]}${JSON.parse(getEncodedLabel(edge.label))}${section.match(/\s*$/)![0]}${suffix}`; } + } else return { ok: false, diagnostics: [{ code: 'unsupported-native-edit', severity: 'error', path: change.path, message: 'Only native node/relationship label edits are supported; members, styles and structural changes require explicit regeneration' }] }; + } + return { ok: true, source: parsed.lines.map((line, index) => line + parsed.endings[index]).join('') }; +} + +export function createPlantUMLDocumentConverter(options: { dialect?: PlantUMLDialect } = {}) { + const dialect = options.dialect ?? 'component'; + return createGraphDocumentConverter({ format: `plantuml:${dialect}`, codec: { from: (source: string) => fromPlantUML(source, { dialect }), to: (graph: Graph) => toRawPlantUML(graph, { dialect }) }, project: (source: string) => ({ graph: getNativeDocumentGraph(fromPlantUML(source, { dialect }), 'plantuml', source) }), patch: (context) => getPlantUMLPatch(context, dialect) }); +} + +/** New output uses component by default; native-source exports apply strict verified patches. */ +export function toPlantUML(graph: Graph, options: PlantUMLExportOptions = {}): string { + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, 'plantuml'); + if (!carrier) return toRawPlantUML(graph, options); + const dialect = options.dialect ?? getParsedPlantUML(carrier.native).dialect; const converter = createPlantUMLDocumentConverter({ dialect }); const document = converter.from(carrier.native); + document.graph = { ...getClonedJSON(graph), data: getCarrierData(graph.data, 'plantuml') }; return converter.to(document); +} +export const plantumlConverter = createFormatConverter(toPlantUML, fromPlantUML); +export const plantumlDocumentConverter = createPlantUMLDocumentConverter(); diff --git a/src/formats/preservation.ts b/src/formats/preservation.ts new file mode 100644 index 0000000..2ac86d8 --- /dev/null +++ b/src/formats/preservation.ts @@ -0,0 +1,391 @@ +/** Private JSON wire convention; not a source document API. */ +export interface FormatMetadata { + kind: '@statelyai/graph'; + version: 2; + format: string; + original: Record; + mapped: Record; +} + +export interface FormatExportOptions { + /** Disable interpretation of native-import state when Graph data is user-authored. */ + nativeCarrier?: boolean; +} + +export interface FormatImportOptions { + /** `native` disables adapter metadata interpretation, including legacy blobs. */ + metadata?: 'auto' | 'native' | 'legacy'; +} + +export function getClonedJSON(value: T): T { + return value === undefined ? value : JSON.parse(JSON.stringify(value)); +} + +export function isJSONEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + return aKeys.length === bKeys.length && aKeys.every((key) => + Object.hasOwn(b, key) && isJSONEqual((a as any)[key], (b as any)[key]), + ); +} + +export function createMetadata(format: string, original: object, mapped: object): FormatMetadata { + return { kind: '@statelyai/graph', version: 2, format, + original: getClonedJSON(original), mapped: getClonedJSON(mapped) }; +} + +export function getMetadata(value: unknown, format: string): FormatMetadata | undefined { + const metadata = value as FormatMetadata | undefined; + return metadata?.kind === '@statelyai/graph' && metadata.version === 2 && + metadata.format === format && metadata.original && typeof metadata.original === 'object' && + !Array.isArray(metadata.original) && metadata.mapped && typeof metadata.mapped === 'object' && + !Array.isArray(metadata.mapped) ? metadata : undefined; +} + +/** Current mapped values win when changed; untouched values retain original presence. */ +export function getRestoredRecord(mapped: T, metadata?: FormatMetadata): T { + if (!metadata) return getClonedJSON(mapped); + // Undefined-valued properties have no JSON wire presence. Compare the same + // serialized view used by both stored snapshots, rather than fabricating edits. + mapped = getClonedJSON(mapped); + const restored = getClonedJSON(metadata.original); + for (const key of new Set([...Object.keys(mapped), ...Object.keys(metadata.mapped)])) { + const current = (mapped as any)[key]; + if (Object.hasOwn(mapped, key) !== Object.hasOwn(metadata.mapped, key) || + !isJSONEqual(current, metadata.mapped[key])) { + if (Object.hasOwn(mapped, key)) Object.defineProperty(restored, key, { + value: getClonedJSON(current), enumerable: true, configurable: true, writable: true, + }); + else delete restored[key]; + } + } + return restored as T; +} + +interface FormatNativeCarrier { + __statelyaiNative: { + kind: '@statelyai/graph/native'; version: 1; format: string; provenance: 'native-import'; + data?: unknown; native: any; baseline: any; + }; +} + +export function createNativeCarrier(data: unknown, format: string, native: unknown, baseline: unknown): any { + return { + ...(data && typeof data === 'object' && !Array.isArray(data) ? getClonedJSON(data) : {}), + __statelyaiNative: { kind: '@statelyai/graph/native', version: 1, format, provenance: 'native-import', + data: getClonedJSON(data), native: getClonedJSON(native), baseline: getClonedJSON(baseline) }, + } satisfies FormatNativeCarrier; +} + +export function getNativeCarrier(data: any, format: string): FormatNativeCarrier['__statelyaiNative'] | undefined { + const carrier = data?.__statelyaiNative; + return carrier?.kind === '@statelyai/graph/native' && carrier.version === 1 && carrier.provenance === 'native-import' && + carrier.format === format && carrier.native && carrier.baseline ? carrier : undefined; +} + +export function getCarrierData(data: any, format: string): any { + const carrier = getNativeCarrier(data, format); + if (!carrier) return data; + const current = { ...data }; + delete current.__statelyaiNative; + if (!carrier.data || typeof carrier.data !== 'object' || Array.isArray(carrier.data)) { + return Object.keys(current).length > 0 ? getClonedJSON(current) : getClonedJSON(carrier.data); + } + // A foreign property with the same name is kept in the captured user payload. + if (Object.hasOwn(carrier.data, '__statelyaiNative')) current.__statelyaiNative = (carrier.data as any).__statelyaiNative; + return getClonedJSON(current); +} + +/** Apply live metadata-field edits while retaining independently captured user data. */ +export function getEditedData(original: any, baseline: any, current: any): any { + if (isJSONEqual(baseline, current)) return getClonedJSON(original); + const result = original && typeof original === 'object' && !Array.isArray(original) ? getClonedJSON(original) : {}; + for (const key of new Set([...Object.keys(baseline ?? {}), ...Object.keys(current ?? {})])) { + if (Object.hasOwn(baseline ?? {}, key) === Object.hasOwn(current ?? {}, key) && isJSONEqual(baseline?.[key], current?.[key])) continue; + if (Object.hasOwn(current ?? {}, key)) Object.defineProperty(result, key, { + value: getClonedJSON(current[key]), enumerable: true, configurable: true, writable: true, + }); + else delete result[key]; + } + return result; +} + +/** Patch differences between generated projections onto an independent native snapshot. */ +export function getMergedNative(original: any, before: any, after: any): any { + if (after?.kind === '@statelyai/graph' && getMetadata(after, after.format)) return getClonedJSON(after); + if (isJSONEqual(before, after)) return getClonedJSON(original); + if (!before || !after || typeof before !== 'object' || typeof after !== 'object' || + Array.isArray(before) !== Array.isArray(after)) return getClonedJSON(after); + if (Array.isArray(after)) { + const beforeIds = before.map(getEntityIdentity); + const afterIds = after.map(getEntityIdentity); + const hasIdentity = [...beforeIds, ...afterIds].every((id) => id !== undefined) && + new Set(beforeIds).size === beforeIds.length && new Set(afterIds).size === afterIds.length; + if (hasIdentity && Array.isArray(original)) { + const byId = new Map(before.map((value: any, index: number) => [beforeIds[index], value])); + const originalById = new Map(original.map((value: any) => [getEntityIdentity(value), value])); + // Missing native edge ids acquire stable ids in the baseline projection. + // Importers keep those arrays in input order, which supplies correspondence. + if (original.length === before.length) { + original.forEach((value: any, index: number) => { + if (getEntityIdentity(value) === undefined) originalById.set(beforeIds[index], value); + }); + } + const merged = after.map((value: any, index: number) => { + const id = afterIds[index]; + return byId.has(id) && originalById.has(id) + ? getMergedNative(originalById.get(id), byId.get(id), value) + : getClonedJSON(value); + }); + // Projection-only arrays can represent one native section among several. + // Entries absent from the baseline projection were never Graph-owned. + if (original.length !== before.length) merged.push(...original.filter((value: any) => { + const id = getEntityIdentity(value); + return id !== undefined && !byId.has(id); + }).map(getClonedJSON)); + return merged; + } + // Label/section/value arrays have no provable identity. Replace conservatively + // when their length changes; otherwise retain unchanged positions. + if (!Array.isArray(original) || before.length !== after.length || original.length !== before.length) return getClonedJSON(after); + return after.map((value, index) => getMergedNative(original[index], before[index], value)); + } + const result = original && typeof original === 'object' && !Array.isArray(original) ? getClonedJSON(original) : {}; + for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { + if (isJSONEqual(before[key], after[key]) && Object.hasOwn(before, key) === Object.hasOwn(after, key)) continue; + if (!Object.hasOwn(after, key)) delete result[key]; + else Object.defineProperty(result, key, { value: getMergedNative(result[key], before[key], after[key]), + enumerable: true, writable: true, configurable: true }); + } + return result; +} + +function getEntityIdentity(value: any): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const id = value.id ?? value.data?.id ?? value.name; + return typeof id === 'string' || typeof id === 'number' ? String(id) : undefined; +} + +/** Native document comparison consumes verified adapter state once. */ +export function getNativeDocumentGraph(graph: T, format: string, source: any): T { + const result = getClonedJSON(graph); + const carrier = getNativeCarrier(result.data, format); + let external = source; + if (format === 'cytoscape' && !Array.isArray(source.elements)) external = { ...source, elements: { ...source.elements, nodes: source.elements.nodes ?? [], edges: source.elements.edges ?? [] } }; + if (format === 'cytoscape' && Array.isArray(source.elements)) external = { ...source, elements: { + nodes: source.elements.filter((element: any) => !('source' in element.data && 'target' in element.data)), + edges: source.elements.filter((element: any) => 'source' in element.data && 'target' in element.data), + } }; + if (carrier && isJSONEqual(carrier.native, external) && Array.isArray(carrier.baseline?.nodes) && Array.isArray(carrier.baseline?.edges) && + isJSONEqual(getClonedJSON({ ...result, data: carrier.data }), carrier.baseline)) result.data = getCarrierData(result.data, format); + for (const node of result.nodes) { + if (format === 'd3' && node.__statelyaiD3Node?.kind === '@statelyai/graph/d3-node' && node.__statelyaiD3Node.version === 1) delete node.__statelyaiD3Node; + } + return result; +} + +/** Remove renderer-derived duplication during projection only, never from edited Graphs. */ +export function getStructuredDocumentProjectionGraph(graph: T, source: any, format: string): T { + const projected = getNativeDocumentGraph(graph, format, source); + const getWireData = (native: any, kind: string): FormatMetadata | undefined => { + if (format === 'elk') { + try { return getMetadata(JSON.parse(native?.layoutOptions?.['statelyai.metadata']), `elk.${kind}`); } catch { return undefined; } + } + const value = format === 'cytoscape' || format === 'xyflow' ? native?.data?.__statelyai : format === 'jgf' ? native?.metadata?.__statelyai : native?.__statelyai; + return getMetadata(value, `${format}.${kind}`); + }; + const getVisibleData = (native: any, metadata: FormatMetadata, kind: string): any => { + if (format === 'd3') return getClonedJSON(native.data); + if (format === 'cytoscape') return getClonedJSON(native.data?.[kind === 'graph' ? 'graphData' : kind === 'node' ? 'nodeData' : 'edgeData']); + if (format === 'elk' || kind === 'graph' && format === 'xyflow') return getClonedJSON(metadata.original.data); + const visible = { ...(format === 'jgf' ? native.metadata : native.data) }; + delete visible.__statelyai; + if (format === 'xyflow') delete visible.label; + const baseline = format === 'xyflow' ? { ...(metadata.mapped.__visibleData ?? {}) } : { ...(metadata.mapped.data ?? {}) }; + if (format === 'xyflow') { delete baseline.__statelyai; delete baseline.label; } + const restored = getEditedData(metadata.original.data, baseline, visible); + if (format === 'jgf' && kind === 'edge' && Object.hasOwn(native, 'relation')) return { ...restored, relation: native.relation }; + return restored; + }; + const root = format === 'jgf' ? source.graph : source; + const rootMetadata = getWireData(root, 'graph'); + if (rootMetadata) projected.data = getVisibleData(root, rootMetadata, 'graph'); + const natives: Record> = { nodes: new Map(), edges: new Map() }; + if (format === 'elk') { + const queue = [source]; + for (let i = 0; i < queue.length; i++) { + for (const node of queue[i].children ?? []) { natives.nodes.set(node.id, node); queue.push(node); } + for (const edge of queue[i].edges ?? []) natives.edges.set(edge.id, edge); + } + } else if (format === 'cytoscape') { + const values = Array.isArray(source.elements) ? source.elements : [...(source.elements.nodes ?? []), ...(source.elements.edges ?? [])]; + let index = 0; + for (const native of values) { + if ('source' in native.data && 'target' in native.data) { natives.edges.set(native.data.id ?? `e${index}`, native); index++; } + else natives.nodes.set(native.data.id, native); + } + } else if (['d3', 'jgf', 'xyflow'].includes(format)) { + if (Array.isArray(root.nodes)) for (const node of root.nodes) natives.nodes.set(node.id, node); + else for (const [id, node] of Object.entries(root.nodes ?? {})) natives.nodes.set(id, node); + (root.links ?? root.edges ?? []).forEach((edge: any, index: number) => natives.edges.set(edge.id ?? `e${index}`, edge)); + } + for (const collection of ['nodes', 'edges'] as const) for (const entity of projected[collection]) { + const native = natives[collection].get(entity.id); + const kind = collection === 'nodes' ? 'node' : 'edge'; + const metadata = getWireData(native, kind); + if (metadata) entity.data = getVisibleData(native, metadata, kind); + } + if (format === 'xyflow') for (const collection of ['nodes', 'edges'] as const) { + const byId = new Map((source[collection] ?? []).map((entity: any) => [entity.id, entity])); + for (const entity of projected[collection]) { + if (!entity.data || typeof entity.data !== 'object') continue; + const native: any = byId.get(entity.id); + const metadata = getMetadata(native?.data?.__statelyai, `xyflow.${collection === 'nodes' ? 'node' : 'edge'}`); + if (metadata?.original.data && Object.hasOwn(metadata.original.data, 'label')) entity.data.label = getClonedJSON(metadata.original.data.label); + else delete entity.data.label; + } + } + return projected; +} + +/** Strict native field patches: identity/structure are never inferred from array positions. */ +export function getStructuredDocumentPatch(context: any, format: string): any { + const graph = context.graph; + const baselineGraph = context.sourceGraph; + const source = getClonedJSON(context.source); + const getFailure = (path: string, message = 'No verified native mapping for this Graph edit') => ({ ok: false, diagnostics: [{ code: 'unsupported-edit', severity: 'error', path, message }] }); + const getChanges = (before: any, after: any) => [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((key) => !isJSONEqual(before[key], after[key]) || Object.hasOwn(before, key) !== Object.hasOwn(after, key)); + const updateField = (record: any, key: string, value: any) => { if (value === undefined) delete record[key]; else Object.defineProperty(record, key, { value: getClonedJSON(value), enumerable: true, configurable: true, writable: true }); }; + const updateJGFData = (native: any, before: any, after: any, kind: string, path: string): any => { + const wire = getMetadata(native.metadata?.__statelyai, `jgf.${kind}`); + if (after === null && !wire) { native.metadata = null; return undefined; } + if (!after || typeof after !== 'object' || Array.isArray(after)) return getFailure(path, 'Native JGF metadata requires an object mapping'); + native.metadata ??= {}; + for (const field of getChanges(before ?? {}, after)) { + if (kind === 'edge' && field === 'relation') { updateField(native, 'relation', after.relation); continue; } + if (field === '__statelyai' && wire) return getFailure(path, 'Reserved user metadata has no unambiguous native field mapping'); + updateField(native.metadata, field, after[field]); + } + return undefined; + }; + if (!graph || !baselineGraph) return getFailure('graph', 'A binary Graph projection is unavailable'); + for (const collection of ['nodes', 'edges']) { + if (!isJSONEqual(baselineGraph[collection].map((entity: any) => entity.id), graph[collection].map((entity: any) => entity.id))) return getFailure(collection, 'Structural edits require a native document operation'); + } + if (format === 'adjacency-list' || format === 'edge-list') return getFailure('graph', 'The minimal native representation has no editable Graph fields'); + if (format === 'tgf') { + const tokens = source.split(/(\r\n|\n|\r)/); + const nodeLines = new Map(); + const edgeLines: number[] = []; + let edges = false; + for (let i = 0; i < tokens.length; i += 2) { + if (tokens[i].trim() === '#') { edges = true; continue; } + if (!tokens[i].trim()) continue; + if (edges) edgeLines.push(i); else nodeLines.set(tokens[i].trimStart().split(/\s/)[0], i); + } + for (const key of getChanges(baselineGraph, graph)) if (key !== 'nodes' && key !== 'edges') return getFailure(key); + for (const [collection, records] of [['nodes', nodeLines], ['edges', edgeLines]] as const) { + for (let i = 0; i < graph[collection].length; i++) { + const before = baselineGraph[collection][i], after = graph[collection][i]; + for (const key of getChanges(before, after)) { + if (key !== 'label' || typeof after.label !== 'string' || /[\r\n]/.test(after.label)) return getFailure(`${collection}.${after.id}.${key}`); + const position = collection === 'nodes' ? (records as Map).get(after.id) : (records as number[])[i]; + if (position === undefined) return getFailure(`${collection}.${after.id}`, 'Native line identity is unavailable'); + const pattern = collection === 'nodes' ? /^(\s*\S+)([ \t]*)(.*)$/ : /^(\s*\S+[ \t]+\S+)([ \t]*)(.*)$/; + const match = tokens[position].match(pattern); + if (!match) return getFailure(`${collection}.${after.id}`, 'Native line cannot be patched safely'); + tokens[position] = match[1] + (match[2] || (after.label ? ' ' : '')) + after.label; + } + } + } + return { ok: true, source: tokens.join('') }; + } + const nodes = new Map(), edges = new Map(); + const root = format === 'jgf' ? source.graph : source; + if (format === 'elk') { + const queue = [source]; + for (let i = 0; i < queue.length; i++) { + for (const node of queue[i].children ?? []) { nodes.set(node.id, node); queue.push(node); } + for (const edge of queue[i].edges ?? []) edges.set(edge.id, edge); + } + } else if (format === 'cytoscape') { + const native = Array.isArray(source.elements) ? source.elements : [...(source.elements.nodes ?? []), ...(source.elements.edges ?? [])]; + let index = 0; + for (const value of native) if ('source' in value.data && 'target' in value.data) { edges.set(value.data.id ?? `e${index}`, value); index++; } else nodes.set(value.data.id, value); + } else { + const values = root.nodes ?? []; + if (Array.isArray(values)) for (const node of values) nodes.set(node.id, node); + else for (const [id, node] of Object.entries(values)) nodes.set(id, node); + (root.links ?? root.edges ?? []).forEach((edge: any, index: number) => edges.set(edge.id ?? `e${index}`, edge)); + } + for (const key of getChanges(baselineGraph, graph)) { + if (key === 'nodes' || key === 'edges') continue; + if (format === 'd3' && ['id', 'mode', 'initialNodeId', 'data', 'direction', 'style'].includes(key)) updateField(root, key, graph[key]); + else if (format === 'cytoscape' && ['id', 'mode', 'initialNodeId', 'direction', 'style'].includes(key)) { root.data ??= {}; updateField(root.data, key, graph[key]); } + else if (format === 'cytoscape' && key === 'data' && graph.data && typeof graph.data === 'object' && !Array.isArray(graph.data)) { + root.data ??= {}; + if (getMetadata(root.data.__statelyai, 'cytoscape.graph')) { updateField(root.data, 'graphData', graph.data); continue; } + for (const field of getChanges(baselineGraph.data ?? {}, graph.data)) { + if (['id', 'mode', 'initialNodeId', 'direction', 'style', '__statelyai'].includes(field)) return getFailure(`data.${field}`, 'User data collides with native graph fields'); + updateField(root.data, field, graph.data[field]); + } + } + else if (format === 'jgf' && key === 'data') { const failure = updateJGFData(root, baselineGraph.data, graph.data, 'graph', 'data'); if (failure) return failure; } + else if (format === 'elk' && key === 'direction') { root.layoutOptions ??= {}; updateField(root.layoutOptions, Object.hasOwn(root.layoutOptions, 'org.eclipse.elk.direction') ? 'org.eclipse.elk.direction' : 'elk.direction', String(graph.direction).toUpperCase()); } + else return getFailure(key); + } + for (const collection of ['nodes', 'edges']) { + for (let i = 0; i < graph[collection].length; i++) { + const before = baselineGraph[collection][i], after = graph[collection][i]; + const native = (collection === 'nodes' ? nodes : edges).get(after.id); + if (!native) return getFailure(`${collection}.${after.id}`, 'Native entity identity is unavailable'); + for (const key of getChanges(before, after)) { + const path = `${collection}.${after.id}.${key}`; + if (format === 'd3' && collection === 'nodes' && (root.links ?? []).some((link: any) => ['source', 'target'].some((endpoint) => { + const value = link[endpoint]; + return value && typeof value === 'object' && value.id === after.id && Object.hasOwn(value, key); + }))) return getFailure(path, 'Embedded D3 object endpoints duplicate this node field; JSON cannot prove runtime alias identity'); + + if (key === 'ports' && collection === 'nodes' && ['d3', 'elk'].includes(format)) { + if (!Array.isArray(before.ports) || !Array.isArray(after.ports) || !isJSONEqual(before.ports.map((port: any) => port.name), after.ports.map((port: any) => port.name))) return getFailure(path, 'Native port identity or order changed'); + const byName = new Map((native.ports ?? []).map((port: any) => [format === 'elk' ? port.id : port.name, port])); + for (let p = 0; p < after.ports.length; p++) { + const original = before.ports[p], current = after.ports[p], port: any = byName.get(current.name); + if (!port) return getFailure(path, 'Native port identity is unavailable'); + for (const field of getChanges(original, current)) { + if (format === 'd3' && ['label', 'data', 'style', 'x', 'y', 'width', 'height', 'direction'].includes(field)) updateField(port, field, current[field]); + else if (format === 'elk' && ['x', 'y', 'width', 'height'].includes(field)) updateField(port, field, current[field]); + else if (format === 'elk' && field === 'label' && typeof current.label === 'string') { if (port.labels?.[0]) updateField(port.labels[0], 'text', current.label); else port.labels = [{ text: current.label }]; } + else return getFailure(`${path}.${current.name}.${field}`); + } + } + continue; + } + if (['id', 'type', 'sourceId', 'targetId', 'sourcePort', 'targetPort', 'parentId', 'initialNodeId', 'ports'].includes(key)) return getFailure(path, 'Structural edits require a native document operation'); + if (format === 'd3' && ['label', 'data', 'style', 'x', 'y', 'width', 'height', 'weight', 'color', 'shape', 'mode', 'routing', 'points'].includes(key)) updateField(native, key, after[key]); + else if (format === 'cytoscape' && ['label', 'style', 'width', 'height', 'weight', 'color', 'shape', 'mode', 'routing', 'points'].includes(key)) updateField(native.data, key, after[key]); + else if (format === 'cytoscape' && ['x', 'y'].includes(key) && collection === 'nodes') { if (native.position) updateField(native.position, key, after[key]); else updateField(native.data, key, after[key]); } + else if (format === 'cytoscape' && key === 'data' && after.data && typeof after.data === 'object' && !Array.isArray(after.data)) { + const wire = getMetadata(native.data.__statelyai, `cytoscape.${collection === 'nodes' ? 'node' : 'edge'}`); + if (wire) { updateField(native.data, collection === 'nodes' ? 'nodeData' : 'edgeData', after.data); continue; } + for (const field of getChanges(before.data ?? {}, after.data)) { if (['id', 'parent', 'source', 'target', 'label', 'x', 'y'].includes(field)) return getFailure(path, 'User data collides with native entity fields'); updateField(native.data, field, after.data[field]); } + } else if (format === 'jgf' && key === 'label' && typeof after.label === 'string') updateField(native, key, after.label); + else if (format === 'jgf' && key === 'data') { const failure = updateJGFData(native, before.data, after.data, collection === 'nodes' ? 'node' : 'edge', path); if (failure) return failure; } + else if (format === 'jgf' && key === 'mode' && ['directed', 'undirected'].includes(after.mode)) updateField(native, 'directed', after.mode === 'directed'); + else if (format === 'xyflow' && key === 'label' && typeof after.label === 'string') { if (collection === 'nodes') { native.data ??= {}; updateField(native.data, 'label', after.label); } else updateField(native, 'label', after.label); } + else if (format === 'xyflow' && key === 'data' && after.data && typeof after.data === 'object' && !Array.isArray(after.data)) { for (const field of getChanges(before.data ?? {}, after.data)) { if (field === 'label' || field === '__statelyai') return getFailure(path, 'Renderer labels and reserved wire fields have separate mappings'); native.data ??= {}; updateField(native.data, field, after.data[field]); } } + else if (format === 'xyflow' && ['x', 'y'].includes(key) && collection === 'nodes') updateField(native.position, key, after[key]); + else if (format === 'xyflow' && ['width', 'height'].includes(key) && collection === 'nodes') updateField(native.measured && Object.hasOwn(native.measured, key) ? native.measured : native, key, after[key]); + else if (format === 'elk' && ['x', 'y', 'width', 'height'].includes(key) && collection === 'nodes') updateField(native, key, after[key]); + else if (format === 'elk' && key === 'label' && typeof after.label === 'string') { if (native.labels?.length) updateField(native.labels[0], 'text', after.label); else native.labels = [{ text: after.label }]; } + else if (format === 'elk' && ['x', 'y', 'width', 'height'].includes(key) && collection === 'edges' && native.labels?.length) updateField(native.labels[0], key, after[key]); + else if (format === 'elk' && key === 'points' && Array.isArray(after.points) && after.points.length >= 2) { const first = after.points[0], last = after.points.at(-1), bends = after.points.slice(1, -1); if (native.sections?.[0]) { const section = native.sections[0]; section.startPoint = { ...section.startPoint, ...first }; section.endPoint = { ...section.endPoint, ...last }; if (bends.length || Object.hasOwn(section, 'bendPoints')) section.bendPoints = bends; } else if ('source' in native) { native.sourcePoint = { ...native.sourcePoint, ...first }; native.targetPoint = { ...native.targetPoint, ...last }; native.bendPoints = bends; } else return getFailure(path, 'Native routing section identity is unavailable'); } + else return getFailure(path); + } + } + } + return { ok: true, source }; +} diff --git a/src/formats/structured-json.ts b/src/formats/structured-json.ts new file mode 100644 index 0000000..6e3432b --- /dev/null +++ b/src/formats/structured-json.ts @@ -0,0 +1,172 @@ +import type { Graph } from '../types'; +import { createMetadata, getMetadata, getRestoredRecord, getClonedJSON, isJSONEqual, getMergedNative, getEditedData } from './preservation'; + +/** Private attribute convention shared by the three native JSON adapters. */ +export const ROOT_FIELDS = ['id', 'initialNodeId', 'direction', 'style']; +export const NODE_FIELDS = ['label', 'parentId', 'initialNodeId', 'x', 'y', 'width', 'height', 'color', 'shape', 'style', 'ports']; +export const EDGE_FIELDS = ['label', 'weight', 'sourcePort', 'targetPort', 'points', 'routing', 'x', 'y', 'width', 'height', 'color', 'style']; + +export function getFields(kind: 'graph' | 'node' | 'edge'): string[] { + return kind === 'graph' ? ROOT_FIELDS : kind === 'node' ? NODE_FIELDS : EDGE_FIELDS; +} + +export function getRecordWithField(record: T, field: string, value: any): T { + const result = getClonedJSON(record) as any; + if (value === undefined) delete result[field]; + else Object.defineProperty(result, field, { value, enumerable: true, writable: true, configurable: true }); + return result; +} + +export function isNativeField(field: string, value: any): boolean { + if (['label', 'parentId', 'initialNodeId'].includes(field)) return value === null || typeof value === 'string'; + if (['id', 'sourcePort', 'targetPort', 'color', 'shape'].includes(field)) return typeof value === 'string'; + if (field === 'direction') return ['up', 'down', 'left', 'right'].includes(value); + if (field === 'routing') return ['polyline', 'orthogonal', 'splines'].includes(value); + if (['x', 'y', 'width', 'height', 'weight'].includes(field)) return typeof value === 'number' && Number.isFinite(value); + if (field === 'style') return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === 'string' || typeof entry === 'boolean' || (typeof entry === 'number' && Number.isFinite(entry))); + if (field === 'points') return Array.isArray(value) && value.every((point) => point && typeof point.x === 'number' && Number.isFinite(point.x) && typeof point.y === 'number' && Number.isFinite(point.y)); + if (field === 'ports') return Array.isArray(value) && value.every((port) => port && typeof port === 'object' && typeof port.name === 'string' && ['in', 'out', 'inout'].includes(port.direction) && (!Object.hasOwn(port, 'label') || typeof port.label === 'string') && ['x', 'y', 'width', 'height'].every((key) => !Object.hasOwn(port, key) || isNativeField(key, port[key]))); + return false; +} + +/** Protocol snapshots are untrusted JSON, including matching origin headers. */ +export function isNativeMetadataRecord(record: any, kind: string): boolean { + if (!record || typeof record !== 'object' || Array.isArray(record) || typeof record.id !== 'string') return false; + if (kind === 'graph') { if (!['directed', 'undirected', 'bidirectional'].includes(record.mode)) return false; } + else if (record.type !== kind || (kind === 'edge' && (typeof record.sourceId !== 'string' || typeof record.targetId !== 'string'))) return false; + if (kind === 'edge' && record.mode !== undefined && !['directed', 'undirected', 'bidirectional'].includes(record.mode)) return false; + return getFields(kind as 'graph' | 'node' | 'edge').every((field) => !Object.hasOwn(record, field) || record[field] === undefined || isNativeField(field, record[field])); +} + +function getMarkerIndex(key: string): number | undefined { + if (key === '__statelyai') return 0; + if (!/^__statelyai__\d+$/.test(key)) return undefined; + const value = Number(key.slice('__statelyai__'.length)); + return Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +function getNativeMetadataKey(attributes: any, format: string, kind: string): string | undefined { + if (!attributes || typeof attributes !== 'object') return undefined; + return Object.keys(attributes).filter((key) => getMarkerIndex(key) !== undefined && attributes[key]?.origin === `@statelyai/graph/${format}` && getMetadata(attributes[key], `${format}.${kind}`) && isNativeMetadataRecord(attributes[key].original, kind) && isNativeMetadataRecord(attributes[key].mapped, kind)) + .sort((a, b) => getMarkerIndex(b)! - getMarkerIndex(a)!)[0]; +} + +export function getNativeMetadata(attributes: any, format: string, kind: string): ReturnType { + const key = getNativeMetadataKey(attributes, format, kind); + return key === undefined ? undefined : attributes[key]; +} + +export function getAttributeRecord(attributes: Record | undefined, base: Record, kind: 'graph' | 'node' | 'edge', format: string, native = false): any { + const attrs = attributes ?? {}; + const metadata = native ? undefined : getNativeMetadata(attrs, format, kind); + const metadataKey = native ? undefined : getNativeMetadataKey(attrs, format, kind); + const fields = getFields(kind); + const represented = fields.filter((key) => Object.hasOwn(attrs, key) && isNativeField(key, attrs[key])); + const data = Object.fromEntries(Object.entries(attrs).filter(([key]) => !represented.includes(key) && key !== metadataKey)); + const mapped = { ...base, ...Object.fromEntries(represented.map((key) => [key, attrs[key]])), data }; + const record = getRestoredRecord(mapped, metadata); + // Emitted Graph user data occupies its own native attribute. Added unknown + // native attributes stay in source provenance, never replace user data. + if (metadata) record.data = (metadata as any).dataEncoding === 'native-attributes' + ? getEditedData(metadata.original.data, metadata.mapped.data, data) + : Object.hasOwn(attrs, 'data') ? getClonedJSON(attrs.data) : undefined; + return record; +} + +/** Attach identity only when needed, retaining foreign attribute layout and keys. */ +export function getRebasedAttributes(attributes: Record, entity: Record, kind: 'node' | 'edge', format: string, base: Record, force = false): Record { + const attrs = getClonedJSON(attributes); + const candidate = getNativeMetadataKey(attrs, format, kind); + const key = candidate && !Object.hasOwn(entity.data ?? {}, candidate) ? candidate : undefined; + const metadata = key ? attrs[key] : undefined; + const identityFields = kind === 'node' ? ['id'] : ['id', 'sourceId', 'targetId']; + if (!force && identityFields.every((field) => isJSONEqual((metadata?.mapped ?? entity)[field], base[field]))) return attrs; + const visible = getClonedJSON(attrs); if (key) delete visible[key]; + let markerKey = key ?? '__statelyai'; + if (!key && Object.hasOwn(attrs, markerKey)) { + let suffix = 1; + while (Object.hasOwn(attrs, `__statelyai__${suffix}`)) suffix++; + markerKey = `__statelyai__${suffix}`; + } + Object.defineProperty(attrs, markerKey, { value: { ...createMetadata(`${format}.${kind}`, entity, getAttributeRecord(visible, base, kind, format, true)), origin: `@statelyai/graph/${format}`, dataEncoding: metadata ? metadata.dataEncoding ?? 'attribute' : 'native-attributes' }, enumerable: true, writable: true, configurable: true }); + return attrs; +} + +export function getNativeAttributes(entity: Record, kind: 'graph' | 'node' | 'edge', format: string, base: Record): Record { + const attrs: Record = Object.fromEntries(getFields(kind).filter((key) => Object.hasOwn(entity, key)).map((key) => [key, getClonedJSON(entity[key])])); + if (Object.hasOwn(entity, 'data') && entity.data !== undefined) attrs.data = getClonedJSON(entity.data); + attrs.__statelyai = { ...createMetadata(`${format}.${kind}`, entity, getAttributeRecord(attrs, base, kind, format, true)), origin: `@statelyai/graph/${format}` }; + return attrs; +} + +export function getUpdatedAttributes(original: Record | undefined, before: Record, current: Record, kind: 'graph' | 'node' | 'edge', format: string, base: Record): Record | undefined { + if (isJSONEqual(before, current)) return getClonedJSON(original); + const metadata = getNativeMetadata(original, format, kind); + const markerKey = getNativeMetadataKey(original, format, kind); + const isWire = metadata && markerKey && !Object.hasOwn(before.data ?? {}, markerKey); + if (isWire && (metadata as any).dataEncoding !== 'native-attributes') { + return getMergedNative(original, getNativeAttributes(before, kind, format, base), getNativeAttributes(current, kind, format, base)); + } + const attrs = getClonedJSON(original ?? {}); + for (const field of getFields(kind)) { + if (isJSONEqual(before[field], current[field]) && Object.hasOwn(before, field) === Object.hasOwn(current, field)) continue; + if (Object.hasOwn(before.data ?? {}, field)) throw new Error(`${format}: native ${field} is unrepresentable user data; separate field edit would erase it`); + if (Object.hasOwn(current, field) && current[field] !== undefined) Object.defineProperty(attrs, field, { value: getClonedJSON(current[field]), enumerable: true, writable: true, configurable: true }); + else delete attrs[field]; + } + if (!isJSONEqual(before.data, current.data)) { + if (current.data !== undefined && (!current.data || typeof current.data !== 'object' || Array.isArray(current.data))) throw new Error(`${format}: native attributes require object data; scalar replacement is unsupported`); + const previous = before.data ?? {}; + const data = current.data ?? {}; + for (const key of new Set([...Object.keys(previous), ...Object.keys(data)])) { + if (Object.hasOwn(previous, key) === Object.hasOwn(data, key) && isJSONEqual(previous[key], data[key])) continue; + if (getFields(kind).includes(key) && Object.hasOwn(data, key) && isNativeField(key, data[key])) throw new Error(`${format}: user data collides with native ${key}`); + if (Object.hasOwn(data, key)) Object.defineProperty(attrs, key, { value: getClonedJSON(data[key]), enumerable: true, writable: true, configurable: true }); + else delete attrs[key]; + } + } + return isWire && kind !== 'graph' ? getRebasedAttributes(attrs, current, kind, format, base, true) : attrs; +} + +export function getNodeBase(id: string): Graph['nodes'][number] { + return { type: 'node', id, parentId: null, initialNodeId: null, label: '', data: undefined }; +} + +export function getEdgeBase(id: string, sourceId: string, targetId: string, mode?: 'directed' | 'undirected'): Graph['edges'][number] { + return { type: 'edge', id, sourceId, targetId, label: '', data: undefined, ...(mode && { mode }) }; +} + +export function getRootBase(mode: 'directed' | 'undirected'): Omit { + return { id: '', mode, initialNodeId: null, data: undefined }; +} + +export function getUniformMode(graph: Graph, format: string): 'directed' | 'undirected' { + const modes = new Set(graph.edges.map((edge) => edge.mode ?? graph.mode)); + if (!modes.size) modes.add(graph.mode); + if (modes.size !== 1 || modes.has('bidirectional')) throw new Error(`${format}: native graphs require one directed or undirected mode`); + return [...modes][0] as 'directed' | 'undirected'; +} + +export function getValidatedGraph(graph: Graph, format: string): void { + const nodes = new Set(); + const edges = new Set(); + for (const node of graph.nodes) { + if (typeof node.id !== 'string' || nodes.has(node.id)) throw new Error(`${format}: node IDs must be unique strings`); + nodes.add(node.id); + } + for (const edge of graph.edges) { + if (typeof edge.id !== 'string' || edges.has(edge.id)) throw new Error(`${format}: edge IDs must be unique strings`); + if (!nodes.has(edge.sourceId) || !nodes.has(edge.targetId)) throw new Error(`${format}: missing edge endpoint`); + edges.add(edge.id); + } +} + +/** Native document edits use only demonstrated attributes, never private snapshots alone. */ +export function getAttributeDocumentPatch(context: any, format: string, toNative: (graph: Graph) => any): any { + for (const change of context.changes) { + const fields = getFields(change.entity); + if ((!fields.includes(change.field) && change.field !== 'data') || ['id', 'parentId', 'initialNodeId', 'sourcePort', 'targetPort', 'ports'].includes(change.field)) return { ok: false, diagnostics: [{ code: 'unsupported-native-edit', severity: 'error', path: change.path, message: `${format}: ${change.field} cannot be patched without changing native structure` }] }; + } + try { return { ok: true, source: toNative(context.graph) }; } + catch (error) { return { ok: false, diagnostics: [{ code: 'unsupported-native-edit', severity: 'error', message: String(error) }] }; } +} diff --git a/src/formats/support.ts b/src/formats/support.ts index afccc00..4e8165f 100644 --- a/src/formats/support.ts +++ b/src/formats/support.ts @@ -1,5 +1,21 @@ +/** `full` covers the stated contract; `partial` covers a subset; `none` is unsupported. */ export type FormatSupportLevel = 'full' | 'partial' | 'none'; +/** + * Distinct bare-Graph converter preservation contracts. Native document envelopes + * separately retain exact source on no-op export within their validated JSON domain. + * These describe existing behavior, not a + * promise that every native construct is representable by the Graph model. + */ +export interface FormatPreservationCapabilities { + /** Graph -> format -> Graph: all JSON-serializable fields, presence and entity order. */ + graph: FormatSupportLevel; + /** Native format -> Graph -> format: native topology, attributes and semantics. */ + native: FormatSupportLevel; + /** Source-only details: comments, declaration order, spelling and whitespace. */ + document: FormatSupportLevel; +} + export interface FormatSupportFeatures { directed: FormatSupportLevel; undirected: FormatSupportLevel; @@ -8,6 +24,7 @@ export interface FormatSupportFeatures { visual: FormatSupportLevel; style: FormatSupportLevel; weight: FormatSupportLevel; + /** Legacy Graph round-trip assessment; use `preservation` for distinct contracts. */ roundTrip: FormatSupportLevel; } @@ -15,20 +32,132 @@ export interface FormatSupportEntry { id: string; importPath: string; features: FormatSupportFeatures; + /** Optional for compatibility with third-party entries; populated for all built-in formats. */ + preservation?: FormatPreservationCapabilities; notes: string[]; } /** - * Round-trip support is allowed to use adapter-specific graph, node, and edge - * `data` metadata when the target format has no native field for a source - * concept. A `partial` value means the adapter still drops meaningful source - * information instead of preserving it as metadata. + * Feature support describes mapped Graph concepts. Adapter-specific metadata + * may carry concepts without a native field, but that does not establish native + * format conformance or document preservation. Selected fixture tests exercise + * only their asserted fields; no adapter currently preserves unrestricted Graphs. */ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ + { + id: 'adjacency-matrix', importPath: '@statelyai/graph/adjacency-matrix', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, + features: { directed: 'full', undirected: 'partial', hierarchy: 'none', ports: 'none', visual: 'partial', style: 'partial', weight: 'partial', roundTrip: 'partial' }, + notes: ["Numeric adjacency matrices require an explicit aggregation policy for parallels and preserve selected Graph fields through a sidecar. Matrix values cannot independently encode identities, direction, ports or native document metadata."], + }, + { + id: 'matrix-market', importPath: '@statelyai/graph/matrix-market', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, + features: { directed: 'full', undirected: 'partial', hierarchy: 'none', ports: 'none', visual: 'partial', style: 'partial', weight: 'partial', roundTrip: 'partial' }, + notes: ["Matrix Market uses square coordinate/array real/integer/pattern profiles and explicit Graph metadata sidecars; complex/skew/malformed inputs reject. Aggregation and symmetry policies are explicit; numeric matrices do not independently retain arbitrary graph semantics."], + }, + { + id: 'graphviz-json', importPath: '@statelyai/graph/graphviz-json', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, + features: { directed: 'full', undirected: 'partial', hierarchy: 'partial', ports: 'partial', visual: 'partial', style: 'partial', weight: 'partial', roundTrip: 'partial' }, + notes: ["Consumes Graphviz JSON objects/_gvid endpoints and rendered layout attributes. Captured layout is preserved for unedited native imports; structural regeneration is explicit and discards stale rendering commands. This is a scoped rendered-layout JSON profile. Graphviz provides JSON output rather than a native JSON input reader; fresh structural JSON has no native-reader conformance claim."], + }, + { + id: 'drawio', importPath: '@statelyai/graph/drawio', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, + features: { directed: 'full', undirected: 'partial', hierarchy: 'full', ports: 'partial', visual: 'partial', style: 'partial', weight: 'partial', roundTrip: 'partial' }, + notes: ["Maps mxGraph cells, layers, hierarchy, styles and routing in selected draw.io pages. Arrow decorations are separate from graph direction; mode interpretation is explicit. Compressed raw-DEFLATE pages require optional fflate, and HTML labels remain raw source values."], + }, + { + id: 'pajek', importPath: '@statelyai/graph/pajek', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, + features: { directed: 'full', undirected: 'partial', hierarchy: 'none', ports: 'none', visual: 'partial', style: 'partial', weight: 'partial', roundTrip: 'partial' }, + notes: ["Scoped Pajek vertices/arcs/edges preserve names, uniform modes, weights and selected metadata. Hierarchy, ports and other Graph-only fields use private comment metadata, rather than native Pajek features. Unknown native attributes remain opaque; incompatible mixed/bidirectional graphs and executable/unprojected sections reject."], + }, + { + id: 'plantuml', importPath: '@statelyai/graph/plantuml', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, + features: { directed: 'full', undirected: 'partial', hierarchy: 'partial', ports: 'none', visual: 'partial', style: 'partial', weight: 'partial', roundTrip: 'partial' }, + notes: ["Scoped PlantUML component/class declarations, hierarchy, members and relationships map to Graph. Executable includes/preprocessors and other diagram languages reject; semantic coverage remains partial. Exact document source retention is separate from projection."], + }, + { + id: 'mermaid/architecture', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, + features: { directed: 'full', undirected: 'partial', hierarchy: 'full', ports: 'full', visual: 'partial', style: 'partial', weight: 'none', roundTrip: 'partial' }, + notes: ["Architecture services/groups/junctions map hierarchy, icons and L/R/T/B connection-side ports; canonical modes govern arrows. Unknown grammar rejects projection while documents preserve exact source; verified declaration-label patches are bounded."], + }, + { + id: 'mermaid/requirement', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, + features: { directed: 'full', undirected: 'none', hierarchy: 'none', ports: 'none', visual: 'partial', style: 'partial', weight: 'none', roundTrip: 'partial' }, + notes: ["Requirement types, business IDs, text, risk, verification, typed relationships and selected styles/classes are mapped. Native empty strings require omission; empty identities reject. Verified body-text patches preserve comments; other native semantics remain partial."], + }, + { + id: 'mermaid/sankey', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, + features: { directed: 'full', undirected: 'none', hierarchy: 'none', ports: 'none', visual: 'partial', style: 'none', weight: 'full', roundTrip: 'partial' }, + notes: ["Mermaid12.1 Sankey accepts printable ASCII names only and trims outer whitespace, even within quoted fields. Weights map native values; missing export weights default to one. Unicode, isolates and incompatible topology reject projection/output; documents retain unsupported source exactly."], + }, + { + id: 'mermaid/gitgraph', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, + features: { directed: 'full', undirected: 'none', hierarchy: 'none', ports: 'none', visual: 'partial', style: 'partial', weight: 'none', roundTrip: 'partial' }, + notes: ["GitGraph projects ordered branch/checkout/commit/merge/cherry-pick actions into a child-to-parent commit DAG. Anonymous IDs normalize; mainBranch controls native configuration. Inconsistent action/DAG edits reject; document Graph patches are unsupported."], + }, + { + id: 'mermaid/c4', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, + features: { directed: 'full', undirected: 'none', hierarchy: 'full', ports: 'none', visual: 'partial', style: 'partial', weight: 'none', roundTrip: 'partial' }, + notes: ["Scoped C4 Context/Container/Component/Dynamic/Deployment macros, boundaries, typed entities, technologies, descriptions and relation arguments are mapped. Unsupported renderer/update macros reject projection; verified declaration-label patches retain source."], + }, + { + id: 'mermaid/usecase', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, + features: { directed: 'full', undirected: 'partial', hierarchy: 'partial', ports: 'none', visual: 'partial', style: 'partial', weight: 'none', roundTrip: 'partial' }, + notes: ["Mermaid12 UseCase actors, one-level system boundaries, include/extend/generalization, association markers/lengths, notes, JSON tables and selected metadata/styles/classes map to Graph. Notes normalize after regular entities; verified declaration-label patches are bounded."], + }, + + { + id: 'csv', + importPath: '@statelyai/graph/csv', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, + features: { directed: 'full', undirected: 'full', hierarchy: 'full', ports: 'full', visual: 'full', style: 'full', weight: 'full', roundTrip: 'partial' }, + notes: ["Explicit node/edge tables retain isolates; configurable typed columns and default JSON cells preserve null, missing and empty values. The Graph-specific schema/root/extra fields are a partial profile, not a universal CSV graph standard. Exact document no-op preserves unknown columns; bounded cell edits retain untouched text."], + }, + { + id: 'tsv', + importPath: '@statelyai/graph/csv', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, + features: { directed: 'full', undirected: 'full', hierarchy: 'full', ports: 'full', visual: 'full', style: 'full', weight: 'full', roundTrip: 'partial' }, + notes: ["Uses the CSV adapter with a tab delimiter and explicit node/edge tables. Custom schemas and nativeCarrier:false can omit Graph fields; document edits are bounded to mapped existing cells."], + }, + { + id: 'graphology', + importPath: '@statelyai/graph/graphology', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, + features: { directed: 'full', undirected: 'full', hierarchy: 'partial', ports: 'full', visual: 'full', style: 'full', weight: 'full', roundTrip: 'partial' }, + notes: ["Graphology export JSON supports mixed, directed and undirected graphs and parallel edges. Graph-specific fields use attribute metadata; native reserved options and constructor-sensitive keys have explicit boundaries."], + }, + { + id: 'graphlib', + importPath: '@statelyai/graph/graphlib', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, + features: { directed: 'full', undirected: 'full', hierarchy: 'full', ports: 'full', visual: 'full', style: 'full', weight: 'full', roundTrip: 'partial' }, + notes: ["Graphlib JSON maps compound hierarchy and named parallel edges. Native direction is uniform; mixed/bidirectional graphs require explicit conversion policy or are rejected. Graph-specific fields use attribute metadata."], + }, + { + id: 'networkx', + importPath: '@statelyai/graph/networkx', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, + features: { directed: 'full', undirected: 'full', hierarchy: 'partial', ports: 'full', visual: 'full', style: 'full', weight: 'full', roundTrip: 'partial' }, + notes: ["NetworkX 3.7 node-link JSON defaults to edges; links requires an explicit legacy option. Typed native IDs and multigraph keys are retained. Native direction is uniform; Graph-specific fields use metadata."], + }, + { id: 'adjacency-list', importPath: '@statelyai/graph/adjacency-list', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, features: { directed: 'full', undirected: 'partial', @@ -39,11 +168,12 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ weight: 'none', roundTrip: 'partial', }, - notes: ['Adjacency lists preserve connectivity, but not edge metadata.'], + notes: ['Adjacency lists preserve connectivity with collision-safe endpoint tuples and prototype-safe keys; identity, parallel-edge multiplicity and arbitrary edge metadata remain unsupported.'], }, { id: 'cytoscape', importPath: '@statelyai/graph/cytoscape', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, features: { directed: 'full', undirected: 'full', @@ -52,7 +182,7 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ visual: 'full', style: 'full', weight: 'full', - roundTrip: 'full', + roundTrip: 'partial', }, notes: [ 'Uses Cytoscape JSON element data with graph, node, and edge metadata stored in element data.', @@ -63,6 +193,7 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ { id: 'd3', importPath: '@statelyai/graph/d3', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, features: { directed: 'full', undirected: 'full', @@ -71,7 +202,7 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ visual: 'full', style: 'full', weight: 'full', - roundTrip: 'full', + roundTrip: 'partial', }, notes: [ 'Targets force-graph structures, but graph, node, and edge metadata can be preserved on the loose JSON shape.', @@ -82,6 +213,7 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ { id: 'd2', importPath: '@statelyai/graph/d2', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, features: { directed: 'full', undirected: 'full', @@ -90,20 +222,21 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ visual: 'full', style: 'full', weight: 'none', - roundTrip: 'full', + roundTrip: 'partial', }, notes: [ - 'Containers map to hierarchy; dot vs block declaration form is preserved per node.', + 'Containers map to hierarchy; authored key paths/declaration hints are retained where compatible with live Graph edits.', 'sql_table/class fields map to ports; node.field connections round-trip as sourcePort/targetPort.', - 'Per-edge connectors (->, <-, --, <->) map to edge.mode; the authored glyph is preserved in _d2.arrow.', - 'vars/classes/imports are preserved on graph data; comments attach to the following entity (best-effort).', - 'Only flat key/value vars round-trip; nested sub-blocks inside vars are dropped.', + 'Per-edge connectors (->, <-, --, <->) map to edge.mode; the authored glyph is preserved in edge.data.arrow.', + 'Recursive vars/classes and scoped import kind/order are preserved as source metadata; substitutions/imports remain opaque, and boards/globs are retained for inspection rather than projected as nodes.', + 'Quoted key identity, ancestor/indexed-edge references, chain attributes, structured common attributes and arrowheads are retained. Effective canonical modes override stale connector hints; comments may be reformatted.', 'd2 has no native edge weight.', ], }, { id: 'dot', importPath: '@statelyai/graph/dot', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, features: { directed: 'full', undirected: 'full', @@ -115,13 +248,14 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ roundTrip: 'partial', }, notes: [ - 'Edge port ids round-trip, but compass points and node port definitions are not mapped.', - 'HTML labels and layout hints beyond `rankdir` are lossy.', + 'Edge port ids, compass points and supported record/HTML label port definitions are retained; unrestricted DOT grammar and native port semantics remain partial.', + 'Repeated declarations, strict edges, rank-group contents, native clusters, HTML labels and inherited attributes retain supported semantics. Comments and source declaration order remain unsupported.', ], }, { id: 'edge-list', importPath: '@statelyai/graph/edge-list', + preservation: { graph: 'partial', native: 'full', document: 'none' }, features: { directed: 'full', undirected: 'partial', @@ -137,6 +271,7 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ { id: 'elk', importPath: '@statelyai/graph/elk', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, features: { directed: 'full', undirected: 'partial', @@ -145,17 +280,18 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ visual: 'full', style: 'full', weight: 'full', - roundTrip: 'full', + roundTrip: 'partial', }, notes: [ - 'ELK-native layout fields are preserved directly; graph, node, port, and edge metadata round-trip through reserved layout options.', + 'Native ELK labels, sections, options and primitive edges are retained alongside mapped Graph fields; hyperedges are rejected at the Graph boundary rather than truncated.', 'Per-edge `mode` overrides (including bidirectional) round-trip through reserved layout options.', - 'Port ids are emitted as `nodeId__portName` so they are document-unique as ELK requires; original port names round-trip through reserved layout options.', + 'Port IDs use collision-free document-unique allocation; original port names round-trip through reserved metadata.', ], }, { id: 'gexf', importPath: '@statelyai/graph/gexf', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, features: { directed: 'full', undirected: 'full', @@ -164,17 +300,18 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ visual: 'full', style: 'full', weight: 'full', - roundTrip: 'full', + roundTrip: 'partial', }, notes: [ 'Custom attributes preserve graph, node, and edge metadata beyond the standard viz module.', 'Ports round-trip via custom node/edge attributes.', - 'Per-edge directedness round-trips via the `type` edge attribute; bidirectional maps to directed.', + 'Native weight, undirected defaults and mutual bidirectional edges are imported/emitted. Scoped attributes, selected viz data and opaque temporal metadata are retained; custom Graph extensions are not an unrestricted XSD-conformance guarantee.', ], }, { id: 'gml', importPath: '@statelyai/graph/gml', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, features: { directed: 'full', undirected: 'full', @@ -183,7 +320,7 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ visual: 'full', style: 'full', weight: 'full', - roundTrip: 'full', + roundTrip: 'partial', }, notes: [ 'GML stores graph, node, and edge metadata directly or as JSON-stringified fields.', @@ -194,6 +331,7 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ { id: 'graphml', importPath: '@statelyai/graph/graphml', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, features: { directed: 'full', undirected: 'full', @@ -202,19 +340,20 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ visual: 'partial', style: 'partial', weight: 'full', - roundTrip: 'full', + roundTrip: 'partial', }, notes: [ 'Emit is own-dialect: a flat structure with hierarchy, ports, and metadata in `` fields; the emitter does not write nested `` or native `` elements.', 'Import handles both dialects: own-dialect `` fields plus standard nested `` hierarchy, native `` elements (imported as direction `inout`), and `sourceport`/`targetport` edge attributes. `` fields take precedence.', - 'Multi-graph documents import the first `` element only.', - 'Vendor extensions (e.g. yEd visual attributes) are not represented.', - 'Per-edge directedness round-trips via the `directed` edge attribute; bidirectional maps to directed.', + 'Multi-graph documents import the first `` element only; hyperedges fail explicitly because Graph cannot faithfully represent their endpoints.', + 'Scoped key IDs/names/types/defaults, prefixed namespaces, nested direction and native port metadata are imported. Unknown vendor data is retained opaquely; renderer-specific semantics are not interpreted.', + 'Per-edge directedness uses the native boolean edge attribute; Graph bidirectional mode and optional-field presence use private metadata while visible native edits take precedence.', ], }, { id: 'jgf', importPath: '@statelyai/graph/jgf', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, features: { directed: 'full', undirected: 'full', @@ -223,10 +362,10 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ visual: 'full', style: 'full', weight: 'full', - roundTrip: 'full', + roundTrip: 'partial', }, notes: [ - 'JGF preserves graph, node, and edge metadata via `metadata` objects.', + 'Supports JGF v1 node arrays and explicit v2 node dictionaries; legacy dialect input remains readable. Native relation/directedness and foreign metadata are retained with versioned Graph metadata.', 'Ports round-trip through node and edge metadata.', 'Per-edge `mode` overrides and graph-level bidirectional mode round-trip through `metadata` (JGF `directed` is binary).', ], @@ -234,6 +373,7 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ { id: 'tgf', importPath: '@statelyai/graph/tgf', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, features: { directed: 'full', undirected: 'partial', @@ -249,6 +389,7 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ { id: 'xyflow', importPath: '@statelyai/graph/xyflow', + preservation: { graph: 'partial', native: 'partial', document: 'none' }, features: { directed: 'full', undirected: 'partial', @@ -257,18 +398,19 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ visual: 'full', style: 'full', weight: 'full', - roundTrip: 'full', + roundTrip: 'partial', }, notes: [ - 'xyflow-native fields are preserved directly; graph, node, edge, style, weight, port, and per-edge `mode` metadata round-trip through reserved data fields.', + 'Native xyflow viewport/UI fields and foreign reserved-key data are retained through versioned metadata; unambiguous renderer-visible edits take precedence over stored Graph snapshots.', ], }, { id: 'mermaid/block', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, features: { directed: 'full', - undirected: 'none', + undirected: 'partial', hierarchy: 'partial', ports: 'none', visual: 'partial', @@ -276,26 +418,28 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ weight: 'none', roundTrip: 'partial', }, - notes: ['Block edge semantics currently follow flowchart-style behavior.'], + notes: ['Block quoted labels, shapes, spans, nested columns and direct styles are retained. Supported connectors use canonical modes; unsupported spacing/directive syntax is retained for inspection.'], }, { id: 'mermaid/class', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, features: { directed: 'partial', undirected: 'partial', - hierarchy: 'none', + hierarchy: 'partial', ports: 'none', visual: 'none', style: 'partial', weight: 'none', roundTrip: 'partial', }, - notes: ['Generic syntax is stored conservatively and not fully parsed.'], + notes: ['Class namespaces, aliases, notes, styles, lollipop interfaces and two-sided relationships are retained. Generic/member syntax remains conservative and is not a complete UML semantic model.'], }, { id: 'mermaid/er', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, features: { directed: 'partial', undirected: 'partial', @@ -306,14 +450,15 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ weight: 'none', roundTrip: 'partial', }, - notes: ['Entity attributes and cardinality are preserved better than layout metadata.'], + notes: ['ER isolated entities, aliases, direction, multiple attribute keys/comments and supported cardinalities are retained; complete styling/renderer metadata remains unsupported.'], }, { id: 'mermaid/flowchart', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, features: { directed: 'full', - undirected: 'none', + undirected: 'partial', hierarchy: 'partial', ports: 'none', visual: 'partial', @@ -322,13 +467,14 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ roundTrip: 'partial', }, notes: [ - 'Index-based `linkStyle` metadata is fragile after graph mutation.', - 'Mermaid init directives are not fully preserved.', + 'Flowchart styles, expanded shape/image/icon properties, explicit edge IDs, long connectors and declaration overrides are retained; linkStyle indexes are recomputed from live edges.', + 'Canonical labels/shapes/modes and live stroke edits override stale source hints. Comments, opaque frontmatter and JSON directives are retained, with reformatted source placement.', ], }, { id: 'mermaid/ishikawa', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, features: { directed: 'full', undirected: 'none', @@ -344,6 +490,7 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ { id: 'mermaid/mindmap', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, features: { directed: 'partial', undirected: 'none', @@ -354,11 +501,12 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ weight: 'none', roundTrip: 'partial', }, - notes: ['Icon syntax is stored conservatively and not fully re-emitted.'], + notes: ['Mindmap named shapes retain authored IDs; icon/class lines decorate their node, and escaping is symmetric. Arbitrary non-tree edges and generated-ID identity are not native source concepts.'], }, { id: 'mermaid/sequence', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, features: { directed: 'full', undirected: 'none', @@ -369,11 +517,12 @@ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ weight: 'none', roundTrip: 'partial', }, - notes: ['Actor links and menu syntax are not yet supported.'], + notes: ['Sequence notes/lifecycle use ordered live-entity references and stable note IDs; missing references fail explicitly. Official participant configuration, participant order and numbering parameters are retained; actor links/menus remain unsupported.'], }, { id: 'mermaid/state', importPath: '@statelyai/graph/mermaid', + preservation: { graph: 'partial', native: 'partial', document: 'partial' }, features: { directed: 'full', undirected: 'none', diff --git a/src/formats/tgf/README.md b/src/formats/tgf/README.md index 720bd98..345a517 100644 --- a/src/formats/tgf/README.md +++ b/src/formats/tgf/README.md @@ -2,6 +2,16 @@ Converter for [TGF](https://en.wikipedia.org/wiki/Trivial_Graph_Format) (Trivial Graph Format) — a minimal newline-delimited text format. +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **partial**. +Source document preservation: **none**. These are separate guarantees; +selected-field round-trip tests do not prove unrestricted losslessness. + +Imports CRLF, LF and CR line endings and whitespace-separated endpoints. Export rejects IDs containing whitespace and multiline labels because TGF cannot encode them unambiguously. Node IDs and textual labels are supported; edge IDs, metadata, ports, hierarchy and geometry remain outside this representation. + ## Resources - [TGF on Wikipedia](https://en.wikipedia.org/wiki/Trivial_Graph_Format) @@ -74,3 +84,16 @@ const graph = tgfConverter.from(tgf); ``` > **Note:** TGF is a lossy format. Node `data`, `parentId`, visual properties, and edge `data` are discarded on export. + + +## Native documents + + + +`tgfDocumentConverter.from(source)` creates a plain JSON document with the +original source and a separate Graph projection. `.to(document)` preserves exact +source when unchanged, including after JSON serialization. Supported edits patch +verified native fields; unsupported edits throw with diagnostics. `.getUpdate(document)` +returns a diagnostic-bearing result. Native semantic projection remains partial. + +Original line endings, separator whitespace and declaration spacing stay intact. Node and edge labels can be edited on verified declaration lines. Embedded newlines, structural changes and fields outside TGF reject. diff --git a/src/formats/tgf/index.ts b/src/formats/tgf/index.ts index c63e24a..bb4c4eb 100644 --- a/src/formats/tgf/index.ts +++ b/src/formats/tgf/index.ts @@ -1,3 +1,5 @@ +import { createGraphDocumentConverter } from '../document'; +import { getStructuredDocumentProjectionGraph, getStructuredDocumentPatch } from '../preservation'; import type { Graph, GraphFormatConverter } from '../../types'; import { createFormatConverter } from '../converter'; @@ -22,14 +24,19 @@ import { createFormatConverter } from '../converter'; */ export function toTGF(graph: Graph): string { const lines: string[] = []; + const ids = new Set(graph.nodes.map((node) => node.id)); for (const node of graph.nodes) { + if (!node.id || /\s/.test(node.id) || node.id === '#') throw new Error('TGF: node ids must be whitespace-free tokens other than #'); + if (node.label != null && /[\r\n]/.test(node.label)) throw new Error('TGF: labels cannot contain newlines'); lines.push(node.label ? `${node.id} ${node.label}` : node.id); } lines.push('#'); for (const edge of graph.edges) { + if (!ids.has(edge.sourceId) || !ids.has(edge.targetId)) throw new Error('TGF: edge endpoint references a missing node'); + if (edge.label != null && /[\r\n]/.test(edge.label)) throw new Error('TGF: labels cannot contain newlines'); const parts = [edge.sourceId, edge.targetId]; if (edge.label) parts.push(edge.label); lines.push(parts.join(' ')); @@ -53,18 +60,21 @@ export function fromTGF(tgf: string): Graph { if (typeof tgf !== 'string') { throw new Error('TGF: expected a string'); } - const lines = tgf.split('\n'); - const sepIdx = lines.indexOf('#'); + const lines = tgf.split(/\r\n|\n|\r/); + const sepIdx = lines.findIndex((line) => line.trim() === '#'); const nodeLines = sepIdx >= 0 ? lines.slice(0, sepIdx) : lines; const edgeLines = sepIdx >= 0 ? lines.slice(sepIdx + 1) : []; const nodes: Graph['nodes'] = []; + const ids = new Set(); for (const line of nodeLines) { - const trimmed = line.trim(); - if (!trimmed) continue; - const spaceIdx = trimmed.indexOf(' '); - const id = spaceIdx >= 0 ? trimmed.slice(0, spaceIdx) : trimmed; - const label = spaceIdx >= 0 ? trimmed.slice(spaceIdx + 1) : ''; + if (!line.trim()) continue; + const match = line.trimStart().match(/^(\S+)(?:[ \t]+(.*))?$/); + if (!match) throw new Error('TGF: malformed node declaration'); + const id = match[1]; + const label = match[2] ?? ''; + if (ids.has(id)) throw new Error(`TGF: duplicate node id "${id}"`); + ids.add(id); nodes.push({ type: 'node', id, @@ -77,12 +87,12 @@ export function fromTGF(tgf: string): Graph { const edges: Graph['edges'] = []; for (const line of edgeLines) { - const trimmed = line.trim(); - if (!trimmed) continue; - const parts = trimmed.split(' '); - const sourceId = parts[0]; - const targetId = parts[1]; - const label = parts.slice(2).join(' '); + if (!line.trim()) continue; + const match = line.trimStart().match(/^(\S+)[ \t]+(\S+)(?:[ \t]+(.*))?$/); + if (!match) throw new Error('TGF: malformed edge declaration'); + const [, sourceId, targetId] = match; + const label = match[3] ?? ''; + if (!ids.has(sourceId) || !ids.has(targetId)) throw new Error('TGF: edge endpoint references a missing node'); edges.push({ type: 'edge', id: `e${edges.length}`, @@ -122,3 +132,12 @@ export function fromTGF(tgf: string): Graph { */ export const tgfConverter: GraphFormatConverter = createFormatConverter(toTGF, fromTGF); + + +/** Exact native source preservation, with strict verified field edits. */ +export const tgfDocumentConverter = createGraphDocumentConverter({ + format: 'tgf', + codec: { from: (source: string) => fromTGF(source), to: toTGF }, + project: (source: string) => ({ graph: getStructuredDocumentProjectionGraph(fromTGF(source), source, 'tgf') }), + patch: (context) => getStructuredDocumentPatch(context, 'tgf'), +}); diff --git a/src/formats/xml-document.ts b/src/formats/xml-document.ts new file mode 100644 index 0000000..3958858 --- /dev/null +++ b/src/formats/xml-document.ts @@ -0,0 +1,245 @@ +import type { Graph } from '../types'; +import type { GraphDocumentChange, GraphDocumentPatchContext, GraphDocumentPatchResult, GraphDocumentProjection } from './document'; + +interface TextEdit { start: number; end: number; text: string } +interface XMLAttribute { name: string; value: string; start: number; end: number; quote: string } +interface XMLElement { + name: string; local: string; uri: string; start: number; openEnd: number; + closeStart: number; end: number; empty: boolean; parent: number | null; + attributes: XMLAttribute[]; children: number[]; +} + +function getDecodedXML(value: string): string { + return value.replace(/&(?:#x([0-9a-f]+)|#(\d+)|(amp|lt|gt|quot|apos));/gi, (match, hex, decimal, name) => { + if (hex || decimal) { + const code = Number.parseInt(hex ?? decimal, hex ? 16 : 10); + return code <= 0x10ffff ? String.fromCodePoint(code) : match; + } + return ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" } as Record)[name] ?? match; + }); +} + +/** Lexical spans only: declarations/comments/CDATA never become element identities. */ +function getXMLElements(source: string): XMLElement[] { + const elements: XMLElement[] = []; + const stack: Array<{ index: number; namespaces: Map }> = []; + let position = 0; + while (position < source.length) { + const start = source.indexOf('<', position); + if (start === -1) break; + if (source.startsWith('' : source.startsWith('' : '?>'; + const end = source.indexOf(delimiter, start + 2); + if (end < 0) throw new Error('unsupported-xml-syntax: unterminated XML token'); + position = end + delimiter.length; continue; + } + if (source.startsWith('', cursor + 4); + if (end < 0) throw new Error('unsupported-xml-syntax: unterminated declaration comment'); + cursor = end + 2; continue; + } + const character = source[cursor]; + if (quote) { if (character === quote) quote = ''; } + else if (character === '"' || character === "'") quote = character; + else if (character === '[') brackets++; + else if (character === ']') brackets--; + else if (character === '>' && brackets === 0) break; + } + if (cursor === source.length) throw new Error('unsupported-xml-syntax: unterminated declaration'); + position = cursor + 1; continue; + } + if (source.startsWith('', start + 2); + const frame = stack.pop(); + if (!frame || end < 0 || source.slice(start + 2, end).trim() !== elements[frame.index].name) throw new Error('unsupported-xml-syntax: mismatched tag'); + elements[frame.index].closeStart = start; elements[frame.index].end = end + 1; + position = end + 1; continue; + } + let cursor = start + 1; + while (cursor < source.length && !/[\s/>]/.test(source[cursor])) cursor++; + const name = source.slice(start + 1, cursor); + const attributes: XMLAttribute[] = []; + while (cursor < source.length) { + while (/\s/.test(source[cursor] ?? '') && cursor < source.length) cursor++; + if (source[cursor] === '>' || source.startsWith('/>', cursor)) break; + const attrStart = cursor; + while (cursor < source.length && !/[\s=/>]/.test(source[cursor])) cursor++; + const attrName = source.slice(attrStart, cursor); + while (/\s/.test(source[cursor] ?? '') && cursor < source.length) cursor++; + if (!attrName || source[cursor++] !== '=') throw new Error('unsupported-xml-syntax: expected quoted attribute'); + while (/\s/.test(source[cursor] ?? '') && cursor < source.length) cursor++; + const quote = source[cursor++]; + if (quote !== '"' && quote !== "'") throw new Error('unsupported-xml-syntax: expected quoted attribute'); + const valueStart = cursor; + cursor = source.indexOf(quote, cursor); + if (cursor < 0) throw new Error('unsupported-xml-syntax: unterminated attribute'); + attributes.push({ name: attrName, value: getDecodedXML(source.slice(valueStart, cursor)), start: valueStart, end: cursor, quote }); + cursor++; + } + const empty = source.startsWith('/>', cursor), openEnd = cursor + (empty ? 2 : 1); + if (openEnd > source.length || (!empty && source[cursor] !== '>')) throw new Error('unsupported-xml-syntax: unterminated tag'); + const namespaces = new Map(stack.at(-1)?.namespaces); + for (const attr of attributes) { + if (attr.name === 'xmlns') namespaces.set('', attr.value); + else if (attr.name.startsWith('xmlns:')) namespaces.set(attr.name.slice(6), attr.value); + } + const colon = name.indexOf(':'), prefix = colon < 0 ? '' : name.slice(0, colon), local = colon < 0 ? name : name.slice(colon + 1); + const parent = stack.at(-1)?.index ?? null; + const element: XMLElement = { name, local, uri: namespaces.get(prefix) ?? (prefix ? `unbound:${prefix}` : ''), start, openEnd, closeStart: cursor, end: openEnd, empty, parent, attributes, children: [] }; + const index = elements.length; + elements.push(element); + if (parent !== null) elements[parent].children.push(index); + if (!empty) stack.push({ index, namespaces }); + position = openEnd; + } + if (stack.length) throw new Error('unsupported-xml-syntax: unclosed tag'); + return elements; +} + +function getXMLAttribute(element: XMLElement, name: string): XMLAttribute | undefined { + return element.attributes.find((attribute) => attribute.name === name); +} + +function isCoreXML(element: XMLElement, format: 'graphml' | 'gexf'): boolean { + return !element.uri || (format === 'graphml' ? element.uri === 'http://graphml.graphdrawing.org/xmlns' + : ['http://gexf.net/1.3', 'http://gexf.net/1.2draft', 'http://www.gexf.net/1.2draft'].includes(element.uri)); +} + +function getEncodedXML(value: string, quote?: string): string { + // XML 1.0 characters, including rejection of lone UTF-16 surrogates. + for (const character of value) { + const code = character.codePointAt(0)!; + if (!(code === 9 || code === 10 || code === 13 || (code >= 32 && code <= 0xd7ff) || (code >= 0xe000 && code <= 0xfffd) || (code >= 0x10000 && code <= 0x10ffff))) throw new Error('invalid-native-label: label contains a character outside XML 1.0'); + } + const escaped = value.replace(/&/g, '&').replace(//g, '>') + .replace(/\r/g, ' ').replace(quote === "'" ? /'/g : /"/g, quote === "'" ? ''' : '"'); + return quote ? escaped.replace(/[\t\n]/g, (character) => character === '\t' ? ' ' : ' ') : escaped; +} + +function getUnsupported(change: GraphDocumentChange, message: string): GraphDocumentPatchResult { + return { ok: false, diagnostics: [{ code: 'unsupported-native-edit', message, path: change.path, severity: 'error' }] }; +} + +function getLabelChangeError(changes: GraphDocumentChange[]): GraphDocumentPatchResult | undefined { + for (const change of changes) { + if ((change.entity !== 'node' && change.entity !== 'edge') || change.field !== 'label' || !change.afterPresent || typeof change.after !== 'string') { + return getUnsupported(change, 'Only string node/edge label edits have verified native spans; structure, data, geometry and nullable labels require explicit regeneration'); + } + } + return undefined; +} + +function getEditedSource(source: string, edits: TextEdit[]): string { + const sorted = [...edits].sort((left, right) => right.start - left.start || right.end - left.end); + let boundary = source.length; + const chunks: string[] = []; + for (const edit of sorted) { + if (edit.start < 0 || edit.end < edit.start || edit.end > boundary) throw new Error('unsupported-native-edit: overlapping or invalid native spans'); + chunks.push(source.slice(edit.end, boundary), edit.text); + boundary = edit.start; + } + chunks.push(source.slice(0, boundary)); + return chunks.reverse().join(''); +} + +/** Preserve native source separately from the explicitly partial binary projection. */ +export function getXMLDocumentProjection(source: string, format: 'graphml' | 'gexf', from: (source: string) => Graph): GraphDocumentProjection { + const elements = getXMLElements(source); + const roots = elements.filter((element) => element.parent === null && isCoreXML(element, format)); + const root = roots.find((element) => element.local === (format === 'graphml' ? 'graphml' : 'gexf')); + const count = root?.children.filter((index) => elements[index].local === 'graph' && isCoreXML(elements[index], format)).length ?? 0; + if (count > 1 && format === 'gexf') return { graph: null, diagnostics: [{ code: 'projection-unavailable', message: 'Multiple GEXF graphs have no single binary projection; every native graph remains preserved', severity: 'warning' }] }; + const graph = from(source); + return { graph, diagnostics: [{ code: count > 1 ? 'projection-multiple-graphs' : 'projection-partial', message: count > 1 ? 'Only the first graph is projected; every native graph remains in the preserved source' : 'The Graph view is partial; comments, extensions and unmodeled semantics remain in native source', severity: 'warning' }] }; +} + +/** Patch only unambiguous native literal labels; the document core proves reparsed semantics. */ +export function getXMLDocumentPatch(context: GraphDocumentPatchContext, format: 'graphml' | 'gexf'): GraphDocumentPatchResult { + const invalid = getLabelChangeError(context.changes); + if (invalid) return invalid; + const elements = getXMLElements(context.source); + const rootIndex = elements.findIndex((element) => element.parent === null && element.local === (format === 'graphml' ? 'graphml' : 'gexf') && isCoreXML(element, format)); + if (rootIndex < 0) return getUnsupported(context.changes[0], 'Cannot locate the native root'); + const graphIndex = elements[rootIndex].children.find((index) => elements[index].local === 'graph' && isCoreXML(elements[index], format)); + if (graphIndex === undefined) return getUnsupported(context.changes[0], 'Cannot locate the projected graph'); + const selected = new Set(); + if (format === 'graphml') { + const pending = [graphIndex]; + while (pending.length) { + const index = pending.pop()!; + for (const childIndex of elements[index].children) { + const child = elements[childIndex]; + if (!isCoreXML(child, format) || !['node', 'edge'].includes(child.local)) continue; + selected.add(childIndex); + if (child.local === 'node') for (const nested of child.children) { + if (elements[nested].local === 'graph' && isCoreXML(elements[nested], format)) pending.push(nested); + } + } + } + } else { + const pending = [graphIndex]; + while (pending.length) { + const owner = pending.pop()!; + const wrappers = elements[owner].children.filter((index) => elements[index].local === 'nodes' && isCoreXML(elements[index], format)); + const wrapped = wrappers.length === 1 ? elements[wrappers[0]].children.filter((index) => elements[index].local === 'node' && isCoreXML(elements[index], format)) : []; + const nodes = wrapped.length ? wrapped : elements[owner].children.filter((index) => elements[index].local === 'node' && isCoreXML(elements[index], format)); + for (const index of nodes) { selected.add(index); pending.push(index); } + } + const wrappers = elements[graphIndex].children.filter((index) => elements[index].local === 'edges' && isCoreXML(elements[index], format)); + const wrapped = wrappers.length === 1 ? elements[wrappers[0]].children.filter((index) => elements[index].local === 'edge' && isCoreXML(elements[index], format)) : []; + for (const index of wrapped.length ? wrapped : elements[graphIndex].children.filter((index) => elements[index].local === 'edge' && isCoreXML(elements[index], format))) selected.add(index); + } + const keys = elements[rootIndex].children.map((index) => elements[index]).filter((element) => element.local === 'key' && isCoreXML(element, format)); + const edits: TextEdit[] = []; + for (const change of context.changes) { + const matches = [...selected].map((index) => elements[index]).filter((element) => element.local === change.entity && isCoreXML(element, format) && getXMLAttribute(element, 'id')?.value === change.id); + if (matches.length !== 1) return getUnsupported(change, 'A unique explicit native entity ID is required for label edits'); + const element = matches[0], label = change.after as string; + try { getEncodedXML(label); } + catch (error) { return { ok: false, diagnostics: [{ code: 'invalid-native-label', message: error instanceof Error ? error.message : String(error), path: change.path, severity: 'error' }] }; } + if (format === 'gexf') { + const attribute = getXMLAttribute(element, 'label'); + const text = getEncodedXML(label, attribute?.quote ?? '"'); + edits.push(attribute ? { start: attribute.start, end: attribute.end, text } + : { start: element.openEnd - (element.empty ? 2 : 1), end: element.openEnd - (element.empty ? 2 : 1), text: ` label="${text}"` }); + continue; + } + const definitions = keys.filter((key) => (getXMLAttribute(key, 'attr.name')?.value ?? getXMLAttribute(key, 'id')?.value) === 'label' && ['all', change.entity].includes(getXMLAttribute(key, 'for')?.value ?? 'all')); + const labelKeys = new Set(definitions.map((key) => getXMLAttribute(key, 'id')?.value)); + // Legacy key="label" without a declaration remains accepted by the bare importer. + if (!keys.some((key) => getXMLAttribute(key, 'id')?.value === 'label')) labelKeys.add('label'); + const data = element.children.map((index) => elements[index]).filter((child) => child.local === 'data' && isCoreXML(child, format) && labelKeys.has(getXMLAttribute(child, 'key')?.value)); + if (!data.length) { + const compatible = definitions.filter((key) => ['string', undefined].includes(getXMLAttribute(key, 'attr.type')?.value)); + if (compatible.length !== 1 || !getXMLAttribute(compatible[0], 'id')) return getUnsupported(change, 'A missing GraphML label requires one existing scoped string label key'); + const prefix = element.name.includes(':') ? element.name.slice(0, element.name.indexOf(':') + 1) : ''; + const text = `<${prefix}data key="${getEncodedXML(getXMLAttribute(compatible[0], 'id')!.value, '"')}">${getEncodedXML(label)}`; + if (element.empty) edits.push({ start: element.openEnd - 2, end: element.openEnd, text: `>${text}` }); + else { + const afterData = element.children.map((index) => elements[index]).find((child) => ['port', 'graph'].includes(child.local) && isCoreXML(child, format)); + const position = afterData?.start ?? element.closeStart; + edits.push({ start: position, end: position, text }); + } + continue; + } + if (data.length !== 1) return getUnsupported(change, 'Duplicate GraphML label data cannot be safely patched'); + const definition = keys.find((key) => getXMLAttribute(key, 'id')?.value === getXMLAttribute(data[0], 'key')?.value); + if (definition && !['string', undefined].includes(getXMLAttribute(definition, 'attr.type')?.value)) return getUnsupported(change, 'GraphML label key must have string type'); + const item = data[0], content = context.source.slice(item.openEnd, item.closeStart); + if (item.empty) { + edits.push({ start: item.openEnd - 2, end: item.openEnd, text: `>${getEncodedXML(label)}` }); + continue; + } + if (item.children.length || content.includes('<')) return getUnsupported(change, 'GraphML label content must be plain text; CDATA/mixed XML labels are retained but cannot be safely edited'); + edits.push({ start: item.openEnd, end: item.closeStart, text: getEncodedXML(label) }); + } + return { ok: true, source: getEditedSource(context.source, edits) }; +} + +export type { TextEdit }; +export { getLabelChangeError, getUnsupported, getEditedSource }; +export { getXMLElements, getDecodedXML, getXMLAttribute, getEncodedXML }; +export type { XMLElement, XMLAttribute }; diff --git a/src/formats/xml-preservation.ts b/src/formats/xml-preservation.ts new file mode 100644 index 0000000..f4c7f20 --- /dev/null +++ b/src/formats/xml-preservation.ts @@ -0,0 +1,96 @@ +/** Private, JSON-safe provenance. This is not a source-document/CST API. */ +export function getFormatMetadata(data: unknown, format: string): Record | undefined { + if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined; + const metadata = (data as Record)[`_${format}`]; + return metadata?.format === `@statelyai/graph/${format}` && metadata.version === 1 + ? metadata + : undefined; +} + +export function getUserData(data: unknown, format: string): any { + const metadata = getFormatMetadata(data, format); + if (!metadata) return data; + if (metadata.wrapped) return metadata.userData; + const value = { ...(data as Record) }; + delete value[`_${format}`]; + return metadata.absent && Object.keys(value).length === 0 ? undefined : value; +} + +export function createFormatData(data: unknown, format: string, metadata: Record): any { + if (Object.keys(metadata).length === 0) return data; + const key = `_${format}`; + const header = { format: `@statelyai/graph/${format}`, version: 1, ...metadata }; + if (data === undefined) return { [key]: { ...header, absent: true } }; + if (data !== null && typeof data === 'object' && !Array.isArray(data) && !(key in data)) { + return { ...data, [key]: header }; + } + // Scalars and foreign reserved-key payloads are never discarded or spread. + return { [key]: { ...header, wrapped: true, userData: data } }; +} + +/** Normalize only elements bound to the expected namespaces; accept legacy unqualified XML. */ +export function getNormalizedXML(value: any, namespace: string): any { + const output: any = {}; + const pending = [{ input: value, output, bindings: {} as Record, name:'' }]; + while (pending.length) { + const frame = pending.pop()!; + const bindings = { ...frame.bindings }; + for (const [key, val] of Object.entries(frame.input)) { + if (key === '@_xmlns') bindings[''] = String(val); + else if (key.startsWith('@_xmlns:')) bindings[key.slice(8)] = String(val); + } + if (['data','key','meta'].includes(frame.name.split(':').pop()!)) { + for (const [prefix,uri] of Object.entries(bindings)) if (prefix) frame.output[`@_xmlns:${prefix}`] = uri; + } + for (const [key, val] of Object.entries(frame.input)) { + let name = key; + if (!key.startsWith('@_') && !key.startsWith('#') && !key.startsWith('?')) { + const colon = key.indexOf(':'); + const prefix = colon < 0 ? '' : key.slice(0, colon); + const local = colon < 0 ? key : key.slice(colon + 1); + const child = Array.isArray(val) ? val[0] : val; + const uri = child && typeof child === 'object' + ? child[prefix ? `@_xmlns:${prefix}` : '@_xmlns'] ?? bindings[prefix] + : bindings[prefix]; + if (uri === namespace || (colon < 0 && uri === undefined)) name = local; + else if (uri === 'http://gexf.net/1.3/viz' || uri === 'http://gexf.net/1.2draft/viz' || uri === 'http://www.gexf.net/1.2draft/viz') name = `viz:${local}`; + else if (colon < 0 && uri !== undefined) name = `{${uri}}${local}`; + } + if (Array.isArray(val)) { + const children: any[] = []; + frame.output[name] = children; + for (const child of val) { + if (child && typeof child === 'object') { + const next = {}; + children.push(next); + pending.push({ input: child, output: next, bindings, name }); + } else children.push(child); + } + } else if (val && typeof val === 'object') { + const next = {}; + frame.output[name] = next; + pending.push({ input: val, output: next, bindings, name }); + } else frame.output[name] = val; + } + } + return output; +} + +/** Re-emit preserved unknown XML with its local namespace declarations. */ +export function getNativeXML(value: any): any { + if (!value || typeof value !== 'object') return value; + const output: any = Array.isArray(value) ? [] : {}; + const pending = [{input:value,output}]; + while (pending.length) { + const current = pending.pop()!; + for (const [key,child] of Object.entries(current.input)) { + const name = key.replace(/^\{[^}]+\}/,''); + if (child && typeof child === 'object') { + const next: any = Array.isArray(child) ? [] : {}; + current.output[name] = next; + pending.push({input:child,output:next}); + } else current.output[name] = child; + } + } + return output; +} diff --git a/src/formats/xml-standard.ts b/src/formats/xml-standard.ts new file mode 100644 index 0000000..8d2c116 --- /dev/null +++ b/src/formats/xml-standard.ts @@ -0,0 +1,36 @@ +/** XML 1.0 native character domain; JSON strings are checked after serialization. */ +function isXMLCharacter(value: number): boolean { + return value === 9 || value === 10 || value === 13 || value >= 32 && value <= 0xd7ff || value >= 0xe000 && value <= 0xfffd || value >= 0x10000 && value <= 0x10ffff; +} + +/** Validate the actual native wire tree without rejecting JSON-escaped user data. */ +export function getValidatedXMLValue(value: unknown): void { + const pending = [value]; + while (pending.length) { + const current = pending.pop(); + if (typeof current === 'string') { + for (const character of current) if (!isXMLCharacter(character.codePointAt(0)!)) throw new Error('XML standard profile: invalid XML 1.0 character'); + } else if (typeof current === 'number' && !Number.isFinite(current)) throw new Error('XML standard profile: nonfinite native number'); + else if (current && typeof current === 'object') for (const value of Object.values(current)) pending.push(value); + } +} + +/** Preserve attribute whitespace and text CR through native XML normalization. */ +export function getStandardXML(source: string): string { + let tag = false, quote = '', output = ''; + for (const character of source) { + if (character === '\r') output += ' '; + else if (quote && (character === '\t' || character === '\n')) output += character === '\t' ? ' ' : ' '; + else output += character; + if (quote) { if (character === quote) quote = ''; } + else if (tag && (character === '"' || character === "'")) quote = character; + else if (character === '<') tag = true; + else if (character === '>') tag = false; + } + return output; +} + +/** NMTOKEN accepts XML NameChar, but whitespace normalization must not change IDs. */ +export function isXMLNMTOKEN(value: string): boolean { + return /^[A-Za-z0-9_.:\-\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0300-\u036F\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]+$/u.test(value); +} diff --git a/src/formats/xyflow/README.md b/src/formats/xyflow/README.md index 2c7ecef..d7a9be3 100644 --- a/src/formats/xyflow/README.md +++ b/src/formats/xyflow/README.md @@ -2,6 +2,24 @@ Converter for [xyflow](https://xyflow.com/) (React Flow / Svelte Flow) node and edge objects. Works with `VisualGraph` since xyflow expects `position` and size. +## Preservation + + + +Graph round-trip: **partial**. Native semantic round-trip: **partial**. +Source document preservation: **none**. These are separate guarantees; +selected-field round-trip tests do not prove unrestricted losslessness. + +Versioned metadata preserves Graph field presence, null labels, ports and authored order. Live native labels and user data edits take precedence. Native snapshots retain viewport and UI fields. Parent ordering uses a linear queue and preserves sibling order. Reserved payloads can be imported as ordinary native data with `{ metadata: 'native' }`. + + + + +Native snapshots use a private JSON convention on Graph data. Import options +`{ metadata: 'native' }` disable adapter marker interpretation. A foreign value +that deliberately matches the marker schema remains ambiguous in automatic mode. +Comments, lexical formatting and source locations are not preserved. + ## Resources - [React Flow documentation](https://reactflow.dev/) @@ -39,7 +57,7 @@ const roundTripped = fromXYFlow(flow); | `node.parentId` | `node.parentId` | | `edge.sourceId` / `edge.targetId` | `edge.source` / `edge.target` | | `edge.sourcePort` / `edge.targetPort` | `edge.sourceHandle` / `edge.targetHandle` | -| `edge.label` | `edge.data.label` | +| `edge.label` | Top-level `edge.label`, with the original value also stored in adapter metadata | ### `xyflowConverter` @@ -56,6 +74,24 @@ const roundTripped = xyflowConverter.from(flow); | --- | --- | | `XYFlow` | `{ nodes: XYFlowNode[], edges: XYFlowEdge[] }` | | `XYFlowNode` | Re-exported `NodeBase` from `@xyflow/system` | -| `XYFlowEdge` | Re-exported `EdgeBase` from `@xyflow/system` | +| `XYFlowEdge` | `EdgeBase` from `@xyflow/system` plus a string `label` field | > **Note:** `fromXYFlow` restores `mode` and `direction` from graph metadata when present; otherwise it defaults to a `directed` graph with `direction: 'down'`. + + +## Native documents + + + +`xyflowDocumentConverter.from(source)` creates a plain JSON document with the +original source and a separate Graph projection. `.to(document)` preserves exact +source when unchanged, including after JSON serialization. Supported edits patch +verified native fields; unsupported edits throw with diagnostics. `.getUpdate(document)` +returns a diagnostic-bearing result. Native semantic projection remains partial. + +Labels, positions, measured dimensions and user data patch native fields while retaining viewport and UI properties. Renderer labels are distinguished during projection. Edits to user labels colliding with the renderer mapping reject explicitly. Structural edits reject. + +Bare exports interpret recognized import carriers by default. Use +`{ nativeCarrier: false }` when Graph data is user-authored and could match the +private carrier schema. Document projection pins importer state to its external +source before unwrapping it, preserving foreign lookalikes. diff --git a/src/formats/xyflow/index.ts b/src/formats/xyflow/index.ts index 74e2f52..2be157e 100644 --- a/src/formats/xyflow/index.ts +++ b/src/formats/xyflow/index.ts @@ -1,310 +1,182 @@ +import type { FormatExportOptions } from '../preservation'; +import { createGraphDocumentConverter } from '../document'; +import { getStructuredDocumentProjectionGraph, getStructuredDocumentPatch } from '../preservation'; import type { NodeBase, EdgeBase } from '@xyflow/system'; import type { VisualGraph, VisualGraphFormatConverter } from '../../types'; +import { createMetadata, createNativeCarrier, getCarrierData, getClonedJSON, getMetadata, + getNativeCarrier, getRestoredRecord, getMergedNative, isJSONEqual, type FormatMetadata, type FormatImportOptions } from '../preservation'; -const STATELYAI_METADATA_KEY = '__statelyai'; - -/** xyflow Node — re-exported from `@xyflow/system`. */ -export type XYFlowNode< - TNodeData extends Record = Record, -> = NodeBase; - -/** - * xyflow Edge — `EdgeBase` from `@xyflow/system` plus the top-level `label` - * that React Flow / Svelte Flow actually render (it's a renderer prop, so - * `EdgeBase` itself doesn't declare it). - */ -export type XYFlowEdge< - TEdgeData extends Record = Record, -> = EdgeBase & { label?: string }; - -export interface XYFlow< - TNodeData extends Record = Record, - TEdgeData extends Record = Record, -> { +export type XYFlowNode = Record> = NodeBase; +export type XYFlowEdge = Record> = EdgeBase & { label?: string }; +export interface XYFlow = Record, TEdgeData extends Record = Record> { nodes: XYFlowNode[]; edges: XYFlowEdge[]; data?: Record; + viewport?: { x: number; y: number; zoom: number }; + [key: string]: unknown; } +export interface XYFlowImportOptions extends FormatImportOptions {} -interface XYFlowMetadata { - graph?: Partial; - data?: unknown; - node?: Record; - edge?: Record; +function toDataObject(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? getClonedJSON(value) : {}; } - -function toDataObject(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? { ...(value as Record) } - : {}; +function getLegacyMetadata(value: any, kind: 'graph' | 'node' | 'edge'): any { + const metadata = value?.__statelyai; + return metadata && typeof metadata === 'object' && !metadata.kind && metadata[kind] && + typeof metadata[kind] === 'object' && Object.keys(metadata).every((key) => key === kind || key === 'data') ? metadata : undefined; } - -function withMetadata( - value: unknown, - metadata: XYFlowMetadata, -): Record { - return { - ...toDataObject(value), - [STATELYAI_METADATA_KEY]: { - ...(value !== undefined && { data: value }), - ...metadata, - }, - }; +function getUserData(value: any, metadata?: FormatMetadata, legacy?: any): any { + if (legacy) return legacy.data; + if (!metadata) return getClonedJSON(value); + const current = { ...value }; + delete current.__statelyai; + const baseline = metadata.mapped.__visibleData ?? {}; + const original = metadata.original.data; + const result = toDataObject(original); + let edited = false; + for (const key of new Set([...Object.keys(current), ...Object.keys(baseline)])) { + if (key === '__statelyai') continue; + if (Object.hasOwn(current, key) === Object.hasOwn(baseline, key) && isJSONEqual(current[key], baseline[key])) continue; + // The lifted renderer label belongs to Graph.label unless user data already + // had a label field. Avoid manufacturing a data.label on unrelated edits. + if (key === 'label' && !Object.hasOwn(result, 'label')) continue; + edited = true; + if (Object.hasOwn(current, key)) Object.defineProperty(result, key, { value: getClonedJSON(current[key]), enumerable: true, writable: true, configurable: true }); + else delete result[key]; + } + return edited ? result : getClonedJSON(original); } - -function readMetadata(value: unknown): XYFlowMetadata | undefined { - if (!value || typeof value !== 'object') return undefined; - const metadata = (value as Record)[STATELYAI_METADATA_KEY]; - return metadata && typeof metadata === 'object' - ? (metadata as XYFlowMetadata) - : undefined; +function getFields(value: any, keys: string[]): any { + return Object.fromEntries(keys.filter((key) => value && Object.hasOwn(value, key)).map((key) => [key, value[key]])); } - -function readUserData(value: unknown): unknown { - const metadata = readMetadata(value); - // No metadata blob: external xyflow input — pass the data through as-is. - if (!metadata) return value; - // Metadata blob present: the original user data (possibly `undefined`) was - // captured at conversion time; never leak the wrapper object itself. - return 'data' in metadata ? metadata.data : undefined; +function getXYRoot(flow: XYFlow, legacy?: any): Omit { + return { id: legacy?.graph?.id ?? '', mode: legacy?.graph?.mode ?? 'directed', initialNodeId: legacy?.graph?.initialNodeId ?? null, + direction: legacy?.graph?.direction ?? 'down', data: legacy?.graph?.data, + ...getFields(legacy?.graph, ['style']) }; +} +function getXYNode(node: XYFlowNode, metadata?: FormatMetadata, legacy?: any): VisualGraph['nodes'][number] { + const preserved = metadata?.original ?? legacy?.node ?? {}; + const visible = node.data as Record; + return { type: 'node', id: node.id, parentId: node.parentId ?? null, + initialNodeId: preserved.initialNodeId ?? null, label: visible?.label?.toString() ?? '', + data: getUserData(node.data, metadata, legacy), x: node.position.x, y: node.position.y, + width: node.measured?.width ?? node.width ?? node.initialWidth ?? 0, + height: node.measured?.height ?? node.height ?? node.initialHeight ?? 0, + ...(Object.hasOwn(node, 'type') && { shape: node.type }), + ...getFields(preserved, ['color', 'style', 'ports']) }; +} +function getXYEdge(edge: XYFlowEdge, index: number, metadata?: FormatMetadata, legacy?: any): VisualGraph['edges'][number] { + const preserved = metadata?.original ?? legacy?.edge ?? {}; + return { type: 'edge', id: edge.id ?? `e${index}`, sourceId: edge.source, targetId: edge.target, + label: Object.hasOwn(edge, 'label') ? edge.label : (edge.data as any)?.label?.toString() ?? '', + data: getUserData(edge.data, metadata, legacy), + x: preserved.x ?? 0, y: preserved.y ?? 0, width: preserved.width ?? 0, height: preserved.height ?? 0, + ...(edge.sourceHandle != null && { sourcePort: edge.sourceHandle }), + ...(edge.targetHandle != null && { targetPort: edge.targetHandle }), + ...getFields(preserved, ['mode', 'weight', 'points', 'routing', 'color', 'style']) }; } /** * React Flow requires parent nodes to appear before their children in the - * nodes array. Reorders iteratively, keeping authored order otherwise: each - * pass emits nodes whose parent is already emitted (or absent). Nodes left - * over by a parentId cycle are appended in authored order rather than hanging. + * nodes array. Uses a linear queue, retaining authored sibling order. Nodes + * left over by a parentId cycle are appended in authored order. */ function orderParentsFirst(nodes: XYFlowNode[]): XYFlowNode[] { - const nodeIds = new Set(nodes.map((n) => n.id)); + const nodeIds = new Set(nodes.map((node) => node.id)); + const children = new Map(); + const queue: XYFlowNode[] = []; + for (const node of nodes) { + if (!node.parentId || !nodeIds.has(node.parentId)) queue.push(node); + else { + const siblings = children.get(node.parentId) ?? []; + siblings.push(node); children.set(node.parentId, siblings); + } + } const emitted = new Set(); const result: XYFlowNode[] = []; - let remaining = nodes; - while (remaining.length > 0) { - const deferred: XYFlowNode[] = []; - for (const node of remaining) { - if ( - !node.parentId || - emitted.has(node.parentId) || - // Parent isn't in the graph at all: treat as a root. - !nodeIds.has(node.parentId) - ) { - result.push(node); - emitted.add(node.id); - } else { - deferred.push(node); - } - } - if (deferred.length === remaining.length) { - // No progress: parentId cycle. Keep the rest in authored order. - result.push(...deferred); - break; - } - remaining = deferred; + for (let cursor = 0; cursor < queue.length; cursor++) { + const node = queue[cursor]; + result.push(node); emitted.add(node.id); + queue.push(...(children.get(node.id) ?? [])); } + // Cycles and their descendants retain authored order, as in legacy exports. + result.push(...nodes.filter((node) => !emitted.has(node.id))); return result; } -// --- Conversion --- +/** Export renderer-visible fields and versioned metadata; snapshots are independent clones. */ +export interface XYFlowExportOptions extends FormatExportOptions {} -/** - * Converts a visual graph to xyflow (React Flow / Svelte Flow) format. - * - * @example - * ```ts - * import { createVisualGraph } from '@statelyai/graph'; - * import { toXYFlow } from '@statelyai/graph/xyflow'; - * - * const graph = createVisualGraph({ - * nodes: [ - * { id: 'a', x: 0, y: 0, width: 100, height: 50 }, - * { id: 'b', x: 200, y: 100, width: 100, height: 50 }, - * ], - * edges: [{ id: 'e0', sourceId: 'a', targetId: 'b' }], - * }); - * - * const flow = toXYFlow(graph); - * // { nodes: [...], edges: [...] } - * ``` - */ -export function toXYFlow(graph: VisualGraph): XYFlow { - return { - data: { - [STATELYAI_METADATA_KEY]: { - graph: { - id: graph.id, - mode: graph.mode, - initialNodeId: graph.initialNodeId, - data: graph.data, - direction: graph.direction, - style: graph.style, - }, - }, - }, - nodes: orderParentsFirst(graph.nodes.map((n) => { - const node: XYFlowNode = { - id: n.id, - position: { x: n.x, y: n.y }, - data: withMetadata(n.data, { - node: { - initialNodeId: n.initialNodeId, - label: n.label, - color: n.color, - style: n.style, - ports: n.ports, - }, - }), - }; - if (n.parentId) node.parentId = n.parentId; - // React Flow's built-in nodes render `data.label` - if (n.label) node.data.label = n.label; - if (n.shape) node.type = n.shape; - if (n.width !== undefined) node.width = n.width; - if (n.height !== undefined) node.height = n.height; - return node; +export function toXYFlow(graph: VisualGraph, options: XYFlowExportOptions = {}): XYFlow { + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, 'xyflow'); + if (carrier) return getMergedNative(carrier.native, toRawXYFlow(carrier.baseline), + toRawXYFlow({ ...graph, data: getCarrierData(graph.data, 'xyflow') })); + return toRawXYFlow(graph); +} + +function toRawXYFlow(graph: VisualGraph): XYFlow { + const { nodes, edges, ...root } = graph; + const flow: XYFlow = { + nodes: orderParentsFirst(nodes.map((node) => { + const native: XYFlowNode = { id: node.id, position: { x: node.x, y: node.y }, data: toDataObject(node.data), + ...getFields(node, ['width', 'height']), + ...(Object.hasOwn(node, 'shape') && { type: node.shape }), + ...(node.parentId != null && { parentId: node.parentId }) }; + if (node.label) native.data.label = node.label; + const mapped = { ...getXYNode(native), data: node.data, ...getFields(node, ['color', 'style', 'ports']), __visibleData: getClonedJSON(native.data) }; + native.data.__statelyai = createMetadata('xyflow.node', node, mapped); + return native; })), - edges: graph.edges.map((e) => { - const edge: XYFlowEdge = { - id: e.id, - source: e.sourceId, - target: e.targetId, - }; - if (e.sourcePort) edge.sourceHandle = e.sourcePort; - if (e.targetPort) edge.targetHandle = e.targetPort; - edge.data = withMetadata(e.data, { - edge: { - label: e.label, - mode: e.mode, - weight: e.weight, - points: e.points, - routing: e.routing, - color: e.color, - style: e.style, - x: e.x, - y: e.y, - width: e.width, - height: e.height, - }, - }); - // Top-level `label` is what React Flow / Svelte Flow render for edges - // (their built-in edge components ignore `data.label`) - if (e.label) edge.label = e.label; - return edge; + edges: edges.map((edge, index) => { + const native: XYFlowEdge = { id: edge.id, source: edge.sourceId, target: edge.targetId, + data: toDataObject(edge.data), ...(edge.label != null && { label: edge.label }), + ...(edge.sourcePort !== undefined && { sourceHandle: edge.sourcePort }), + ...(edge.targetPort !== undefined && { targetHandle: edge.targetPort }) }; + const mapped = { ...getXYEdge(native, index), data: edge.data, ...getFields(edge, ['x', 'y', 'width', 'height', 'mode', 'weight', 'points', 'routing', 'color', 'style']), __visibleData: getClonedJSON(native.data) }; + native.data!.__statelyai = createMetadata('xyflow.edge', edge, mapped); + return native; }), }; + flow.data = { __statelyai: { ...createMetadata('xyflow.graph', root, getXYRoot(flow)), + nodeOrder: nodes.map((node) => node.id), nativeNodeOrder: flow.nodes.map((node) => node.id) } }; + return getClonedJSON(flow); } -/** - * Parses an xyflow (React Flow / Svelte Flow) object into a visual graph. - * - * @example - * ```ts - * import { fromXYFlow } from '@statelyai/graph/xyflow'; - * - * const graph = fromXYFlow({ - * nodes: [ - * { id: 'a', position: { x: 0, y: 0 }, data: {} }, - * { id: 'b', position: { x: 200, y: 100 }, data: {} }, - * ], - * edges: [{ id: 'e0', source: 'a', target: 'b' }], - * }); - * ``` - */ -export function fromXYFlow(flow: XYFlow): VisualGraph { - if (!flow || typeof flow !== 'object') { - throw new Error('XYFlow: expected an object'); - } - if (!Array.isArray(flow.nodes)) { - throw new Error('XYFlow: "nodes" must be an array'); - } - if (!Array.isArray(flow.edges)) { - throw new Error('XYFlow: "edges" must be an array'); - } - const graphMetadata = readMetadata(flow.data)?.graph; - return { - id: graphMetadata?.id?.toString() ?? '', - mode: graphMetadata?.mode ?? 'directed', - initialNodeId: - graphMetadata && 'initialNodeId' in graphMetadata - ? (graphMetadata.initialNodeId as string | null) - : null, - data: - graphMetadata && 'data' in graphMetadata - ? graphMetadata.data - : (undefined as any), - direction: - (graphMetadata?.direction as VisualGraph['direction'] | undefined) ?? - 'down', - ...(graphMetadata?.style !== undefined && { style: graphMetadata.style }), - nodes: flow.nodes.map((n) => { - const metadata = readMetadata(n.data)?.node; - return { - type: 'node' as const, - id: n.id, - parentId: n.parentId ?? null, - initialNodeId: - metadata && 'initialNodeId' in metadata - ? (metadata.initialNodeId as string | null) - : null, - label: - metadata && 'label' in metadata - ? (metadata.label as string | null) - : ((n.data as Record | undefined)?.label?.toString() ?? - ''), - data: readUserData(n.data), - x: n.position.x, - y: n.position.y, - width: n.measured?.width ?? n.width ?? n.initialWidth ?? 0, - height: n.measured?.height ?? n.height ?? n.initialHeight ?? 0, - ...(n.type && { shape: n.type }), - ...(metadata?.color !== undefined && { color: metadata.color as string }), - ...(metadata?.style !== undefined && { - style: metadata.style as VisualGraph['style'], - }), - ...(metadata?.ports !== undefined && { - ports: metadata.ports as VisualGraph['nodes'][number]['ports'], - }), - }; +/** Import native flows, with an explicit override for reserved wire conventions. */ +export function fromXYFlow(flow: XYFlow, options: XYFlowImportOptions = {}): VisualGraph { + if (!flow || typeof flow !== 'object') throw new Error('XYFlow: expected an object'); + if (!Array.isArray(flow.nodes)) throw new Error('XYFlow: "nodes" must be an array'); + if (!Array.isArray(flow.edges)) throw new Error('XYFlow: "edges" must be an array'); + const getWireMetadata = (value: any, format: string) => options.metadata === 'native' ? undefined : getMetadata(value?.__statelyai, format); + const getLegacy = (value: any, kind: 'graph' | 'node' | 'edge') => options.metadata === 'native' ? undefined : getLegacyMetadata(value, kind); + const metadata = getWireMetadata(flow.data, 'xyflow.graph'); + const legacy = getLegacy(flow.data, 'graph'); + const graph: VisualGraph = { + ...getRestoredRecord(getXYRoot(flow, legacy), metadata), + nodes: flow.nodes.map((node) => { + const wire = getWireMetadata(node.data, 'xyflow.node'); + const mapped: any = getXYNode(node, wire, getLegacy(node.data, 'node')); + if (wire) mapped.__visibleData = wire.mapped.__visibleData; + const restored = getRestoredRecord(mapped, wire); + delete restored.__visibleData; + return restored; }), - edges: flow.edges.map((e, i) => { - const metadata = readMetadata(e.data)?.edge; - return { - type: 'edge' as const, - id: e.id ?? `e${i}`, - sourceId: e.source, - targetId: e.target, - label: - metadata && 'label' in metadata - ? (metadata.label as string | null) - : (e.label ?? - (e.data as Record | undefined)?.label?.toString() ?? - ''), - ...(e.sourceHandle && { sourcePort: e.sourceHandle }), - ...(e.targetHandle && { targetPort: e.targetHandle }), - data: readUserData(e.data), - x: (metadata?.x as number | undefined) ?? 0, - y: (metadata?.y as number | undefined) ?? 0, - width: (metadata?.width as number | undefined) ?? 0, - height: (metadata?.height as number | undefined) ?? 0, - ...(metadata?.mode !== undefined && { - mode: metadata.mode as VisualGraph['mode'], - }), - ...(metadata?.weight !== undefined && { - weight: metadata.weight as number, - }), - ...(metadata?.points !== undefined && { - points: metadata.points as VisualGraph['edges'][number]['points'], - }), - ...(metadata?.routing !== undefined && { - routing: metadata.routing as VisualGraph['edges'][number]['routing'], - }), - ...(metadata?.color !== undefined && { color: metadata.color as string }), - ...(metadata?.style !== undefined && { - style: metadata.style as VisualGraph['style'], - }), - }; + edges: flow.edges.map((edge, index) => { + const wire = getWireMetadata(edge.data, 'xyflow.edge'); + const mapped: any = getXYEdge(edge, index, wire, getLegacy(edge.data, 'edge')); + if (wire) mapped.__visibleData = wire.mapped.__visibleData; + const restored = getRestoredRecord(mapped, wire); + delete restored.__visibleData; + return restored; }), }; + const order = flow.data?.__statelyai as any; + if (metadata && Array.isArray(order.nodeOrder) && isJSONEqual(flow.nodes.map((node) => node.id), order.nativeNodeOrder)) { + const indices = new Map(order.nodeOrder.map((id: string, index: number) => [id, index])); + graph.nodes.sort((a, b) => Number(indices.get(a.id)) - Number(indices.get(b.id))); + } + if (!metadata) graph.data = createNativeCarrier(graph.data, 'xyflow', flow, graph); + return graph; } /** @@ -331,3 +203,12 @@ export const xyflowConverter: VisualGraphFormatConverter = { to: toXYFlow, from: fromXYFlow, }; + + +/** Exact native source preservation, with strict verified field edits. */ +export const xyflowDocumentConverter = createGraphDocumentConverter({ + format: 'xyflow', + codec: { from: (source: XYFlow) => fromXYFlow(source, { metadata: 'native' }), to: (graph: VisualGraph) => toXYFlow(graph, { nativeCarrier: false }) }, + project: (source: XYFlow) => ({ graph: getStructuredDocumentProjectionGraph(fromXYFlow(source, { metadata: 'native' }), source, 'xyflow') }), + patch: (context) => getStructuredDocumentPatch(context, 'xyflow'), +}); diff --git a/tests/formats/csv.test.ts b/tests/formats/csv.test.ts new file mode 100644 index 0000000..9f979c0 --- /dev/null +++ b/tests/formats/csv.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { fromCSV, toCSV, fromTSV, toTSV, csvDocumentConverter, tsvDocumentConverter, type CSVGraphSource, type CSVGraphSchema } from '../../src/formats/csv'; + +const schema: CSVGraphSchema = { + nodes: [{ name: 'key', field: 'id', type: 'string', required: true }, { name: 'title', field: 'label', type: 'string' }, { name: 'value', field: 'data', type: 'json' }], + edges: [{ name: 'key', field: 'id', type: 'string', required: true }, { name: 'from', field: 'sourceId', type: 'string', required: true }, { name: 'to', field: 'targetId', type: 'string', required: true }, { name: 'cost', field: 'weight', type: 'number' }, { name: 'kind', field: 'mode', type: 'mode' }, { name: 'flag', field: 'data', type: 'boolean' }], +}; +const source: CSVGraphSource = { nodes: 'key,title,value\r\na,"Title, \"\"quoted\"\" 東京\r\nsecond line",null\r\nb,B,0\r\nIsolated,,[]\r\n', edges: 'key,from,to,cost,kind,flag\r\ne,a,b,1.5,undirected,true\r\n', schema }; + +describe('CSV/TSV graph tables', () => { + it('reads explicit typed columns with quoted delimiters, doubled quotes, embedded CRLF and isolated nodes', () => { + const graph = fromCSV(source); + expect(graph.nodes.map((node) => [node.id, node.label, node.data])).toEqual([['a', 'Title, "quoted" 東京\r\nsecond line', null], ['b', 'B', 0], ['Isolated', '', []]]); + expect(graph.edges[0]).toMatchObject({ id: 'e', sourceId: 'a', targetId: 'b', weight: 1.5, mode: 'undirected', data: true }); + }); + + it.each([',', '\t'] as const)('retains full JSON Graph values and field presence using generated %s schema', (delimiter) => { + const graph = createGraph({ id: 'Root', mode: 'bidirectional', data: [null, '', 0, false], direction: 'right', nodes: [ + { id: 'a,東京', label: null, parentId: null, data: { __statelyai: { user: true }, text: 'one\r\ntwo "quotes" \\' }, ports: [{ name: 'out', direction: 'out', data: 7 }], x: 0, y: 2, style: { visible: true } }, + { id: 'b', label: '', data: false, ports: [{ name: 'in', direction: 'in', data: null }] }, + { id: 'Isolated', data: ['a', null] }, + ], edges: [{ id: 'e1', sourceId: 'a,東京', targetId: 'b', sourcePort: 'out', targetPort: 'in', mode: 'undirected', weight: 0, data: 3, points: [{ x: 0, y: 2 }] }, { id: 'e2', sourceId: 'a,東京', targetId: 'b', label: 'Parallel', data: 'text' }] }); + delete graph.nodes[2].label; + Object.defineProperty(graph.nodes[0], '__proto__', { value: { safe: true }, enumerable: true }); + const wire = toCSV(graph, { delimiter }); + expect(fromCSV(JSON.parse(JSON.stringify(wire)))).toEqual(graph); + if (delimiter === '\t') expect(fromTSV(toTSV(graph))).toEqual(graph); + }); + + it('distinguishes missing, null and empty JSON labels visibly in cells', () => { + const graph = createGraph({ nodes: [{ id: 'missing' }, { id: 'null', label: null }, { id: 'empty', label: '' }] }); + delete graph.nodes[0].label; + const wire = toCSV(graph); + const result = fromCSV(wire); + expect(Object.hasOwn(result.nodes[0], 'label')).toBe(false); + expect(result.nodes[1].label).toBeNull(); + expect(result.nodes[2].label).toBe(''); + expect(wire.nodes).toContain('null'); + expect(wire.nodes).toContain('""""""'); + }); + + it('keeps native metadata interpretation explicit and documents opt-out losses', () => { + const graph = createGraph({ id: 'g', data: 1, nodes: [{ id: 'a', label: null }] }); + (graph.nodes[0] as any).future = 'extra'; + const wire = toCSV(graph); + expect((fromCSV(wire).nodes[0] as any).future).toBe('extra'); + expect((fromCSV(wire, { metadata: 'native' }).nodes[0] as any).future).toBeUndefined(); + const minimal = toCSV(graph, { nativeCarrier: false }); + expect(minimal.graph).toBeUndefined(); + expect(minimal.nodes).not.toContain('__extra'); + expect(fromCSV(minimal).id).toBe(''); + }); + + it('requires separate header-bearing node and edge tables including empty graphs', () => { + expect(fromCSV(toCSV(createGraph({}))).nodes).toEqual([]); + expect(() => fromCSV({ nodes: '', edges: '' })).toThrow('header'); + expect(() => fromCSV({ nodes: 'label\nA', edges: 'id,sourceId,targetId' })).toThrow('header'); + expect(fromCSV({ nodes: '\ufeffid\r\na\r\n', edges: 'id,sourceId,targetId\r\n' }).nodes[0].id).toBe('a'); + }); + + it.each([ + [{ nodes: 'id,id\na,a', edges: 'id,sourceId,targetId' }, 'headers'], + [{ nodes: 'id\na\na', edges: 'id,sourceId,targetId' }, 'node IDs'], + [{ nodes: 'id\na', edges: 'id,sourceId,targetId\ne,a,a\ne,a,a' }, 'edge IDs'], + [{ nodes: 'id\na', edges: 'id,sourceId,targetId\ne,a,missing' }, 'endpoints'], + [{ nodes: 'id\n"unterminated', edges: 'id,sourceId,targetId' }, 'unterminated'], + [{ nodes: 'id\na"bad', edges: 'id,sourceId,targetId' }, 'quote'], + [{ nodes: 'id\n"a"garbage', edges: 'id,sourceId,targetId' }, 'unexpected'], + [{ nodes: 'id\na\r', edges: 'id,sourceId,targetId' }, 'bare CR'], + [{ nodes: 'id,label\na', edges: 'id,sourceId,targetId' }, 'row'], + [{ nodes: 'id,data\na,NaN', edges: 'id,sourceId,targetId' }, 'JSON'], + [{ nodes: 'id,data\na,1e999', edges: 'id,sourceId,targetId' }, 'JSON'], + [{ nodes: 'id\na', edges: 'id,sourceId,targetId,mode\ne,a,a,"""invalid"""' }, 'mode'], + ] as Array<[CSVGraphSource, string]>)('rejects malformed tables/domain: %s', (wire, message) => { expect(() => fromCSV(wire)).toThrow(message); }); + + it('rejects malformed typed cells and schema rather than guessing', () => { + for (const [cell, field] of [['01', 'cost'], ['Infinity', 'cost'], ['yes', 'flag'], ['unknown', 'kind']]) { + const graph = { ...source, edges: source.edges.replace(field === 'cost' ? '1.5' : field === 'flag' ? 'true' : 'undirected', cell) }; + expect(() => fromCSV(graph)).toThrow(field === 'kind' ? 'mode' : field === 'flag' ? 'boolean' : 'number'); + } + expect(() => fromCSV({ ...source, schema: { nodes: [{ name: 'key', field: 'id', type: 'string' }, { name: 'key', field: 'label', type: 'string' }] } })).toThrow('duplicate schema'); + expect(() => fromCSV({ ...source, schema: { nodes: [{ name: 'x', field: 'label', type: 'string' }] } })).toThrow('schema requires'); + expect(() => toCSV(createGraph({ nodes: [{ id: 'a' }] }), { schema: { nodes: [{ name: 'id', field: 'id', type: 'string' }, { name: 'label', field: 'label', type: 'string' }] } })).toThrow('cannot be encoded'); + }); + + it('rejects non-JSON Graph data before silently dropping information', () => { + for (const data of [NaN, new Date(), { fn() {} }, [undefined], { value: undefined }, Symbol('x')]) expect(() => toCSV(createGraph({ nodes: [{ id: 'a', data }] }))).toThrow('CSV'); + const cycle: any = {}; cycle.self = cycle; + expect(() => toCSV(createGraph({ data: cycle }))).toThrow('cyclic'); + }); + + it('patches known cells while retaining exact unrelated columns, quotes, CRLF and metadata', () => { + const wire: CSVGraphSource = { nodes: 'id,label,future\r\n"a","""Before"""," untouched "\r\nb,"""B""",raw\r\n', edges: 'id,sourceId,targetId,weight,note\r\ne,a,b,1,"Keep, this"\r\n', metadata: { vendor: ['exact', null] } }; + const document = csvDocumentConverter.from(wire); + expect(csvDocumentConverter.to(JSON.parse(JSON.stringify(document)))).toEqual(wire); + document.graph!.nodes[0].label = 'After "quoted"\nline'; + document.graph!.edges[0].weight = 2; + const result = csvDocumentConverter.to(document); + expect(result.nodes).toContain('" untouched "\r\nb,"""B""",raw\r\n'); + expect(result.edges).toBe(wire.edges.replace(',1,', ',2,')); + expect(result.metadata).toEqual(wire.metadata); + expect(fromCSV(result).nodes[0].label).toBe('After "quoted"\nline'); + }); + + it('merges independent source changes and reports mapped conflicts or unsupported structure', () => { + const document = csvDocumentConverter.from({ nodes: 'id,label,future\na,"""Before""",x\n', edges: 'id,sourceId,targetId\n' }); + document.source.nodes = document.source.nodes.replace(',x', ',y'); + document.graph!.nodes[0].label = 'After'; + expect(csvDocumentConverter.to(document).nodes).toContain(',y\n'); + document.source.nodes = document.source.nodes.replace('Before', 'Native'); + expect(() => csvDocumentConverter.to(document)).toThrow('conflicting-edit'); + const structural = csvDocumentConverter.from(toCSV(createGraph({ nodes: [{ id: 'a' }] }))); + structural.graph!.nodes.push({ type: 'node', id: 'b', data: null }); + expect(() => csvDocumentConverter.to(structural)).toThrow('unsupported-edit'); + }); + + it('patches TSV without inventing schema metadata on an external source', () => { + const wire = { nodes: 'id\tlabel\tunknown\na\t"""Before"""\tkeep\n', edges: 'id\tsourceId\ttargetId\n' }; + const document = tsvDocumentConverter.from(wire); + document.graph!.nodes[0].label = 'After'; + const result = tsvDocumentConverter.to(document); + expect(result).toEqual({ ...wire, nodes: wire.nodes.replace('Before', 'After') }); + }); + + it('patches root data/style and removes a mapped optional value with reparse proof', () => { + const graph = createGraph({ id: 'g', data: { old: true }, nodes: [{ id: 'a', label: 'Before' }] }); + const document = csvDocumentConverter.from(toCSV(graph)); + document.graph!.data = [false, null]; + document.graph!.style = { stroke: 'blue' }; + delete document.graph!.nodes[0].label; + const source = csvDocumentConverter.to(document); + expect(source.graph!.data).toEqual([false, null]); + expect(fromCSV(source).style).toEqual({ stroke: 'blue' }); + expect(Object.hasOwn(fromCSV(source).nodes[0], 'label')).toBe(false); + }); + + it('rejects structural mapped cell edits and missing source cells', () => { + const portDocument = csvDocumentConverter.from(toCSV(createGraph({ nodes: [{ id: 'a', ports: [{ name: 'p' }] }] }))); + portDocument.graph!.nodes[0].ports![0].name = 'renamed'; + expect(() => csvDocumentConverter.to(portDocument)).toThrow('unsupported-edit'); + const missing = csvDocumentConverter.from({ nodes: 'id\na\n', edges: 'id,sourceId,targetId\n' }); + missing.graph!.nodes[0].label = 'New'; + expect(() => csvDocumentConverter.to(missing)).toThrow('no declared source cell'); + }); + + it('exports configured number/boolean/mode cells without coercion', () => { + const graph = fromCSV(source); + const emitted = toCSV(graph, { schema, lineEnding: '\n' }); + expect(emitted.edges).toBe('key,from,to,cost,kind,flag\ne,a,b,1.5,undirected,true\n'); + expect(fromCSV(emitted)).toEqual(graph); + expect(() => toCSV(graph, { lineEnding: '\r' as any })).toThrow('line ending'); + expect(() => fromCSV(source, { delimiter: ';' as any })).toThrow('delimiter'); + }); + + it('processes ten thousand rows with identity and source patch correspondence intact', () => { + const graph = createGraph({ nodes: Array.from({ length: 10_000 }, (_, index) => ({ id: `n${index}`, data: index })) }); + const wire = toCSV(graph); + const document = csvDocumentConverter.from(wire); + document.graph!.nodes[9999].data = 10000; + expect(fromCSV(csvDocumentConverter.to(document)).nodes[9999].data).toBe(10000); + }); + it.each([ + ['label', '123', 'label'], ['x', '"heavy"', 'finite'], ['style', '[]', 'style'], + ['parentId', '123', 'parent'], ['ports', '"bad"', 'ports'], + ])('validates typed node fields despite JSON cell syntax: %s', (field, value, message) => { + const cell = `"${value.replace(/"/g, '""')}"`; + expect(() => fromCSV({ nodes: `id,${field}\na,${cell}\n`, edges: 'id,sourceId,targetId\n' })).toThrow(message); + }); + + it('validates typed weight, geometry and roots even with declared text cells', () => { + expect(() => fromCSV({ nodes: 'id\na\n', edges: 'id,sourceId,targetId,weight\ne,a,a,"""heavy"""\n' })).toThrow('finite'); + expect(() => fromCSV({ nodes: 'id,x\na,heavy\n', edges: 'id,sourceId,targetId\n', schema: { nodes: [{ name: 'id', field: 'id', type: 'string' }, { name: 'x', field: 'x', type: 'string' }] } })).toThrow('finite'); + expect(() => fromCSV({ nodes: 'id\na\n', edges: 'id,sourceId,targetId\n', graph: { mode: null } })).toThrow('mode'); + expect(() => fromCSV({ nodes: 'id\na\n', edges: 'id,sourceId,targetId\n', graph: { style: [] } })).toThrow('style'); + }); + + it('protects required/type fields from extra cells while preserving hostile user names', () => { + const graph = createGraph({ nodes: [{ id: '__proto__', data: JSON.parse('{"constructor":"value","__proto__":{"safe":true}}') }] }); + (graph.nodes[0] as any).$extra = 'actual user field'; + expect(fromCSV(toCSV(graph))).toEqual(graph); + const malicious = 'id,__extra\na,"{\"\"type\"\":\"\"edge\"\"}"\n'; + expect(() => fromCSV({ nodes: malicious, edges: 'id,sourceId,targetId\n' })).toThrow('collides'); + }); + +}); diff --git a/tests/formats/cytoscape.test.ts b/tests/formats/cytoscape.test.ts index ecc89ff..cf0c8e1 100644 --- a/tests/formats/cytoscape.test.ts +++ b/tests/formats/cytoscape.test.ts @@ -62,9 +62,17 @@ describe('Cytoscape.js JSON', () => { expect(() => fromCytoscapeJSON({} as any)).toThrow('Cytoscape: missing "elements" property'); }); - it('throws on missing nodes/edges arrays', () => { - expect(() => fromCytoscapeJSON({ elements: { edges: [] } } as any)).toThrow('Cytoscape: "elements.nodes" must be an array'); - expect(() => fromCytoscapeJSON({ elements: { nodes: [] } } as any)).toThrow('Cytoscape: "elements.edges" must be an array'); + it('throws on malformed present nodes/edges groups', () => { + expect(() => fromCytoscapeJSON({ elements: { nodes: null, edges: [] } } as any)).toThrow('Cytoscape: "elements.nodes" must be an array'); + expect(() => fromCytoscapeJSON({ elements: { nodes: [], edges: {} } } as any)).toThrow('Cytoscape: "elements.edges" must be an array'); + }); + + it('accepts native omitted empty groups while retaining the grouped output shape', () => { + for (const elements of [{ nodes: [] }, { edges: [] }, {}]) { + const graph = fromCytoscapeJSON({ elements }); + expect(graph.nodes).toEqual([]); expect(graph.edges).toEqual([]); + expect(toCytoscapeJSON(graph).elements).toEqual({ nodes: [], edges: [] }); + } }); it('handles empty elements', () => { diff --git a/tests/formats/document-diagrams.test.ts b/tests/formats/document-diagrams.test.ts new file mode 100644 index 0000000..9bbf074 --- /dev/null +++ b/tests/formats/document-diagrams.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { d2DocumentConverter } from '../../src/formats/d2'; +import { mermaidFlowchartDocumentConverter, mermaidSequenceDocumentConverter, mermaidStateDocumentConverter, mermaidClassDocumentConverter, mermaidERDocumentConverter, mermaidBlockDocumentConverter, mermaidMindmapDocumentConverter, mermaidIshikawaDocumentConverter } from '../../src/formats/mermaid'; +import type { GraphDocumentConverter } from '../../src/formats/document'; + +const cases: Array<[GraphDocumentConverter, string]> = [ + [d2DocumentConverter, '# original\r\na: "Before"\r\na -> b\r\n'], + [mermaidFlowchartDocumentConverter, '---\ntitle: Original\n---\n%% comment\nflowchart TD\nA[Before]\nA --> B\n'], + [mermaidSequenceDocumentConverter, '%%{init: {"theme":"dark"}}%%\nsequenceDiagram\nparticipant A as Before\nA->>B: Message\n%% comment\n'], + [mermaidStateDocumentConverter, 'stateDiagram-v2\nstate "Before" as A\n%% comment\nA --> B\n'], + [mermaidClassDocumentConverter, 'classDiagram\n%% comment\nclass A\nA --> B\n'], + [mermaidERDocumentConverter, 'erDiagram\n%% comment\nA\n'], + [mermaidBlockDocumentConverter, 'block-beta\n%% comment\nA["Before"]\n'], + [mermaidMindmapDocumentConverter, 'mindmap\n%% comment\n root((Before))\n After\n'], + [mermaidIshikawaDocumentConverter, 'ishikawa-beta\n%% comment\n Cause\n Effect\n'], +]; + +describe('diagram native documents', () => { + it.each(cases.map(([converter, source]) => [converter.format, converter, source] as const))('%s preserves every no-op byte through JSON persistence and refuses unsupported edits', (_format, converter, source) => { + const document = converter.from(source); + expect(document.graph).not.toBeNull(); + expect(converter.to(JSON.parse(JSON.stringify(document)))).toBe(source); + document.graph!.nodes.push({ type: 'node', id: 'new-node', data: {} }); + expect(() => converter.to(document)).toThrow('unsupported-edit'); + }); + + it.each([ + [d2DocumentConverter, '# untouched\r\na: "Before" # trailing\r\na -> b\r\n', '# untouched\r\na: "After" # trailing\r\na -> b\r\n'], + [mermaidFlowchartDocumentConverter, 'flowchart TD\r\n A["Before"] %% trailing\r\n A --> B\r\n', 'flowchart TD\r\n A["After"] %% trailing\r\n A --> B\r\n'], + [mermaidStateDocumentConverter, 'stateDiagram-v2\n state "Before" as A %% trailing\n A --> B\n', 'stateDiagram-v2\n state "After" as A %% trailing\n A --> B\n'], + [mermaidSequenceDocumentConverter, 'sequenceDiagram\n participant A as Before %% trailing\n A->>B: Message\n', 'sequenceDiagram\n participant A as After %% trailing\n A->>B: Message\n'], + ] as Array<[GraphDocumentConverter, string, string]>)('%s patches a verified label span preserving comments and whitespace', (converter, source, expected) => { + const document = converter.from(source); + document.graph!.nodes[0].label = 'After'; + expect(converter.to(JSON.parse(JSON.stringify(document)))).toBe(expected); + expect(converter.from(expected).graph!.nodes[0].label).toBe('After'); + }); + + it('patches a sequence message while retaining note placement and inline comments', () => { + const source = 'sequenceDiagram\nA->>B: Before %% trailing\nnote over A,B: Keep note\nB-->>A: Response\n'; + const document = mermaidSequenceDocumentConverter.from(source); + document.graph!.edges[0].label = 'After'; + const result = mermaidSequenceDocumentConverter.to(document); + expect(result).toBe(source.replace('Before', 'After')); + expect(mermaidSequenceDocumentConverter.from(result).graph!.data.events).toEqual(document.graph!.data.events); + }); + + it('merges a native comment edit with an independent Graph label edit', () => { + const document = mermaidFlowchartDocumentConverter.from('%% Original\nflowchart TD\nA[Before]\n'); + document.source = document.source.replace('Original', 'Native edit'); + document.graph!.nodes[0].label = 'After'; + expect(mermaidFlowchartDocumentConverter.to(document)).toBe('%% Native edit\nflowchart TD\nA["After"]\n'); + }); + + it('rejects duplicate declaration spans and unsupported expanded syntax safely', () => { + for (const source of ['flowchart TD\nA[Before]\nA[Before]\n', 'flowchart TD\nA@{ label: "Before", shape: rect }\n']) { + const document = mermaidFlowchartDocumentConverter.from(source); + document.graph!.nodes[0].label = 'After'; + expect(() => mermaidFlowchartDocumentConverter.to(document)).toThrow('unsupported-edit'); + } + }); + + it('round-trips escaped label edits symmetrically and retains adjacent comments', () => { + const document = mermaidFlowchartDocumentConverter.from('flowchart TD\nA[Before] %% keep\n'); + document.graph!.nodes[0].label = 'quoted "value" # | \\ newline\n'; + const source = mermaidFlowchartDocumentConverter.to(document); + expect(source).toContain('%% keep'); + expect(mermaidFlowchartDocumentConverter.from(source).graph!.nodes[0].label).toBe(document.graph!.nodes[0].label); + }); +}); diff --git a/tests/formats/document-structured.test.ts b/tests/formats/document-structured.test.ts new file mode 100644 index 0000000..937fdb7 --- /dev/null +++ b/tests/formats/document-structured.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest'; +import { createVisualGraph } from '../../src/graph'; +import { cytoscapeDocumentConverter, toCytoscapeJSON } from '../../src/formats/cytoscape'; +import { d3DocumentConverter, toD3Graph } from '../../src/formats/d3'; +import { elkDocumentConverter, toELK } from '../../src/formats/elk'; +import { xyflowDocumentConverter, toXYFlow } from '../../src/formats/xyflow'; +import { jgfDocumentConverter, toJGF } from '../../src/formats/jgf'; +import { adjacencyListDocumentConverter } from '../../src/formats/adjacency-list'; +import { edgeListDocumentConverter } from '../../src/formats/edge-list'; +import { tgfDocumentConverter } from '../../src/formats/tgf'; + +function getPersisted(value: T): T { return JSON.parse(JSON.stringify(value)); } +const cases: Array<[string, any, any]> = [ + ['cytoscape flat', cytoscapeDocumentConverter, { zoom: 2, elements: [{ data: { id: 'a', label: 'A', __statelyai: { foreign: true } } }, { data: { id: 'e', source: 'a', target: 'b' }, custom: 1 }, { data: { id: 'b' } }] }], + ['D3', d3DocumentConverter, { data: { __statelyaiNative: { foreign: true } }, nodes: [{ id: 'a', label: 'A', fx: null }], links: [], custom: [1, 2] }], + ['ELK hyperedge', elkDocumentConverter, { id: 'root', children: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], edges: [{ id: 'e', sources: ['a', 'b'], targets: ['c'], custom: 'hyper' }] }], + ['xyflow', xyflowDocumentConverter, { nodes: [{ id: 'a', position: { x: 1, y: 2 }, data: { label: 'A', __statelyai: { foreign: true } }, selected: true }], edges: [], viewport: { x: 2, y: 3, zoom: 4 } }], + ['JGF v1', jgfDocumentConverter, { graph: { nodes: [{ id: 'a', label: 'A', metadata: { __statelyai: { foreign: true } } }], edges: [], metadata: { author: 'Ada' } } }], + ['JGF v2', jgfDocumentConverter, { graph: { id: 'g', nodes: { '10': { label: 'Ten' }, '2': { label: 'Two' } }, edges: [], custom: true } }], + ['JGF hybrid', jgfDocumentConverter, { graph: { id: '', nodes: [{ id: 'a' }], edges: [], metadata: { data: { legacy: true } } } }], + ['adjacency', adjacencyListDocumentConverter, { a: ['b', 'b'], b: ['a'], extra: [] }], + ['edge list', edgeListDocumentConverter, [['a', 'b'], ['a', 'b'], ['b', 'a']]], + ['TGF', tgfDocumentConverter, ' a\tA\r\nb B\r\n # \r\n a\t b edge\r\n'], +]; + +describe('structured native document source preservation', () => { + it.each(cases)('%s preserves complete native source after JSON persistence', (_name, converter, source) => { + const document = converter.from(source); + expect(converter.to(document)).toEqual(source); + expect(converter.to(getPersisted(document))).toEqual(source); + }); + it('retains ELK hyperedge source without fabricating a binary Graph', () => { + const document = elkDocumentConverter.from(cases[2][2]); + expect(document.graph).toBeNull(); + expect(document.diagnostics.length).toBeGreaterThan(0); + }); +}); + +describe('strict verified structured native edits', () => { + it('patches flat Cytoscape labels while retaining interleaved elements and reserved fields', () => { + const document = getPersisted(cytoscapeDocumentConverter.from(cases[0][2])); + document.graph!.nodes[0].label = 'Changed'; + (document.source.elements as any[])[0].custom = 'source edit'; + const source: any = cytoscapeDocumentConverter.to(document); + expect(source.elements.map((element: any) => element.data.id)).toEqual(['a', 'e', 'b']); + expect(source.elements[0]).toEqual({ data: { id: 'a', label: 'Changed', __statelyai: { foreign: true } }, custom: 'source edit' }); + }); + it('patches D3 label and geometry while retaining fixed/velocity properties', () => { + const source = { nodes: [{ id: 'a', label: 'Before', x: 1, fx: null, vx: 3, custom: true }], links: [] }; + const document = getPersisted(d3DocumentConverter.from(source)); + document.graph!.nodes[0].label = 'After'; document.graph!.nodes[0].x = 4; + expect(d3DocumentConverter.to(document)).toEqual({ nodes: [{ id: 'a', label: 'After', x: 4, fx: null, vx: 3, custom: true }], links: [] }); + }); + it('patches native JGF v2 metadata and labels without changing its dictionary shape', () => { + const source = { graph: { id: 'g', nodes: { a: { label: 'Before', metadata: { __statelyai: { foreign: true }, score: 1 } } }, edges: [], custom: true } }; + const document = getPersisted(jgfDocumentConverter.from(source)); + document.graph!.nodes[0].label = 'After'; document.graph!.nodes[0].data.score = 2; + expect(jgfDocumentConverter.to(document)).toEqual({ graph: { ...source.graph, nodes: { a: { label: 'After', metadata: { __statelyai: { foreign: true }, score: 2 } } } } }); + }); + it('patches renderer labels and positions without losing viewport or foreign data', () => { + const document = getPersisted(xyflowDocumentConverter.from(cases[3][2])); + document.graph!.nodes[0].label = 'After'; document.graph!.nodes[0].x = 9; + const source: any = xyflowDocumentConverter.to(document); + expect(source.nodes[0]).toEqual({ id: 'a', position: { x: 9, y: 2 }, data: { label: 'After', __statelyai: { foreign: true } }, selected: true }); + expect(source.viewport).toEqual({ x: 2, y: 3, zoom: 4 }); + }); + it('patches original ELK first section while retaining unknown fields and extra sections', () => { + const second = { id: 's2', startPoint: { x: 3, y: 4 }, endPoint: { x: 5, y: 6 }, custom: 'keep' }; + const source = { id: 'root', children: [{ id: 'a', labels: [{ text: 'Before', custom: 1 }, { text: 'secondary' }] }, { id: 'b' }], edges: [{ id: 'e', sources: ['a'], targets: ['b'], sections: [{ id: 's1', startPoint: { x: 1, y: 2 }, endPoint: { x: 3, y: 4 }, outgoingSections: ['s2'] }, second] }] }; + const document = getPersisted(elkDocumentConverter.from(source)); + document.graph!.nodes[0].label = 'After'; document.graph!.edges[0].points![0].x = 8; + const result: any = elkDocumentConverter.to(document); + expect(result.children[0].labels).toEqual([{ text: 'After', custom: 1 }, { text: 'secondary' }]); + expect(result.edges[0].sections[0]).toMatchObject({ id: 's1', startPoint: { x: 8, y: 2 }, outgoingSections: ['s2'] }); + expect(result.edges[0].sections[1]).toEqual(second); + }); + it('patches TGF labels while retaining CRLF and native prefix spacing', () => { + const document = getPersisted(tgfDocumentConverter.from(cases[9][2])); + document.graph!.nodes[0].label = 'After'; document.graph!.edges[0].label = 'changed'; + expect(tgfDocumentConverter.to(document)).toBe(' a\tAfter\r\nb B\r\n # \r\n a\t b changed\r\n'); + }); + it.each(cases.filter(([name]) => name !== 'ELK hyperedge'))('%s rejects structural or unrepresentable changes', (_name, converter, source) => { + const document = getPersisted(converter.from(source)); + document.graph.nodes[0].id = 'replacement'; + expect(() => converter.to(document)).toThrow(); + }); + it.each([[adjacencyListDocumentConverter, { a: [] }], [edgeListDocumentConverter, [['a', 'b']]]] as const)('minimal formats reject label edits', (converter: any, source: any) => { + const document = getPersisted(converter.from(source)); document.graph.nodes[0].label = 'Unsupported'; + expect(() => converter.to(document)).toThrow(); + }); + it('rejects same-field native and Graph edits instead of picking stale source', () => { + const document = getPersisted(d3DocumentConverter.from({ nodes: [{ id: 'a', label: 'Before' }], links: [] })); + document.source.nodes[0].label = 'Source'; document.graph!.nodes[0].label = 'Graph'; + expect(() => d3DocumentConverter.to(document)).toThrow(); + }); + it('keeps native edits authoritative when the Graph projection is untouched', () => { + const document = getPersisted(d3DocumentConverter.from({ nodes: [{ id: 'a', label: 'Before' }], links: [] })); + document.source.nodes[0].label = 'Source'; + expect(d3DocumentConverter.to(document).nodes[0].label).toBe('Source'); + }); +}); + + +describe('structured document edit boundary', () => { + it('never silently discards edits to actual xyflow user labels', () => { + const source = toXYFlow(createVisualGraph({ nodes: [{ id: 'a', label: 'Visible', data: { label: 'user' } }] })); + const document = getPersisted(xyflowDocumentConverter.from(source)); + expect(document.graph!.nodes[0].data.label).toBe('user'); + document.graph!.nodes[0].data.label = 'changed user'; + const result = xyflowDocumentConverter.getUpdate(document); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.diagnostics.some((diagnostic) => diagnostic.code === 'unsupported-edit')).toBe(true); + expect(() => xyflowDocumentConverter.to(document)).toThrow(); + }); + it('patches original primitive ELK geometry without changing native edge representation', () => { + const source = { id: 'root', children: [{ id: 'a', ports: [{ id: 'p', x: 1, custom: true }] }, { id: 'b' }], edges: [{ id: 'e', source: 'a', sourcePort: 'p', target: 'b', sourcePoint: { x: 1, y: 2 }, targetPoint: { x: 3, y: 4 }, custom: 1 }] }; + const document = getPersisted(elkDocumentConverter.from(source)); + document.graph!.nodes[0].ports![0].x = 9; document.graph!.edges[0].points![0].x = 8; + const result: any = elkDocumentConverter.to(document); + expect(result.children[0].ports[0]).toEqual({ id: 'p', x: 9, custom: true }); + expect(result.edges[0]).toMatchObject({ source: 'a', sourcePort: 'p', target: 'b', sourcePoint: { x: 8, y: 2 }, custom: 1 }); + expect(result.edges[0]).not.toHaveProperty('sources'); + }); + it('patches Cytoscape weight, style and user data without overwriting reserved metadata', () => { + const source = { elements: { nodes: [{ data: { id: 'a', score: 1, __statelyai: { foreign: true } } }, { data: { id: 'b' } }], edges: [{ data: { id: 'e', source: 'a', target: 'b', weight: 2, style: { stroke: 'red' } }, selected: true }] } }; + const document = getPersisted(cytoscapeDocumentConverter.from(source)); + document.graph!.nodes[0].data.score = 3; document.graph!.edges[0].weight = 4; document.graph!.edges[0].style = { stroke: 'blue' }; + const result: any = cytoscapeDocumentConverter.to(document); + expect(result.elements.nodes[0].data).toEqual({ id: 'a', score: 3, __statelyai: { foreign: true } }); + expect(result.elements.edges[0]).toEqual({ data: { id: 'e', source: 'a', target: 'b', weight: 4, style: { stroke: 'blue' } }, selected: true }); + }); + it('retains source schema and unknown fields while editing JGF directedness and relation', () => { + const source = { graph: { nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', source: 'a', target: 'b', relation: 'old', directed: false, metadata: { __statelyai: { foreign: true }, score: 1 } }] } }; + const document = getPersisted(jgfDocumentConverter.from(source)); + document.graph!.edges[0].mode = 'directed'; document.graph!.edges[0].data.relation = 'new'; + const result: any = jgfDocumentConverter.to(document); + expect(result.graph.edges[0]).toEqual({ ...source.graph.edges[0], relation: 'new', directed: true }); + }); + it('retains valid-looking foreign carrier payloads while editing native D3 labels', () => { + const data = { __statelyaiNative: { kind: '@statelyai/graph/native', version: 1, format: 'd3', data: 'foreign', native: { nodes: [], links: [] }, baseline: { id: '', mode: 'directed', initialNodeId: null, data: 1, nodes: [], edges: [] } } }; + const source = { data, nodes: [{ id: 'a', label: 'Before' }], links: [] }; + const document = getPersisted(d3DocumentConverter.from(source)); + expect(document.graph!.data).toEqual(data); + document.graph!.nodes[0].label = 'After'; + expect(d3DocumentConverter.to(document)).toEqual({ ...source, nodes: [{ id: 'a', label: 'After' }] }); + }); +}); + + +describe('generated source user-data provenance', () => { + it.each([ + ['d3', d3DocumentConverter, toD3Graph], + ['cytoscape', cytoscapeDocumentConverter, toCytoscapeJSON], + ['elk', elkDocumentConverter, toELK], + ['xyflow', xyflowDocumentConverter, toXYFlow], + ['jgf', jgfDocumentConverter, toJGF], + ] as Array<[string, any, any]>)('%s preserves actual user data that resembles an import carrier', (format, converter, emit) => { + const data = { __statelyaiNative: { kind: '@statelyai/graph/native', version: 1, format, data: 'foreign', native: { nodes: [], links: [] }, baseline: { id: '', mode: 'directed', initialNodeId: null, data: 1, nodes: [], edges: [] } }, foo: 1 }; + const source = emit(createVisualGraph({ data, nodes: [{ id: 'a', label: 'Before', data }] })); + const document = getPersisted(converter.from(source)); + expect(document.graph.data).toEqual(data); + expect(document.graph.nodes[0].data).toEqual(data); + expect(converter.to(document)).toEqual(source); + document.graph.nodes[0].label = 'After'; + const result = converter.getUpdate(document); + expect(result.ok).toBe(true); + if (result.ok) expect(converter.from(result.source).graph.data).toEqual(data); + }); +}); + + +describe('fresh native structure and requested fields', () => { + it('retains native node reordering and additions while patching a stable-ID label', () => { + const document = getPersisted(d3DocumentConverter.from({ nodes: [{ id: 'a', label: 'Before', custom: 1 }, { id: 'b' }], links: [] })); + document.source.nodes.reverse(); document.source.nodes.push({ id: 'c', custom: 3 }); + document.graph!.nodes[0].label = 'After'; + expect(d3DocumentConverter.to(document)).toEqual({ nodes: [{ id: 'b' }, { id: 'a', label: 'After', custom: 1 }, { id: 'c', custom: 3 }], links: [] }); + }); +}); + + +describe('explicit native carrier export override', () => { + it.each([ + ['d3', d3DocumentConverter, toD3Graph], ['cytoscape', cytoscapeDocumentConverter, toCytoscapeJSON], + ['elk', elkDocumentConverter, toELK], ['xyflow', xyflowDocumentConverter, toXYFlow], ['jgf', jgfDocumentConverter, toJGF], + ] as Array<[string, any, any]>)('%s preserves a user payload matching the full carrier discriminator', (format, converter, emit) => { + const data = { __statelyaiNative: { kind: '@statelyai/graph/native', provenance: 'native-import', version: 1, format, data: 'foreign', native: { nodes: [], links: [] }, baseline: { id: '', mode: 'directed', initialNodeId: null, data: 1, nodes: [], edges: [] } }, foo: 1 }; + const source = emit(createVisualGraph({ data, nodes: [{ id: 'a', label: 'Before', data }] }), { nativeCarrier: false }); + const document = getPersisted(converter.from(source)); + expect(document.graph.data).toEqual(data); + expect(document.graph.nodes[0].data).toEqual(data); + expect(converter.to(document)).toEqual(source); + document.graph.nodes[0].label = 'After'; + expect(converter.getUpdate(document).ok).toBe(true); + }); +}); + + +describe('D3 JSON endpoint identity boundary', () => { + it.each(['x', 'label'] as const)('rejects %s edits that could leave identity-sensitive embedded endpoints stale', (field) => { + const a = { id: 'a', x: 1, label: 'Before', foreign: { keep: true } }, b = { id: 'b' }; + const document = getPersisted(d3DocumentConverter.from({ nodes: [a, b], links: [{ source: a, target: b }] })); + expect(d3DocumentConverter.to(document)).toEqual({ nodes: [a, b], links: [{ source: a, target: b }] }); + if (field === 'x') document.graph!.nodes[0].x = 4; else document.graph!.nodes[0].label = 'After'; + const result = d3DocumentConverter.getUpdate(document); + expect(result.ok).toBe(false); + if (!result.ok) { expect(result.diagnostics[0].code).toBe('unsupported-edit'); expect(result.diagnostics[0].message).toContain('alias identity'); } + expect(document.source).toEqual({ nodes: [a, b], links: [{ source: a, target: b }] }); + }); + it('patches independent node fields when object endpoints contain only explicit IDs', () => { + const source = { nodes: [{ id: 'a', label: 'Before' }, { id: 'b' }], links: [{ source: { id: 'a' }, target: { id: 'b' }, custom: 1 }] }; + const document = getPersisted(d3DocumentConverter.from(source)); document.graph!.nodes[0].label = 'After'; + expect(d3DocumentConverter.to(document)).toEqual({ ...source, nodes: [{ id: 'a', label: 'After' }, { id: 'b' }] }); + }); +}); diff --git a/tests/formats/document-xml.test.ts b/tests/formats/document-xml.test.ts new file mode 100644 index 0000000..33237db --- /dev/null +++ b/tests/formats/document-xml.test.ts @@ -0,0 +1,395 @@ +import { getEditedSource } from '../../src/formats/xml-document'; +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { graphMLDocumentConverter, fromGraphML, toGraphML } from '../../src/formats/graphml'; +import { gexfDocumentConverter, fromGEXF, toGEXF } from '../../src/formats/gexf'; +import { dotDocumentConverter, fromDOT, toDOT } from '../../src/formats/dot'; +import { gmlDocumentConverter, fromGML, toGML } from '../../src/formats/gml'; + +const fixtures = [ + { name: 'GraphML', converter: graphMLDocumentConverter, parse: fromGraphML, source: `\n\nOldKEEPEdge`, old: 'Old', next: 'New & ', encoded: 'New & <safe>', needle: 'KEEP' }, + { name: 'GEXF', converter: gexfDocumentConverter, parse: fromGEXF, source: `\n\nKEEP`, old: 'Old', next: 'New & ', encoded: 'New & <safe>', needle: 'KEEP' }, + { name: 'DOT', converter: dotDocumentConverter, parse: fromDOT, source: `// label="fake"\ndigraph G { a [label="Old", vendor="KEEP"]; b; a -> b [label="Edge"]; }`, old: 'Old', next: 'New "safe"', encoded: 'New \\"safe\\"', needle: 'KEEP' }, + { name: 'GML', converter: gmlDocumentConverter, parse: fromGML, source: `# label "fake"\ngraph [ directed 1 node [ id "a" label "Old" vendor "KEEP" ] node [ id "b" ] edge [ id "e" source "a" target "b" label "Edge" ] ]`, old: 'Old', next: 'New & "safe"', encoded: 'New & "safe"', needle: 'KEEP' }, +]; + +for (const fixture of fixtures) describe(`${fixture.name} native document`, () => { + it('preserves exact source and comments after JSON persistence', () => { + const document = fixture.converter.from(fixture.source); + expect(document.diagnostics.some((item) => item.code.startsWith('projection-'))).toBe(true); + expect(fixture.converter.to(JSON.parse(JSON.stringify(document)))).toBe(fixture.source); + }); + it('patches the verified label span while retaining all other bytes', () => { + const document = fixture.converter.from(fixture.source); + document.graph!.nodes.find((node) => node.id === 'a')!.label = fixture.next; + const output = fixture.converter.to(document); + expect(fixture.parse(output).nodes.find((node) => node.id === 'a')!.label).toBe(fixture.next); + expect(output).toContain(fixture.needle); + expect(output).toBe(fixture.source.replace('Old', fixture.encoded)); + }); + it('retains independent native unknown edits during a Graph label edit', () => { + const document = fixture.converter.from(fixture.source); + document.source = document.source.replace('KEEP', 'CHANGED'); + document.graph!.nodes.find((node) => node.id === 'a')!.label = 'New'; + const output = fixture.converter.to(document); + expect(output).toContain('CHANGED'); + expect(fixture.parse(output).nodes.find((node) => node.id === 'a')!.label).toBe('New'); + }); + it('rejects conflicting native and Graph label edits', () => { + const document = fixture.converter.from(fixture.source); + document.source = document.source.replace('Old', 'Native'); + document.graph!.nodes.find((node) => node.id === 'a')!.label = 'Graph'; + expect(() => fixture.converter.to(document)).toThrow(/conflict/); + }); + it('never replays stale source for unsupported structural/data edits', () => { + const document = fixture.converter.from(fixture.source); + document.graph!.nodes.find((node) => node.id === 'a')!.data = { arbitrary: true }; + expect(() => fixture.converter.to(document)).toThrow(/unsupported/); + }); + it('patches edge labels independently', () => { + const document = fixture.converter.from(fixture.source); + document.graph!.edges[0].label = 'New edge'; + expect(fixture.parse(fixture.converter.to(document)).edges[0].label).toBe('New edge'); + }); +}); + +it('retains GraphML hyperedges with an unavailable binary projection', () => { + const source = ''; + const document = graphMLDocumentConverter.from(source); + expect(document.graph).toBeNull(); + expect(graphMLDocumentConverter.to(JSON.parse(JSON.stringify(document)))).toBe(source); + expect(() => graphMLDocumentConverter.from(source, { strict: true })).toThrow(); +}); +it('makes first-graph GraphML projection partial while retaining every graph', () => { + const source = ''; + const document = graphMLDocumentConverter.from(source); + expect(document.diagnostics.some((item) => item.code === 'projection-multiple-graphs')).toBe(true); + expect(graphMLDocumentConverter.to(document)).toBe(source); +}); +it('rejects HTML DOT label changes instead of changing their interpretation', () => { + const document = dotDocumentConverter.from('digraph { a [label=<Old>]; }'); + document.graph!.nodes[0].label = 'New'; + expect(() => dotDocumentConverter.to(document)).toThrow(/unsupported/); +}); +it('rejects XML control characters in new labels', () => { + const document = gexfDocumentConverter.from(fixtures[1].source); + document.graph!.nodes[0].label = 'bad\u0001'; + expect(() => gexfDocumentConverter.to(document)).toThrow(/invalid.*label/); +}); + +it('ignores fake XML identities in comments, doctype, PI and CDATA', () => { + const source = `Old">]>ActualOld]]>`; + const document = graphMLDocumentConverter.from(source); + expect(document.graph).not.toBeNull(); + document.graph!.nodes[0].label = 'New'; + expect(graphMLDocumentConverter.to(document)).toBe(source.replace('Actual', 'New')); +}); +it('does not mistake vendor or data-subtree elements for graph entities', () => { + const source = `ActualFakeForeign`; + const document = graphMLDocumentConverter.from(source); + document.graph!.nodes[0].label = 'New'; + expect(graphMLDocumentConverter.to(document)).toBe(source.replace('Actual', 'New')); +}); +it('retains CDATA labels unchanged but rejects an unverified content replacement', () => { + const source = ']]>'; + const document = graphMLDocumentConverter.from(source); + expect(graphMLDocumentConverter.to(document)).toBe(source); + document.graph!.nodes[0].label = 'New'; + expect(() => graphMLDocumentConverter.to(document)).toThrow(/unsupported.*CDATA/); +}); +it('rejects non-string GraphML label keys and does not alter native declarations', () => { + const document = graphMLDocumentConverter.from('7'); + document.graph!.nodes[0].label = 'New'; + expect(() => graphMLDocumentConverter.to(document)).toThrow(/string type/); +}); +it('adds a native GEXF label without replacing other attributes or formatting', () => { + const source = ''; + const document = gexfDocumentConverter.from(source); + document.graph!.nodes[0].label = 'New'; + expect(gexfDocumentConverter.to(document)).toBe(source.replace('id="a" />', 'id="a" label="New"/>')); +}); +it('uses the last repeated DOT label declaration and preserves prior statements', () => { + const source = 'digraph { a [label="First"]; /* a[label="Fake"] */ a [label="Actual"]; a [vendor="KEEP"]; }'; + const document = dotDocumentConverter.from(source); + document.graph!.nodes[0].label = 'New'; + expect(dotDocumentConverter.to(document)).toBe(source.replace('Actual', 'New')); +}); +it('adds a local DOT override for an inherited plain label', () => { + const source = 'digraph { node[label="Default"]; a; b; }'; + const document = dotDocumentConverter.from(source); + document.graph!.nodes[0].label = 'New'; + const output = dotDocumentConverter.to(document); + expect(output).toBe(source.replace(' a;', ' a [label="New"];')); + expect(fromDOT(output).nodes[1].label).toBe('Default'); +}); +it.each(['digraph { a -> b -> c [label="Old"]; }', 'strict digraph { a -> b [label="First"]; a -> b [label="Old"]; }'])('rejects ambiguous DOT edge identity: %s', (source) => { + const document = dotDocumentConverter.from(source); + document.graph!.edges[0].label = 'New'; + expect(() => dotDocumentConverter.to(document)).toThrow(/unsupported/); +}); +it('rejects inherited HTML DOT label changes', () => { + for (const source of ['digraph { node[label=<Old>]; a; }']) { + const document = dotDocumentConverter.from(source); + document.graph!.nodes[0].label = 'New'; + expect(() => dotDocumentConverter.to(document)).toThrow(/unsupported/); + } +}); +it('resolves GML numeric IDs and versioned original IDs without touching opaque blocks', () => { + const source = 'graph [ directed 1 node [ id 0 stately "{"format":"stately","version":1,"originalId":"a"}" label "Actual" graphics [ vendor "label "Fake"" ] ] ]'; + const document = gmlDocumentConverter.from(source); + document.graph!.nodes[0].label = 'New'; + expect(gmlDocumentConverter.to(document)).toBe(source.replace('Actual', 'New')); +}); +it('adds a GML quoted label without changing neighboring comments', () => { + const source = 'graph [ node [ id 1 # label "Fake"\n ] ]'; + const document = gmlDocumentConverter.from(source); + document.graph!.nodes[0].label = 'New'; + expect(gmlDocumentConverter.to(document)).toBe(source.replace('\n ]', '\n label "New" ]')); +}); +it('preserves multiple GML graphs with an unavailable projection', () => { + const source = 'graph [ node [ id 1 ] ] # between\ngraph [ node [ id 2 ] ]'; + const document = gmlDocumentConverter.from(source); + expect(document.graph).toBeNull(); + expect(gmlDocumentConverter.to(JSON.parse(JSON.stringify(document)))).toBe(source); +}); +it('never strips deliberately reserved-shaped user data during label edits', () => { + const source = 'Old{"_graphml":{"format":"@statelyai/graph/graphml","version":1,"wrapped":true,"userData":17},"keep":true}'; + const document = graphMLDocumentConverter.from(source); + const before = document.graph!.nodes[0].data; + document.graph!.nodes[0].label = 'New'; + const output = graphMLDocumentConverter.to(document); + expect(output).toBe(source.replace('Old', 'New')); + expect(fromGraphML(output).nodes[0].data).toEqual(before); +}); + +it('preserves valid DOT concatenation with an unavailable projection from the legacy parser', () => { + const source = 'digraph { a [label="Old" + "continued"]; }'; + const document = dotDocumentConverter.from(source); + expect(document.graph).toBeNull(); + expect(document.diagnostics.some((item) => item.code === 'projection-unavailable')).toBe(true); + expect(dotDocumentConverter.to(document)).toBe(source); +}); + +for (const fixture of fixtures) it(`${fixture.name} accepts native-only edits as authoritative`, () => { + const document = fixture.converter.from(fixture.source); + document.source = fixture.source.replace('Old', 'Native').replace('KEEP', 'Vendor').replace('fake', 'COMMENT'); + expect(fixture.converter.to(document)).toBe(document.source); +}); +it('merges native GraphML node addition and reordering with a Graph label edit', () => { + const source = 'Old'; + const document = graphMLDocumentConverter.from(source); + document.source = source.replace('Old', 'Old'); + document.graph!.nodes[0].label = 'New'; + const output = graphMLDocumentConverter.to(document); + expect(output).toBe(document.source.replace('Old', 'New')); + expect(fromGraphML(output).nodes.map((node) => node.id)).toEqual(['b', 'new', 'a']); +}); +it('merges native GEXF node additions with a Graph label edit', () => { + const document = gexfDocumentConverter.from(fixtures[1].source); + document.source = document.source.replace('', ''); + document.graph!.nodes[0].label = 'New'; + expect(gexfDocumentConverter.to(document)).toBe(document.source.replace('Old', 'New')); +}); +it('merges native DOT node additions with a Graph label edit', () => { + const document = dotDocumentConverter.from(fixtures[2].source); + document.source = document.source.replace(' b;', ' new; b;'); + document.graph!.nodes[0].label = 'New'; + expect(dotDocumentConverter.to(document)).toBe(document.source.replace('Old', 'New')); +}); +it('merges native GML node additions with a Graph label edit', () => { + const document = gmlDocumentConverter.from(fixtures[3].source); + document.source = document.source.replace(' node [ id "b" ]', ' node [ id "new" ] node [ id "b" ]'); + document.graph!.nodes[0].label = 'New'; + expect(gmlDocumentConverter.to(document)).toBe(document.source.replace('Old', 'New')); +}); +it('ignores a core-looking graph nested inside an unknown GraphML data subtree', () => { + const source = 'OldFake'; + const document = graphMLDocumentConverter.from(source); + document.graph!.nodes[0].label = 'New'; + expect(graphMLDocumentConverter.to(document)).toBe(source.replace('Old', 'New')); +}); + +it('inserts a missing GraphML label through its existing scoped key', () => { + const source = 'Old'; + const document = graphMLDocumentConverter.from(source); + document.graph!.nodes[0].label = 'New'; + const output = graphMLDocumentConverter.to(document); + expect(output).toBe(source.replace('', 'New')); + expect(fromGraphML(output).nodes[1].label).toBe('Old'); +}); +it('edits actual nested GEXF labels without touching vendor-node lookalikes', () => { + const source = ''; + const document = gexfDocumentConverter.from(source); + document.graph!.nodes.find((node) => node.id === 'a')!.label = 'New'; + expect(gexfDocumentConverter.to(document)).toBe(source.replace('Old', 'New')); +}); +it.each(fixtures)('$name rejects nullable label changes instead of replaying original source', (fixture) => { + const document = fixture.converter.from(fixture.source); + document.graph!.nodes[0].label = null; + expect(() => fixture.converter.to(document)).toThrow(/unsupported/); +}); +it('edits multiple independent native label spans in one output', () => { + for (const fixture of fixtures) { + const document = fixture.converter.from(fixture.source); + document.graph!.nodes[0].label = 'New'; + document.graph!.edges[0].label = 'New edge'; + expect(fixture.converter.to(document)).toBe(fixture.source.replace('Old', 'New').replace('Edge', 'New edge')); + } +}); + +const nullLabels = [ + { converter: graphMLDocumentConverter, from: fromGraphML, to: toGraphML, native: (source: string) => source.replace('', '').replace('', '') }, + { converter: gexfDocumentConverter, from: fromGEXF, to: toGEXF, native: (source: string) => source.replace(/ source.replace('a [', 'a [label="", ').replace('a -> b [', 'a -> b [label="", ') }, + { converter: gmlDocumentConverter, from: fromGML, to: toGML, native: (source: string) => source.replace('id "a"', 'id "a" label ""').replace('id "e"', 'id "e" label ""') }, +]; +for (const fixture of nullLabels) { + it(`${fixture.converter.format} native explicit empty label overrides Graph null metadata`, () => { + const source = fixture.to(createGraph({ nodes: [{ id: 'a', label: null }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', label: null }] })); + expect(fixture.from(source).nodes[0].label).toBeNull(); + expect(fixture.from(source).edges[0].label).toBeNull(); + const imported = fixture.from(fixture.native(source)); + expect(imported.nodes[0].label).toBe(''); + expect(imported.edges[0].label).toBe(''); + }); + it(`${fixture.converter.format} detects native empty versus Graph nonempty conflict`, () => { + const source = fixture.to(createGraph({ nodes: [{ id: 'a', label: null }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', label: null }] })); + const document = fixture.converter.from(source); + document.source = fixture.native(source); + document.graph!.nodes[0].label = 'Graph'; + expect(() => fixture.converter.to(document)).toThrow(/conflict/); + }); +} + +for (const fixture of nullLabels) it(`${fixture.converter.format} patches a Graph null label to explicit native empty`, () => { + const source = fixture.to(createGraph({ nodes: [{ id: 'a', label: null }], edges: [] })); + const document = fixture.converter.from(source); + document.graph!.nodes[0].label = ''; + expect(fixture.from(fixture.converter.to(document)).nodes[0].label).toBe(''); +}); +it('DOT native empty inherited defaults override null markers on nodes and edges', () => { + const source = toDOT(createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }] })); + const graph = fromDOT(source.replace('{', '{ node[label=""]; edge[label=""];')); + expect(graph.nodes[0].label).toBe(''); + expect(graph.edges[0].label).toBe(''); +}); +it('GraphML native empty label defaults override null metadata', () => { + const source = toGraphML(createGraph({ nodes: [{ id: 'a' }] })); + const graph = fromGraphML(source.replace('', '')); + expect(graph.nodes[0].label).toBe(''); +}); +it('DOT compound native empty labels override private null metadata', () => { + const source = toDOT(createGraph({ nodes: [{ id: 'parent' }, { id: 'child', parentId: 'parent' }] })); + const graph = fromDOT(source.replace('subgraph parent {', 'subgraph parent { label="";')); + expect(graph.nodes.find((node) => node.id === 'parent')!.label).toBe(''); +}); + +it('resolves GraphML numeric entity IDs and preserves escaped entity literals', () => { + const source = 'Old &#65;'; + const document = graphMLDocumentConverter.from(source); + expect(document.graph!.nodes[0].id).toBe('a'); + expect(document.graph!.nodes[0].label).toBe('Old A'); + document.graph!.nodes[0].label = 'New A'; + expect(graphMLDocumentConverter.to(document)).toBe(source.replace('Old &#65;', 'New &#65;')); +}); +it('resolves GEXF numeric entity IDs and safely patches attribute line breaks', () => { + const source = ''; + const document = gexfDocumentConverter.from(source); + expect(document.graph!.nodes[0].id).toBe('a'); + expect(document.graph!.nodes[0].label).toBe('Old'); + document.graph!.nodes[0].label = 'New\nline\tvalue\rend'; + const output = gexfDocumentConverter.to(document); + expect(output).toBe(source.replace('label="Old"', 'label="New line value end"')); + expect(fromGEXF(output).nodes[0].label).toBe('New\nline\tvalue\rend'); +}); + +it('preserves every GEXF graph without fabricating an empty single-graph projection', () => { + const source = ''; + const document = gexfDocumentConverter.from(source); + expect(document.graph).toBeNull(); + expect(document.diagnostics.some((item) => item.code === 'projection-unavailable')).toBe(true); + expect(gexfDocumentConverter.to(document)).toBe(source); +}); +it('preserves the complete DOT graph stream with an explicit first-graph diagnostic', () => { + const source = 'digraph First { a; } // middle\ngraph Second { b; }'; + const document = dotDocumentConverter.from(source); + expect(document.graph!.nodes.map((node) => node.id)).toEqual(['a']); + expect(document.diagnostics.some((item) => item.code === 'projection-multiple-graphs')).toBe(true); + expect(dotDocumentConverter.to(document)).toBe(source); +}); + +it('patches a self-closing GraphML empty label without replacing its attributes', () => { + const source = ''; + const document = graphMLDocumentConverter.from(source); + expect(document.graph!.nodes[0].label).toBe(''); + document.graph!.nodes[0].label = 'New'; + expect(graphMLDocumentConverter.to(document)).toBe(source.replace('', 'New')); +}); + +it('retains missing native DOT default-label state and rejects an ambiguous Graph edit', () => { + const source = 'digraph { a; }'; + const document = dotDocumentConverter.from(source); + expect(dotDocumentConverter.to(document)).toBe(source); + document.graph!.nodes[0].label = 'New'; + expect(() => dotDocumentConverter.to(document)).toThrow(/Missing native DOT labels/); +}); + +it('DOT document distinguishes missing native labels from explicit empty and detects presence conflicts', () => { + const document = dotDocumentConverter.from('digraph { a; }'); + expect(document.graph!.nodes[0].label).toBeNull(); + document.source = 'digraph { a [label=""]; }'; + document.graph!.nodes[0].label = 'Graph'; + expect(() => dotDocumentConverter.to(document)).toThrow(/conflict/); +}); +it('DOT document preserves deliberate implicitLabel-shaped private user data', () => { + const source = toDOT(createGraph({ nodes: [{ id: 'a', label: '', data: { dot: { implicitLabel: true } } }] })); + const document = dotDocumentConverter.from(source); + expect(document.graph!.nodes[0].label).toBe(''); + expect(document.graph!.nodes[0].data).toEqual({ dot: { implicitLabel: true } }); + expect(dotDocumentConverter.to(document)).toBe(source); +}); + +it('GML document distinguishes native missing/empty labels and safely inserts literal labels', () => { + const source = 'graph [ node [ id 0 ] ]'; + const document = gmlDocumentConverter.from(source); + expect(document.graph!.nodes[0].label).toBeNull(); + document.graph!.nodes[0].label = 'ADDED'; + expect(gmlDocumentConverter.to(document)).toBe('graph [ node [ id 0 label "ADDED" ] ]'); + const conflict = gmlDocumentConverter.from(source); + conflict.source = 'graph [ node [ id 0 label "" ] ]'; + conflict.graph!.nodes[0].label = 'CONFLICT'; + expect(() => gmlDocumentConverter.to(conflict)).toThrow(/conflict/); +}); + +it('GML document label edits encode native line whitespace and retain unrelated bytes', () => { + const source = '# KEEP\ngraph [ node [ id 0 label "a b" ] node [ id 1 ] edge [ id 2 source 0 target 1 label "OLD" ] ]'; + const document = gmlDocumentConverter.from(source); + expect(document.graph!.nodes[0].label).toBe('a\nb'); + expect(gmlDocumentConverter.to(JSON.parse(JSON.stringify(document)))).toBe(source); + document.graph!.nodes[0].label = 'N\rO\nP\tQ'; + document.graph!.edges[0].label = 'E\rF\nG\tH'; + const output = gmlDocumentConverter.to(document); + expect(output).toBe(source.replace('a b', 'N O P Q').replace('OLD', 'E F G H')); + const invalid = gmlDocumentConverter.from(source); + invalid.graph!.nodes[0].label = '\ud800'; + expect(() => gmlDocumentConverter.to(invalid)).toThrow(/Unicode scalar/); +}); + +it('chunked span edits preserve equal-offset insertion order and boundaries', () => { + expect(getEditedSource('abcd', [{ start: 2, end: 2, text: 'A' }, { start: 2, end: 2, text: 'B' }])).toBe('abBAcd'); + expect(getEditedSource('abcd', [{ start: 0, end: 0, text: '<' }, { start: 4, end: 4, text: '>' }, { start: 1, end: 3, text: 'X' }, { start: 1, end: 1, text: 'I' }])).toBe(''); + expect(getEditedSource('', [{ start: 0, end: 0, text: 'A' }, { start: 0, end: 0, text: 'B' }])).toBe('BA'); + expect(() => getEditedSource('abcd', [{ start: 1, end: 3, text: 'X' }, { start: 2, end: 2, text: 'I' }])).toThrow(/overlapping/); + expect(() => getEditedSource('abcd', [{ start: -1, end: 0, text: 'X' }])).toThrow(/invalid/); +}); + +it('DOT wire labels and IDs reject NUL/lone surrogates while escaped private JSON data survives', () => { + for (const value of ['A\u0000B', '\ud800']) { + expect(() => toDOT(createGraph({ nodes: [{ id: 'a', label: value }] }))).toThrow(/Unicode scalar/); + expect(() => toDOT(createGraph({ nodes: [{ id: value }] }))).toThrow(/Unicode scalar/); + const document = dotDocumentConverter.from('digraph { a [label="OLD"]; }'); document.graph!.nodes[0].label = value; + expect(() => dotDocumentConverter.to(document)).toThrow(/invalid-native-label/); + } + const source = toDOT(createGraph({ nodes: [{ id: 'a', label: 'A', data: { value: 'A\u0000B', surrogate: '\ud800' } }] })); + expect(source).not.toContain('\u0000'); expect(fromDOT(source).nodes[0].data).toEqual({ value: 'A\u0000B', surrogate: '\ud800' }); +}); diff --git a/tests/formats/document.test.ts b/tests/formats/document.test.ts new file mode 100644 index 0000000..5d500e1 --- /dev/null +++ b/tests/formats/document.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; +import { createGraph, createVisualGraph } from '../../src/graph'; +import { createGraphDocumentConverter, getGraphDocumentUpdate, type GraphDocumentPatchContext } from '../../src/formats/document'; +import { adjacencyListDocumentConverter } from '../../src/formats/adjacency-list'; +import type { Graph, VisualGraph } from '../../src/types'; + +interface NativeJSON { nodes: Array<{ id: string; label?: string; future?: string }>; future?: { revision: number } } +const codec = { + from(source: NativeJSON): Graph { return createGraph({ nodes: source.nodes.map((node) => ({ id: node.id, label: node.label })) }); }, + to(graph: Graph): NativeJSON { return { nodes: graph.nodes.map((node) => ({ id: node.id, ...(node.label != null && { label: node.label }) })) }; }, +}; + +function createTestConverter() { + return createGraphDocumentConverter({ + format: 'test-json', codec, + patch(context: GraphDocumentPatchContext) { + if (context.changes.some((change) => change.entity !== 'node' || change.field !== 'label')) return { ok: false as const, diagnostics: [{ code: 'unsupported-edit', message: 'Only node labels are supported', severity: 'error' as const }] }; + const source = context.source; + for (const change of context.changes) { + const node = source.nodes.find((entry) => entry.id === change.id)!; + if (change.afterPresent && typeof change.after === 'string') node.label = change.after; + else delete node.label; + } + return { ok: true as const, source }; + }, + }); +} + +describe('GraphDocument core', () => { + it('returns exact native text after JSON persistence without assuming semantic completeness', () => { + const converter = createGraphDocumentConverter({ format: 'text', codec: { from: (_source: string) => createGraph({}), to: (_graph: Graph) => 'regenerated' } }); + const source = '# copyright\r\nunknown syntax\t\n'; + const document = converter.from(source); + expect(document.diagnostics).toContainEqual(expect.objectContaining({ code: 'projection-partial' })); + expect(converter.to(JSON.parse(JSON.stringify(document)))).toBe(source); + }); + + it('stores independent exact native JSON and graph baselines', () => { + const converter = createTestConverter(); + const source: NativeJSON = { nodes: [{ id: 'a', label: 'Before', future: 'extension' }], future: { revision: 1 } }; + const document = converter.from(source); + source.nodes[0].label = 'Outside mutation'; + source.future!.revision = 2; + expect(document.source.nodes[0].label).toBe('Before'); + document.graph!.nodes[0].label = 'After'; + expect(document.baseline.graph!.nodes[0].label).toBe('Before'); + expect(converter.to(JSON.parse(JSON.stringify(document)))).toEqual({ nodes: [{ id: 'a', label: 'After', future: 'extension' }], future: { revision: 1 } }); + }); + + it('merges independent source extension and Graph label edits', () => { + const converter = createTestConverter(); + const document = converter.from({ nodes: [{ id: 'a', label: 'Before' }], future: { revision: 1 } }); + document.source.future!.revision = 2; + document.graph!.nodes[0].label = 'After'; + expect(converter.to(document)).toEqual({ nodes: [{ id: 'a', label: 'After' }], future: { revision: 2 } }); + }); + + it('native source edits remain authoritative when Graph is unchanged', () => { + const converter = createTestConverter(); + const document = converter.from({ nodes: [{ id: 'a', label: 'Before' }] }); + document.source.nodes[0].label = 'Native edit'; + expect(converter.to(document).nodes[0].label).toBe('Native edit'); + }); + + it('diagnoses conflicting mapped edits with stable entity paths', () => { + const converter = createTestConverter(); + const document = converter.from({ nodes: [{ id: 'a/b', label: 'Before' }] }); + document.source.nodes[0].label = 'Native'; + document.graph!.nodes[0].label = 'Graph'; + expect(getGraphDocumentUpdate(document, converter)).toEqual({ ok: false, diagnostics: [expect.objectContaining({ code: 'conflicting-edit', path: '/nodes/a~1b/label' })] }); + expect(() => converter.to(document)).toThrow('conflicting-edit'); + }); + + it('merges mapped edits on different entities rather than replaying a stale projection', () => { + const converter = createTestConverter(); + const document = converter.from({ nodes: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] }); + document.source.nodes[0].label = 'Native A'; + document.graph!.nodes[1].label = 'Graph B'; + expect(converter.to(document).nodes.map((node) => node.label)).toEqual(['Native A', 'Graph B']); + }); + + it('rejects structural edits and absent patch policies by default', () => { + const converter = createTestConverter(); + const document = converter.from({ nodes: [{ id: 'a' }] }); + document.graph!.nodes.push({ type: 'node', id: 'b', label: 'B', data: null }); + expect(() => converter.to(document)).toThrow('unsupported-edit'); + const withoutPatch = createGraphDocumentConverter({ format: 'test-json', codec }); + const labelEdit = withoutPatch.from({ nodes: [{ id: 'a' }] }); + labelEdit.graph!.nodes[0].label = 'After'; + expect(() => withoutPatch.to(labelEdit)).toThrow('unsupported-edit'); + expect(getGraphDocumentUpdate(labelEdit, withoutPatch, { mode: 'regenerate' })).toMatchObject({ ok: true, diagnostics: [{ code: 'lossy-regeneration' }] }); + }); + + it('rejects a patch that ignores requested edits through post-patch projection proof', () => { + const converter = createGraphDocumentConverter({ format: 'test-json', codec, patch: (context) => ({ ok: true, source: context.source }) }); + const document = converter.from({ nodes: [{ id: 'a', label: 'Before' }] }); + document.graph!.nodes[0].label = 'After'; + expect(() => converter.to(document)).toThrow('patch-verification-failed'); + }); + + it('preserves unavailable projections and strict import errors explicitly', () => { + const converter = createGraphDocumentConverter({ format: 'hypergraph', codec: { from: (_source: string): Graph => { throw new Error('Hyperedge has three endpoints'); }, to: (_graph: Graph) => '' } }); + const source = '# comment\nhyperedge a,b,c'; + const document = converter.from(source); + expect(document.graph).toBeNull(); + expect(document.diagnostics[0].code).toBe('projection-unavailable'); + expect(converter.to(JSON.parse(JSON.stringify(document)))).toBe(source); + expect(() => converter.from(source, { strict: true })).toThrow('Hyperedge'); + }); + + it.each([NaN, Infinity, -Infinity, () => {}, Symbol('invalid'), 1n, new Date(), { value: undefined }, [undefined]])('rejects non-JSON native values %s', (value) => { + const converter = createGraphDocumentConverter({ format: 'json', codec: { from: (_source: unknown) => createGraph({}), to: (_graph: Graph) => null } }); + expect(() => converter.from(value)).toThrow('invalid-json-value'); + }); + + it('rejects cycles/accessors/sparse arrays before silently losing source information', () => { + const converter = createGraphDocumentConverter({ format: 'json', codec: { from: (_source: unknown) => createGraph({}), to: (_graph: Graph) => null } }); + const cycle: { self?: object } = {}; cycle.self = cycle; + expect(() => converter.from(cycle)).toThrow('Cyclic'); + expect(() => converter.from({ get value() { throw new Error('Getter should never run'); } })).toThrow('Accessors'); + expect(() => converter.from(new Array(2))).toThrow('Sparse'); + }); + + it('compares Graph optional undefined using its JSON view', () => { + const converter = createTestConverter(); + const document = converter.from({ nodes: [{ id: 'a' }] }); + document.graph!.nodes[0].parentId = undefined; + expect(converter.to(JSON.parse(JSON.stringify(document)))).toEqual({ nodes: [{ id: 'a' }] }); + }); + + it('validates envelope and baseline provenance rather than trusting forged snapshots', () => { + const converter = createTestConverter(); + const document = converter.from({ nodes: [{ id: 'a', label: 'Before' }] }); + document.baseline.graph!.nodes[0].label = 'Forged'; + expect(() => converter.to(document)).toThrow('invalid-baseline'); + document.version = 2 as 1; + expect(() => converter.to(document)).toThrow('invalid-document'); + }); + + it('infers VisualGraph codecs without weakening the projected type', () => { + const converter = createGraphDocumentConverter({ format: 'visual', codec: { + from: (_source: string): VisualGraph => createVisualGraph({ nodes: [{ id: 'a', x: 1, y: 2, width: 3, height: 4 }] }), + to: (_graph: VisualGraph) => 'visual', + } }); + const document = converter.from('visual'); + const width: number = document.graph!.nodes[0].width; + expect(width).toBe(3); + }); + it('updates ten thousand labels with independent native changes', () => { + const converter = createGraphDocumentConverter({ format: 'bulk', codec, patch(context) { + const labels = new Map(context.changes.map((change) => [change.id, change.after])); + for (const node of context.source.nodes) if (labels.has(node.id)) node.label = labels.get(node.id) as string; + return { ok: true, source: context.source }; + } }); + const document = converter.from({ nodes: Array.from({ length: 10_000 }, (_, index) => ({ id: `n${index}`, label: `Before ${index}` })) }); + document.source.future = { revision: 2 }; + for (let index = 0; index < document.graph!.nodes.length; index++) document.graph!.nodes[index].label = `After ${index}`; + const result = converter.to(document); + expect(result.future).toEqual({ revision: 2 }); + expect(result.nodes.map((node) => node.label)).toEqual(Array.from({ length: 10_000 }, (_, index) => `After ${index}`)); + }); + + it('rejects private state edits even alongside user data edits in the same field', () => { + const converter = createGraphDocumentConverter({ format: 'state', codec: { + from: (source: { value: string }) => createGraph({ nodes: [{ id: 'a', data: { user: source.value, private: { token: 1 } } }] }), + to: (_graph: Graph) => ({ value: 'generated' }), + }, getComparisonGraph(graph) { + for (const node of graph.nodes) node.data = { user: node.data.user }; + return graph; + }, getComparisonState(graph) { return graph.nodes.map((node) => node.data.private); } }); + const document = converter.from({ value: 'Before' }); + document.graph!.nodes[0].data.user = 'After'; + document.graph!.nodes[0].data.private.token = 2; + expect(() => converter.to(document)).toThrow('unsupported-state-edit'); + }); + + it('rejects edited adapter state even alongside a supported Graph edit', () => { + const converter = createGraphDocumentConverter({ format: 'adapter-state', codec, + project(source: NativeJSON) { return { graph: codec.from(source), state: { revision: 1 } }; }, + patch(context) { return { ok: true, source: context.source }; }, + }); + const document = converter.from({ nodes: [{ id: 'a', label: 'Before' }] }); + document.state = { revision: 2 }; + document.graph!.nodes[0].label = 'After'; + expect(() => converter.to(document)).toThrow('unsupported-state-edit'); + }); + + it('reports projection limitations on update results and explicit null projections', () => { + const converter = createTestConverter(); + expect(converter.getUpdate(converter.from({ nodes: [] }))).toMatchObject({ ok: true, diagnostics: [expect.objectContaining({ code: 'projection-partial' })] }); + const unavailable = createGraphDocumentConverter({ format: 'unavailable', codec, project: () => ({ graph: null }) }); + const document = unavailable.from({ nodes: [] }); + expect(document.diagnostics).toContainEqual(expect.objectContaining({ code: 'projection-unavailable' })); + expect(() => unavailable.from({ nodes: [] }, { strict: true })).toThrow('projection-unavailable'); + }); + + it('discloses requested Graph label loss when explicitly regenerating adjacency source', () => { + const document = adjacencyListDocumentConverter.from({ a: ['b'] }); + document.graph!.nodes[0].label = 'Requested label'; + expect(() => adjacencyListDocumentConverter.to(document)).toThrow('unsupported-edit'); + const result = adjacencyListDocumentConverter.getUpdate(document, { mode: 'regenerate' }); + expect(result).toMatchObject({ ok: true, diagnostics: expect.arrayContaining([ + expect.objectContaining({ code: 'lossy-regeneration', severity: 'warning' }), + expect.objectContaining({ code: 'graph-regeneration-loss', path: '/nodes/a/label', severity: 'warning' }), + ]) }); + }); + + it('discloses an unavailable generated projection from a broken codec', () => { + const converter = createGraphDocumentConverter({ format: 'broken', codec: { + from(source: string) { if (source === 'broken') throw new Error('Invalid generated syntax'); return createGraph({ nodes: [{ id: 'a', label: 'Before' }] }); }, + to(_graph: Graph) { return 'broken'; }, + } }); + const document = converter.from('valid'); + document.graph!.nodes[0].label = 'After'; + expect(() => converter.to(document)).toThrow('unsupported-edit'); + expect(converter.getUpdate(document, { mode: 'regenerate' })).toMatchObject({ ok: true, source: 'broken', diagnostics: expect.arrayContaining([ + expect.objectContaining({ code: 'graph-regeneration-unavailable', severity: 'warning', message: expect.stringContaining('Invalid generated syntax') }), + ]) }); + }); + + it('reports known Graph data loss from a codec that drops requested fields', () => { + const converter = createGraphDocumentConverter({ format: 'drops-data', codec }); + const document = converter.from({ nodes: [{ id: 'a', label: 'Before' }] }); + document.graph!.nodes[0].data = { requested: 'retain' }; + expect(converter.getUpdate(document, { mode: 'regenerate' })).toMatchObject({ ok: true, diagnostics: expect.arrayContaining([ + expect.objectContaining({ code: 'graph-regeneration-loss', path: '/nodes/a/data', severity: 'warning' }), + ]) }); + }); + +}); diff --git a/tests/formats/dot.test.ts b/tests/formats/dot.test.ts index 62a943f..e8e2181 100644 --- a/tests/formats/dot.test.ts +++ b/tests/formats/dot.test.ts @@ -20,7 +20,7 @@ describe('toDOT', () => { const dot = toDOT(g); expect(dot).toContain('digraph test'); expect(dot).toContain('a [label="Node A"]'); - expect(dot).toContain('a -> b [label="goes to"]'); + expect(fromDOT(dot).edges[0]).toMatchObject({id:'e1',sourceId:'a',targetId:'b',label:'goes to'}); }); it('exports undirected graph', () => { diff --git a/tests/formats/drawio.test.ts b/tests/formats/drawio.test.ts new file mode 100644 index 0000000..242ddea --- /dev/null +++ b/tests/formats/drawio.test.ts @@ -0,0 +1,93 @@ +import { getUserData } from '../../src/formats/xml-preservation'; +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { fromDrawio, toDrawio, drawioDocumentConverter, createDrawioDocumentConverter } from '../../src/formats/drawio'; +import { getCarrierData } from '../../src/formats/preservation'; +const fixture = (name: string) => readFileSync(new URL(`../native/fixtures/drawio/${name}`, import.meta.url), 'utf8'); +function getGraph(graph: ReturnType) { return { ...graph, data: getCarrierData(graph.data, 'drawio') }; } +const single = ''; +describe('draw.io selected-page XML', () => { + it.each([false, true])('preserves selected Graph fields with compressed=%s and reserved-ID collisions', (compressed) => { + const graph = createGraph({ id: '', mode: 'undirected', data: { stately: 'USER', __statelyaiNative: { user: true } }, nodes: [{ id: '0', label: null, data: 42, x: 1, y: 2, width: 3, height: 4 }, { id: '1', parentId: '0', label: '', style: { rounded: true } }], edges: [{ id: '0', sourceId: '0', targetId: '1', label: 'A\rB\nC\tD', weight: 2.5, points: [{ x: 10, y: 20 }], data: [null, false] }] }); + const source = toDrawio(graph, { compressed }); + expect(getGraph(fromDrawio(source))).toEqual(graph); + expect(toDrawio(JSON.parse(JSON.stringify(fromDrawio(source))))).toBe(source); + }); + it.each(['pages-uncompressed.drawio', 'pages-compressed.drawio'])('requires explicit page selection and preserves all %s source', (name) => { + const source = fixture(name); + expect(() => fromDrawio(source)).toThrow(/pageId/); + expect(drawioDocumentConverter.from(source).graph).toBeNull(); + expect(drawioDocumentConverter.to(JSON.parse(JSON.stringify(drawioDocumentConverter.from(source))))).toBe(source); + const converter = createDrawioDocumentConverter({ pageId: 'first' }), document = converter.from(source); + expect(document.graph!.nodes).toHaveLength(3); + expect(converter.to(JSON.parse(JSON.stringify(document)))).toBe(source); + expect(fromDrawio(source, { pageId: 'second' }).nodes).toHaveLength(1); + }); + it('retains layers, raw HTML/custom attributes, grouped geometry and relative edge label offsets', () => { + const graph = fromDrawio(fixture('pages-uncompressed.drawio'), { pageId: 'first' }); + expect(graph.nodes.map((node) => node.id)).toEqual(['group', 'a', 'b']); + expect(graph.nodes[1]).toMatchObject({ parentId: 'group', label: 'Alpha & α', x: 20, y: 30, data: { user: { vendor: 'KEEP', count: '007' } } }); + expect(graph.edges[0].x).toBeUndefined(); + expect(graph.edges[0].data.labelGeometry).toMatchObject({ x: '0.5', y: '-12', relative: '1' }); + expect(graph.edges[0].points).toEqual([{ x: 250, y: 140 }, { x: 400, y: 140 }]); + }); + it('defaults to directed mode without inferring decorative arrowheads', () => { + expect(fromDrawio(single).mode).toBe('directed'); + expect(fromDrawio(single).edges[0].mode).toBe('directed'); + expect(fromDrawio(single, { mode: 'infer-arrows' }).edges[0].mode).toBe('undirected'); + expect(fromDrawio(single, { mode: 'undirected' }).mode).toBe('undirected'); + const backward = single.replace('endArrow=none;', 'startArrow=classic;endArrow=none;'); + expect(fromDrawio(backward, { mode: 'infer-arrows' }).edges[0]).toMatchObject({ sourceId: 'b', targetId: 'a', mode: 'directed' }); + }); + it('patches labels and independent source comments/vendor changes after persistence', () => { + const document = JSON.parse(JSON.stringify(drawioDocumentConverter.from(single))); + document.source = document.source.replace('OLD', 'OLD').replace('', '').replace('vertex="1" parent="1" style="html=0;"', 'vertex="1" parent="1" style="html=0;" vendor="KEEP"'); + document.graph.nodes[0].label = 'NEW & \n'; document.graph.edges[0].label = ''; + const source = drawioDocumentConverter.to(document); + expect(source).toContain(''); expect(source).toContain('vendor="KEEP"'); + expect(source).toContain('value="NEW & <literal> "'); expect(source).toContain('id="e" value=""'); + }); + it('patches only the selected compressed page payload', () => { + const source = fixture('pages-compressed.drawio'), converter = createDrawioDocumentConverter({ pageId: 'first' }), document = converter.from(source); + document.graph!.nodes.find((node) => node.id === 'b')!.label = 'SELECTED'; + const output = converter.to(document); + expect(output.slice(output.indexOf(' node.id === 'b')!.label).toBe('SELECTED'); + }); + it('keeps native field edits authoritative over private metadata', () => { + const source = toDrawio(createGraph({ nodes: [{ id: 'a', label: null }] })); + const edited = source.replace(' drawioDocumentConverter.to(doc)).toThrow(/conflict/); + }); + it('rejects invalid XML label edits and ambiguous HTML/placeholder labels', () => { + for (const label of ['A\u0000B', '\ud800']) { const doc = drawioDocumentConverter.from(single); doc.graph!.nodes[0].label = label; expect(() => drawioDocumentConverter.to(doc)).toThrow(/invalid-native-label/); } + for (const source of [single.replace('html=0;', 'html=1;'), single.replace('value="OLD"', 'value="OLD" placeholders="1"')]) { const doc = drawioDocumentConverter.from(source); doc.graph!.nodes[0].label = 'GRAPH'; expect(() => drawioDocumentConverter.to(doc)).toThrow(/ambiguous/); } + }); + it('ignores fake cell identities inside comments/CDATA/vendor subtrees', () => { + const source = single.replace('', ']]>'); + const doc = drawioDocumentConverter.from(source); doc.graph!.nodes[0].label = 'REAL'; + expect(drawioDocumentConverter.to(doc)).toContain('id="a" value="REAL"'); + }); + it.each([single.replace('id="b"', 'id="a"'), single.replace('target="b"', 'target="missing"'), single.replace('id="a" value="OLD"', 'id="a" value=" "'), single.replace('parent="1" style="html=0;"', 'parent="a" style="html=0;"'), ''])('rejects unsupported or malformed topology/identity/XML', (source) => { + expect(() => fromDrawio(source)).toThrow(); + expect(drawioDocumentConverter.from(source).graph).toBeNull(); + }); +}); + +it('retains primitive user data alongside independently added native UserObject attributes', () => { + const document = drawioDocumentConverter.from(toDrawio(createGraph({ nodes: [{ id: 'a', label: 'OLD', data: 42 }] }))); + document.source = document.source.replace('label="OLD"', 'label="OLD" vendor="KEEP"'); document.graph!.nodes[0].label = 'NEW'; + const graph = fromDrawio(drawioDocumentConverter.to(document)); + expect(graph.nodes[0].label).toBe('NEW'); expect(getUserData(graph.nodes[0].data, 'drawio')).toBe(42); + expect(graph.nodes[0].data._drawio.native.user.vendor).toBe('KEEP'); +}); +it('patches 10,000 literal labels with verified non-overlapping spans', () => { + const graph = createGraph({ nodes: Array.from({ length: 10000 }, (_, index) => ({ id: String(index), label: 'OLD' })) }); + const document = drawioDocumentConverter.from(toDrawio(graph)); + for (const node of document.graph!.nodes) node.label = 'NEW'; + const output = drawioDocumentConverter.to(document), restored = fromDrawio(output); + expect(restored.nodes).toHaveLength(10000); expect(restored.nodes.every((node) => node.label === 'NEW')).toBe(true); +}, 30000); diff --git a/tests/formats/fidelity-diagrams.test.ts b/tests/formats/fidelity-diagrams.test.ts new file mode 100644 index 0000000..a26a8b9 --- /dev/null +++ b/tests/formats/fidelity-diagrams.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { getEdgeMode } from '../../src/mode'; +import { fromD2, toD2 } from '../../src/formats/d2'; +import { + fromMermaidBlock, + fromMermaidER, + fromMermaidFlowchart, + fromMermaidSequence, + fromMermaidClass, + fromMermaidMindmap, + fromMermaidState, + fromMermaidIshikawa, + toMermaidBlock, + toMermaidClass, + toMermaidMindmap, + toMermaidState, + toMermaidIshikawa, + toMermaidER, + toMermaidFlowchart, + toMermaidSequence, +} from '../../src/formats/mermaid'; + +describe('fidelity regressions: diagram adapters', () => { + it('D2 uses inherited undirected graph mode when no edge override exists', () => { + const original = createGraph({ + mode: 'undirected', nodes: [{ id: 'a' }, { id: 'b' }], + edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }], + }); + const restored = fromD2(toD2(original)); + expect(getEdgeMode(restored, restored.edges[0])).toBe('undirected'); + }); + + it('D2 retains inline comments on the first source round trip', () => { + const emitted = toD2(fromD2('a: A # keep-inline\n# following-node\nb: B\n')); + expect(emitted).toContain('# keep-inline'); + expect(emitted).toContain('# following-node'); + }); + + it('Mermaid sequence emits one spanning note across repeated round trips', () => { + let source = 'sequenceDiagram\nparticipant A\nparticipant B\nA->>B: Before\nNote over A,B: shared\nB->>A: After'; + for (let pass = 0; pass < 3; pass++) { + source = toMermaidSequence(fromMermaidSequence(source)); + expect(source.match(/Note over A,B: shared/g), `pass ${pass + 1}`).toHaveLength(1); + } + }); + + it('Mermaid sequence retains a note between its surrounding messages', () => { + const source = toMermaidSequence(fromMermaidSequence( + 'sequenceDiagram\nparticipant A\nparticipant B\nA->>B: Before\nNote over A,B: shared\nB->>A: After', + )); + expect(source.indexOf('Before')).toBeLessThan(source.indexOf('Note over A,B: shared')); + expect(source.indexOf('Note over A,B: shared')).toBeLessThan(source.indexOf('After')); + }); + + it('Mermaid flowchart retains native node styles', () => { + const graph = fromMermaidFlowchart('flowchart TD\nA --- B\nstyle A fill:red,stroke:blue'); + expect(graph.nodes.find((node) => node.id === 'A')?.style).toEqual({ fill: 'red', stroke: 'blue' }); + const restored = fromMermaidFlowchart(toMermaidFlowchart(graph)); + expect(restored.nodes.find((node) => node.id === 'A')?.style).toEqual({ fill: 'red', stroke: 'blue' }); + }); + + it('Mermaid ER retains isolated entity declarations', () => { + const graph = fromMermaidER('erDiagram\nLONELY'); + expect(graph.nodes.map((node) => node.id)).toEqual(['LONELY']); + const restored = fromMermaidER(toMermaidER(graph)); + expect(restored.nodes.map((node) => node.id)).toEqual(['LONELY']); + }); + + it('Mermaid block parses adjacent quoted labels containing spaces', () => { + const graph = fromMermaidBlock('block-beta\na["Hello world"] b["Second node"]'); + expect(graph.nodes.map((node) => ({ id: node.id, label: node.label }))).toEqual([ + { id: 'a', label: 'Hello world' }, { id: 'b', label: 'Second node' }, + ]); + }); + + it('canonical mode edits override authored D2 and Mermaid connectors', () => { + for (const [from, to, source] of [ + [fromD2, toD2, 'a -> b'], + [fromMermaidFlowchart, toMermaidFlowchart, 'flowchart LR\na --> b'], + [fromMermaidBlock, toMermaidBlock, 'block-beta\na --> b'], + ] as const) { + const graph = from(source); + graph.edges[0].mode = 'undirected'; + const restored = from(to(graph as never)); + expect(getEdgeMode(restored, restored.edges[0])).toBe('undirected'); + } + }); + + it('D2 distinguishes literal dotted keys from hierarchy and resolves ancestor references', () => { + const graph = fromD2('"a.b": Literal\na.b: Nested\nouter: {\na\ninner: {\nb -> _.a\n}\n}'); + expect(graph.nodes.filter((node) => node.label === 'Literal')).toHaveLength(1); + expect(graph.nodes.filter((node) => node.label === 'Nested')).toHaveLength(1); + expect(graph.edges[0].targetId).toBe('outer.a'); + const restored = fromD2(toD2(graph)); + expect(restored.nodes.filter((node) => node.label === 'Literal')).toHaveLength(1); + expect(restored.nodes.filter((node) => node.label === 'Nested')).toHaveLength(1); + expect(restored.edges[0].targetId).toBe('outer.a'); + }); + + it('D2 resolves indexed connection attributes and applies chain labels to every edge', () => { + const graph = fromD2('a -> b -> c: shared\n(a -> b)[0].style.stroke: red'); + expect(graph.edges.map((edge) => edge.label)).toEqual(['shared', 'shared']); + expect(graph.edges[0].style?.stroke).toBe('red'); + expect(graph.nodes.map((node) => node.id)).toEqual(['a', 'b', 'c']); + }); + + it('D2 retains recursive variables, substitution expressions and scoped spread imports', () => { + const source = 'vars: {\nsettings: {\ncolor: red\n}\n}\na: ${settings.color}\ncontainer: {\n...@base\nx\n}'; + const graph = fromD2(source); + expect(graph.data.source?.vars?.settings).toEqual({ color: 'red' }); + const out = toD2(graph); + expect(out).toContain('${settings.color}'); + expect(out).toMatch(/container: \{[\s\S]*\.\.\.@base/); + expect(fromD2(out).data.source?.vars?.settings).toEqual({ color: 'red' }); + }); + + it('D2 preserves structured common attributes, class properties and arrowhead labels/styles', () => { + const source = 'classes: {\nred: {\nshape: circle\nstyle: {\nfill: red\n}\n}\n}\nt: {\nshape: sql_table\nlabel: Users\nwidth: 200\nstyle.fill: red\nid: int\n}\nt -> other: {\nsource-arrowhead: {\nshape: circle\nlabel: From\nstyle.filled: true\n}\n}'; + const first = fromD2(source); + const table = first.nodes.find((node) => node.id === 't')!; + expect(table.label).toBe('Users'); + expect(table.width).toBe(200); + expect(table.ports?.map((port) => port.name)).toEqual(['id']); + const restored = fromD2(toD2(first)); + expect(restored.nodes.find((node) => node.id === 't')?.style?.fill).toBe('red'); + expect(restored.edges[0].data.sourceArrowhead).toMatchObject({ shape: 'circle', label: 'From', style: { filled: true } }); + expect(restored.data.source?.classes).toEqual(first.data.source?.classes); + }); + + it('flowchart keeps expanded shape/property and explicit edge identity while respecting redeclarations', () => { + const graph = fromMermaidFlowchart('flowchart LR\nA@{ shape: cloud, label: "First", icon: "fa:cloud" }\nA@{ shape: cloud, label: "Last", icon: "fa:cloud" }\nA e1@----> B\ne1@{ animate: true, curve: stepBefore }'); + expect(graph.nodes.find((node) => node.id === 'A')?.label).toBe('Last'); + expect(graph.edges[0].id).toBe('e1'); + const restored = fromMermaidFlowchart(toMermaidFlowchart(graph)); + expect(restored.nodes.find((node) => node.id === 'A')?.shape).toBe('cloud'); + expect(restored.nodes.find((node) => node.id === 'A')?.label).toBe('Last'); + expect(restored.edges[0].id).toBe('e1'); + expect(toMermaidFlowchart(restored)).toContain('fa:cloud'); + expect(toMermaidFlowchart(restored)).toContain('animate: true'); + }); + + it('sequence retains official participant configuration and lifecycle event position', () => { + const source = 'sequenceDiagram\nautonumber 10 5\nparticipant A\nparticipant B@{ "type": "database", "alias": "Storage" }\nA->>B: first\ncreate participant C\nB->>C: start\ndestroy C\nC-xB: end'; + const graph = fromMermaidSequence(source); + expect(graph.nodes.find((node) => node.id === 'B')?.data.actorType).toBe('database'); + expect(graph.nodes.find((node) => node.id === 'B')?.label).toBe('Storage'); + const out = toMermaidSequence(graph); + expect(out.indexOf('first')).toBeLessThan(out.indexOf('create participant C')); + expect(out.indexOf('start')).toBeLessThan(out.indexOf('destroy C')); + expect(out).toContain('autonumber 10 5'); + expect(fromMermaidSequence(out).nodes.find((node) => node.id === 'C')?.data.destroyed).toBe(true); + }); + + it('ER retains multiple attribute constraints', () => { + const graph = fromMermaidER('erDiagram\nENTITY {\nint id PK, FK "reference"\n}'); + expect(graph.nodes[0].data.attributes).toHaveLength(1); + const out = toMermaidER(graph); + expect(out).toContain('PK, FK'); + expect(fromMermaidER(out).nodes[0].data.attributes).toEqual(graph.nodes[0].data.attributes); + }); + + it('block retains shapes and scoped column counts', () => { + const graph = fromMermaidBlock('block-beta\ncolumns 3\nblock:group\ncolumns 2\na((Circle)) b["Second node"]\nend\nc'); + expect(graph.data.columns).toBe(3); + const restored = fromMermaidBlock(toMermaidBlock(graph)); + expect(restored.nodes.find((node) => node.id === 'a')?.shape).toBe('circle'); + expect(restored.nodes.find((node) => node.id === 'b')?.label).toBe('Second node'); + expect(restored.nodes.find((node) => node.id === 'group')?.data).toMatchObject({ columns: 2 }); + }); + + it('class retains namespaces, display aliases, notes and lollipop relation identity', () => { + const graph = fromMermaidClass('classDiagram\nnamespace Base {\nclass A["Human label"]\n}\nnote for A "Helpful note"\nA --() Interface'); + expect(graph.nodes.find((node) => node.id === 'A')?.parentId).toBe('Base'); + expect(graph.nodes.find((node) => node.id === 'A')?.label).toBe('Human label'); + const restored = fromMermaidClass(toMermaidClass(graph)); + expect(restored.nodes.find((node) => node.id === 'A')?.parentId).toBe('Base'); + expect(toMermaidClass(restored)).toContain('Helpful note'); + expect(restored.edges[0].targetId).toBe('Interface'); + }); + + it('mindmap preserves authored identifiers, shapes and classes', () => { + const graph = fromMermaidMindmap('mindmap\nroot((Root))\n child[Child]\n :::important'); + expect(graph.nodes.map((node) => node.id)).toEqual(['root', 'child']); + expect(graph.nodes[0].shape).toBe('circle'); + const restored = fromMermaidMindmap(toMermaidMindmap(graph)); + expect(restored.nodes.map((node) => node.id)).toEqual(['root', 'child']); + expect(restored.nodes[1].data).toMatchObject({ classes: ['important'] }); + }); + + it('state and tree labels decode the same escaping they emit', () => { + const state = fromMermaidState('stateDiagram-v2\nA\nnote right of A : A; B | C'); + expect(fromMermaidState(toMermaidState(state)).nodes[0].data.notes).toEqual(state.nodes[0].data.notes); + for (const [from, to, source] of [ + [fromMermaidMindmap, toMermaidMindmap, 'mindmap\nRoot\n A; B'], + [fromMermaidIshikawa, toMermaidIshikawa, 'ishikawa-beta\nRoot\n A; B'], + ] as const) { + const graph = from(source); + expect(from(to(graph as never)).nodes.map((node) => node.label)).toEqual(graph.nodes.map((node) => node.label)); + } + }); + + it('Mermaid metadata survives JSON persistence and all diagram codecs', () => { + const codecs = [ + [fromMermaidFlowchart, toMermaidFlowchart, 'flowchart LR\nA --> B'], + [fromMermaidState, toMermaidState, 'stateDiagram-v2\nA --> B'], + [fromMermaidSequence, toMermaidSequence, 'sequenceDiagram\nA->>B: hello'], + [fromMermaidClass, toMermaidClass, 'classDiagram\nA --> B'], + [fromMermaidER, toMermaidER, 'erDiagram\nA ||--|| B: label'], + [fromMermaidBlock, toMermaidBlock, 'block-beta\na b'], + [fromMermaidMindmap, toMermaidMindmap, 'mindmap\nRoot\n Child'], + [fromMermaidIshikawa, toMermaidIshikawa, 'ishikawa-beta\nProblem\n Cause'], + ] as const; + for (const [from, to, source] of codecs) { + const original = `---\ntitle: Source\nconfig:\n theme: forest\n---\n%%{init: {"theme":"forest"}}%%\n%% Copyright\n${source}`; + const graph = JSON.parse(JSON.stringify(from(original))); + const emitted = to(graph); + expect(emitted).toContain('title: Source'); + expect(emitted).toContain('%% Copyright'); + expect(emitted).toContain('"theme":"forest"'); + expect(from(emitted).nodes).toHaveLength(graph.nodes.length); + } + }); + + it('quoted block/property values with a trailing escaped backslash retain token boundaries', () => { + const block = fromMermaidBlock('block-beta\na["Path\\\\"] b["Second node"]'); + expect(block.nodes.map((node) => node.id)).toEqual(['a', 'b']); + expect(fromMermaidBlock(toMermaidBlock(block)).nodes.map((node) => node.label)).toEqual(block.nodes.map((node) => node.label)); + const flow = fromMermaidFlowchart('flowchart LR\nA@{ shape: cloud, label: "Path\\\\", icon: "fa:cloud" }'); + expect(flow.nodes[0].label).toBe('Path\\'); + expect(fromMermaidFlowchart(toMermaidFlowchart(flow)).nodes[0].label).toBe('Path\\'); + }); + + it('out-of-range numeric entities remain inspectable without throwing', () => { + const graph = fromMermaidFlowchart('flowchart LR\nA["#1114112; #999999999999999999999;"]'); + expect(graph.nodes[0].label).toBe('#1114112; #999999999999999999999;'); + }); + + it('sequence note edits use live data and missing event anchors fail explicitly', () => { + const graph = fromMermaidSequence('sequenceDiagram\nA->>B: first\nNote over A,B: before\nB->>A: last'); + graph.nodes.find((node) => node.id === 'A')!.data.notes![0].text = 'Edited'; + const out = toMermaidSequence(JSON.parse(JSON.stringify(graph))); + expect(out.match(/Note over A,B: Edited/g)).toHaveLength(1); + expect(out).not.toContain('before'); + graph.edges = []; + expect(() => toMermaidSequence(graph)).toThrow('event anchors'); + }); + + it('sequence note array reordering retains stable event identities, deletion fails explicitly', () => { + const graph = fromMermaidSequence('sequenceDiagram\nA->>B: first\nNote right of A: earlier\nB->>A: middle\nNote right of A: later\nA->>B: last'); + const notes = graph.nodes.find((node) => node.id === 'A')!.data.notes!; + notes.reverse(); + const out = toMermaidSequence(graph); + expect(out.indexOf('earlier')).toBeLessThan(out.indexOf('middle')); + expect(out.indexOf('middle')).toBeLessThan(out.indexOf('later')); + notes.splice(1, 1); + expect(() => toMermaidSequence(graph)).toThrow('ordered note no longer exists'); + }); + + it('sequence participant ID edits fail explicitly when lifecycle references are stale', () => { + const graph = fromMermaidSequence('sequenceDiagram\nA->>B: first\ncreate participant C\nB->>C: start\ndestroy C\nC-xB: end'); + graph.nodes.find((node) => node.id === 'C')!.id = 'Renamed'; + expect(() => toMermaidSequence(graph)).toThrow('event participant "C" not found'); + }); + + it('D2 unsupported boards and globs remain source metadata instead of fabricated nodes', () => { + const graph = fromD2('a\nb\n*.style.fill: red\nlayers: {\nother: {\nx -> y\n}\n}'); + expect(graph.nodes.map((node) => node.id)).toEqual(['a', 'b']); + expect(graph.data.source?.unknownStatements).toHaveLength(2); + }); + + it('D2 class arrays and scoped variables remain source expressions', () => { + const graph = fromD2('container: {\nvars: {\ncolor: red\n}\na: {\nclass: [red; blue]\n}\n}'); + expect(graph.nodes.find((node) => node.id === 'container.a')?.data.classes).toEqual(['red', 'blue']); + const out = toD2(graph); + expect(out).toContain('class: [red; blue]'); + expect(fromD2(out).nodes.find((node) => node.id === 'container')?.data.source?.vars).toEqual({ color: 'red' }); + }); + + it('D2 retains variable arrays and single-quoted substitution literals', () => { + const source = 'vars: {\nnames: [red; blue]\n}\na: \'${names}\''; + const graph = fromD2(source); + expect(graph.data.source?.vars?.names).toEqual(['red', 'blue']); + const out = toD2(graph); + expect(out).toContain("a: '${names}'"); + expect(fromD2(out).data.source?.vars?.names).toEqual(['red', 'blue']); + }); + + it('D2 quoted IDs with backslashes retain identity after serialization', () => { + const graph = fromD2('"a\\\\b.c": Literal\n"a\\\\b.c" -> target'); + const restored = fromD2(toD2(graph)); + expect(restored.nodes.map((node) => node.id)).toEqual(graph.nodes.map((node) => node.id)); + expect(restored.edges[0].sourceId).toBe(graph.edges[0].sourceId); + }); + + it('flowchart live stroke edits override authored connector glyph', () => { + const graph = fromMermaidFlowchart('flowchart LR\nA ----> B'); + graph.edges[0].data.stroke = 'dotted'; + const restored = fromMermaidFlowchart(toMermaidFlowchart(graph)); + expect(restored.edges[0].data.stroke).toBe('dotted'); + expect(restored.edges[0].sourceId).toBe('A'); + }); + + it('class keeps both endpoints of a two-sided relationship', () => { + const graph = fromMermaidClass('classDiagram\nA <|--|> B'); + expect(graph.nodes.map((node) => node.id)).toEqual(['A', 'B']); + const out = toMermaidClass(graph); + expect(out).toContain('A <|--|> B'); + expect(fromMermaidClass(out).edges[0].sourceId).toBe('A'); + expect(fromMermaidClass(out).edges[0].targetId).toBe('B'); + }); + + it('block retains row spans and styles without creating directive nodes', () => { + const graph = fromMermaidBlock('block-beta\na["Wide label"]:2 b["Other"]\nstyle a fill:red\ncolumns auto'); + expect(graph.nodes.map((node) => node.id)).toEqual(['a', 'b']); + expect(graph.nodes[0].data.span).toBe(2); + expect(graph.nodes[0].style?.fill).toBe('red'); + const restored = fromMermaidBlock(toMermaidBlock(graph)); + expect(restored.nodes[0].data.span).toBe(2); + expect(restored.nodes[0].style?.fill).toBe('red'); + expect(graph.data.source?.unknownStatements).toContain('columns auto'); + }); + + it('ER retains direction and entity aliases', () => { + const graph = fromMermaidER('erDiagram\ndirection LR\nCUSTOMER["Customer record"] {\nint id PK\n}'); + expect(graph.direction).toBe('right'); + expect(graph.nodes[0].id).toBe('CUSTOMER'); + expect(graph.nodes[0].label).toBe('Customer record'); + const restored = fromMermaidER(toMermaidER(graph)); + expect(restored.direction).toBe('right'); + expect(restored.nodes[0].label).toBe('Customer record'); + }); + + it('flowchart preserves CSS values containing commas/colons and encoded click metadata', () => { + const graph = fromMermaidFlowchart('flowchart LR\nA --> B\nstyle A stroke-dasharray:5\\,5,fill:url(#paint)\nclick A href "https://example.com/#35;fragment" "A #quot;quote#quot;" _blank'); + expect(graph.nodes[0].style).toEqual({ 'stroke-dasharray': '5\\,5', fill: 'url(#paint)' }); + const restored = fromMermaidFlowchart(toMermaidFlowchart(graph)); + expect(restored.nodes[0].style).toEqual(graph.nodes[0].style); + expect(restored.nodes[0].data.click).toEqual(graph.nodes[0].data.click); + }); + + it('sequence notes retain their control-flow block membership', () => { + const graph = fromMermaidSequence('sequenceDiagram\nloop work\nA->>B: inside\nNote right of A: last inside\nend\nB->>A: outside'); + const out = toMermaidSequence(graph); + expect(out.indexOf('last inside')).toBeLessThan(out.indexOf('\n end')); + expect(out.indexOf('\n end')).toBeLessThan(out.indexOf('outside')); + }); + + it('sequence lifecycle directives before blocks remain outside their blocks', () => { + const graph = fromMermaidSequence('sequenceDiagram\nA->>B: first\ncreate participant C\nloop work\nB->>C: start\nend'); + const out = toMermaidSequence(graph); + expect(out.indexOf('create participant C')).toBeLessThan(out.indexOf('loop work')); + }); +}); diff --git a/tests/formats/fidelity-structured.test.ts b/tests/formats/fidelity-structured.test.ts new file mode 100644 index 0000000..4f78167 --- /dev/null +++ b/tests/formats/fidelity-structured.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, it } from 'vitest'; +import { createGraph, createVisualGraph } from '../../src/graph'; +import type { Graph } from '../../src/types'; +import { fromCytoscapeJSON, toCytoscapeJSON } from '../../src/formats/cytoscape'; +import { fromD3Graph, toD3Graph, type D3Graph } from '../../src/formats/d3'; +import { fromELK, toELK, type ElkNode, type ELKInputNode } from '../../src/formats/elk'; +import { fromJGF, toJGF } from '../../src/formats/jgf'; +import { fromXYFlow, toXYFlow } from '../../src/formats/xyflow'; +import { fromAdjacencyList, toAdjacencyList } from '../../src/formats/adjacency-list'; +import { fromTGF, toTGF } from '../../src/formats/tgf'; + +function getElkElementIds(root: ElkNode): string[] { + const ids: string[] = []; + const pending = [root]; + while (pending.length > 0) { + const node = pending.pop()!; + ids.push(node.id, ...(node.ports ?? []).map((port) => port.id)); + pending.push(...(node.children ?? [])); + } + return ids; +} + +describe('fidelity regressions: structured adapters', () => { + it.each([ + ['Cytoscape', (graph: Graph) => fromCytoscapeJSON(toCytoscapeJSON(graph))], + ['D3', (graph: Graph) => fromD3Graph(toD3Graph(graph))], + ['JGF', (graph: Graph) => fromJGF(toJGF(graph))], + ] as const)('%s preserves factory-default null node and edge labels', (_name, roundTrip) => { + const original = createGraph({ + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }], + }); + const restored = roundTrip(original); + expect(restored.nodes[0].label).toBeNull(); + expect(restored.edges[0].label).toBeNull(); + }); + + it('Cytoscape retains independently optional node geometry', () => { + const original = createGraph({ nodes: [{ id: 'a', x: 12 }] }); + const restored = fromCytoscapeJSON(toCytoscapeJSON(original)); + expect(restored.nodes[0].x).toBe(12); + expect(restored.nodes[0].y).toBeUndefined(); + }); + + it('D3 resolves documented numeric node-index endpoints', () => { + // D3 supports numeric endpoints; the adapter's current public type omits them. + const native = { + nodes: [{ id: 'a' }, { id: 'b' }], links: [{ source: 0, target: 1 }], + } as unknown as D3Graph; + expect(fromD3Graph(native).edges[0]).toMatchObject({ sourceId: 'a', targetId: 'b' }); + }); + + it('ELK rejects hyperedges explicitly until a richer document API can preserve them', () => { + const native: ElkNode = { + id: 'root', children: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + edges: [{ id: 'e', sources: ['a', 'b'], targets: ['c'] }], + }; + expect(() => fromELK(native)).toThrow(/hyperedge/i); + }); + + it('ELK emits document-unique ids despite separator-containing node and port names', () => { + const native = toELK(createVisualGraph({ + id: 'root', + nodes: [ + { id: 'a__b', ports: [{ name: 'c' }] }, + { id: 'a', ports: [{ name: 'b__c' }] }, + { id: 'a__b__c' }, + ], + edges: [{ id: 'e', sourceId: 'a__b', targetId: 'a', sourcePort: 'c', targetPort: 'b__c' }], + })); + const ids = getElkElementIds(native); + expect(new Set(ids).size).toBe(ids.length); + const restored = fromELK(native); + expect(restored.edges[0]).toMatchObject({ + sourceId: 'a__b', targetId: 'a', sourcePort: 'c', targetPort: 'b__c', + }); + }); + + it('ELK preserves a null node label', () => { + const original = createVisualGraph({ + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [{ id: 'e', sourceId: 'a', targetId: 'b', routing: 'splines' }], + }); + const restored = fromELK(toELK(original)); + expect(restored.nodes[0].label).toBeNull(); + }); + + it('ELK preserves routing without explicit waypoints', () => { + const original = createVisualGraph({ + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [{ id: 'e', sourceId: 'a', targetId: 'b', routing: 'splines' }], + }); + expect(fromELK(toELK(original)).edges[0].routing).toBe('splines'); + }); + + it('xyflow retains foreign user data containing its reserved metadata name', () => { + const data = { foo: 1, __statelyai: { bar: 2 } }; + const graph = fromXYFlow({ nodes: [{ id: 'a', position: { x: 0, y: 0 }, data }], edges: [] }); + expect(graph.nodes[0].data).toEqual(data); + }); + + it('xyflow imports edits to emitted native node data and labels', () => { + const native = toXYFlow(createVisualGraph({ nodes: [{ id: 'a', label: 'Original', data: { foo: 1 } }] })); + native.nodes[0].data.foo = 2; + native.nodes[0].data.label = 'Edited'; + const graph = fromXYFlow(native); + expect(graph.nodes[0].label).toBe('Edited'); + expect(graph.nodes[0].data.foo).toBe(2); + }); + + it('xyflow imports edits to emitted native edge labels', () => { + const native = toXYFlow(createVisualGraph({ + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [{ id: 'e', sourceId: 'a', targetId: 'b', label: 'Original' }], + })); + native.edges[0].label = 'Edited'; + expect(fromXYFlow(native).edges[0].label).toBe('Edited'); + }); + + it('adjacency lists retain distinct edges whose ids contain the dedup delimiter', () => { + const graph = fromAdjacencyList({ 'a->b': ['c'], a: ['b->c'] }); + expect(graph.edges.map((edge) => [edge.sourceId, edge.targetId])).toEqual([ + ['a->b', 'c'], ['a', 'b->c'], + ]); + }); + + it('adjacency lists retain a node named __proto__ as an own entry', () => { + const native = toAdjacencyList(createGraph({ nodes: [{ id: '__proto__' }] })); + expect(Object.hasOwn(native, '__proto__')).toBe(true); + expect(fromAdjacencyList(native).nodes.map((node) => node.id)).toEqual(['__proto__']); + }); + + it('TGF recognizes a CRLF separator', () => { + const graph = fromTGF('a Alpha\r\nb Beta\r\n#\r\na b go\r\n'); + expect(graph.nodes.map((node) => node.id)).toEqual(['a', 'b']); + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]).toMatchObject({ sourceId: 'a', targetId: 'b', label: 'go' }); + }); + + it.each(['a b go', 'a\tb go'])('TGF parses whitespace-delimited endpoints in %j', (edge) => { + expect(fromTGF(`a Alpha\nb Beta\n#\n${edge}`).edges[0]).toMatchObject({ + sourceId: 'a', targetId: 'b', label: 'go', + }); + }); +}); + + +describe('structured native preservation and validation', () => { + it('D3 rejects out-of-range numeric endpoints', () => { + expect(() => fromD3Graph({ nodes: [{ id: 'a' }], links: [{ source: 2, target: 0 }] } as unknown as D3Graph)).toThrow(/endpoint|index/i); + }); + + it('D3 retains native fixed positions, velocities and foreign attributes', () => { + const native = { nodes: [{ id: 'a', fx: 1, fy: 2, vx: 3, vy: 4, group: 7 }], links: [], author: 'Ada' }; + expect(toD3Graph(fromD3Graph(native))).toEqual(native); + }); + + it('Cytoscape retains native element data and UI fields', () => { + const native = { data: { author: 'Ada' }, zoom: 2, pan: { x: 3, y: 4 }, elements: { + nodes: [{ data: { id: 'a', score: 10 }, classes: 'red', locked: true, style: { color: 'red' } }], edges: [], + } }; + expect(toCytoscapeJSON(fromCytoscapeJSON(native))).toEqual(native); + }); + + it('ELK imports primitive edges', () => { + const native: ELKInputNode = { id: 'root', children: [{ id: 'a' }, { id: 'b' }], + edges: [{ id: 'e', source: 'a', target: 'b', sourcePoint: { x: 1, y: 2 }, targetPoint: { x: 3, y: 4 } }], + }; + const graph = fromELK(native); + expect(graph.edges[0]).toMatchObject({ sourceId: 'a', targetId: 'b', points: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }); + const output = toELK(graph); + expect(output.edges![0]).toMatchObject({ sources: ['a'], targets: ['b'], sections: [{ startPoint: { x: 1, y: 2 }, endPoint: { x: 3, y: 4 } }] }); + expect(output.edges![0]).not.toHaveProperty('source'); + }); + + it('ELK preserves native extra labels, sections and layout options', () => { + const native: ElkNode = { id: 'root', layoutOptions: { 'elk.algorithm': 'layered' }, width: 600, + children: [{ id: 'a', labels: [{ text: 'one', x: 1 }, { text: 'two' }] }, { id: 'b' }], + edges: [{ id: 'e', sources: ['a'], targets: ['b'], sections: [ + { id: 's1', startPoint: { x: 1, y: 2 }, endPoint: { x: 3, y: 4 } }, + { id: 's2', startPoint: { x: 5, y: 6 }, endPoint: { x: 7, y: 8 } }, + ] }], + }; + expect(toELK(fromELK(native))).toEqual(native); + }); + + it('xyflow preserves native viewport and UI fields', () => { + const native = { viewport: { x: 1, y: 2, zoom: 3 }, nodes: [{ id: 'a', position: { x: 0, y: 0 }, + data: { foo: 1 }, selected: true, hidden: true, style: { color: 'red' }, + }], edges: [] }; + expect(toXYFlow(fromXYFlow(native))).toEqual(native); + }); + + it('TGF rejects malformed edges and unknown endpoints', () => { + expect(() => fromTGF('a Alpha\n#\na')).toThrow(/edge/i); + expect(() => fromTGF('a Alpha\n#\na missing')).toThrow(/node|endpoint/i); + }); +}); + +describe('structured native snapshots after Graph edits', () => { + it('retains D3 foreign attributes on surviving node identities after reorder, insert and delete', () => { + const graph = fromD3Graph({ nodes: [{ id: 'a', group: 'alpha' }, { id: 'b', group: 'beta' }, { id: 'c', group: 'gamma' }], links: [] }); + graph.nodes = [graph.nodes[2], createGraph({ nodes: [{ id: 'new' }] }).nodes[0], graph.nodes[0]]; + const native = toD3Graph(graph); + expect(native.nodes.map((node) => node.id)).toEqual(['c', 'new', 'a']); + expect(native.nodes[0].group).toBe('gamma'); + expect(native.nodes[2].group).toBe('alpha'); + }); + + it('uses replacement Graph data instead of restoring an old primitive or object', () => { + const primitive = fromD3Graph({ data: 1, nodes: [], links: [] }); + primitive.data = { changed: true }; + expect(toD3Graph(primitive).data).toEqual({ changed: true }); + const object = fromD3Graph({ data: { old: true }, nodes: [], links: [] }); + object.data = 2; + expect(toD3Graph(object).data).toBe(2); + }); +}); + +describe('complete structured Graph JSON round trips', () => { + const formats = [ + ['Cytoscape', (graph: Graph) => fromCytoscapeJSON(toCytoscapeJSON(graph))], + ['D3', (graph: Graph) => fromD3Graph(toD3Graph(graph))], + ['JGF', (graph: Graph) => fromJGF(toJGF(graph))], + ['ELK', (graph: Graph) => fromELK(toELK(graph as ReturnType))], + ['xyflow', (graph: Graph) => fromXYFlow(toXYFlow(graph as ReturnType))], + ] as const; + it.each(formats)('%s preserves full JSON fields, presence, data collisions and authored order', (_name, roundTrip) => { + const graph = createVisualGraph({ id: 'parent', mode: 'bidirectional', + nodes: [ + { id: 'child', parentId: 'parent', label: null, shape: '', color: '', + data: { __statelyai: { foreign: true }, label: 'user label', nested: { before: 1 } }, ports: [{ name: 'p', label: '', data: null }] }, + { id: 'parent', label: '', data: 0 }, + ], + edges: [{ id: 'parent', sourceId: 'child', targetId: 'parent', sourcePort: 'p', label: null, + color: '', points: [], routing: 'splines', data: ['array', null], mode: 'undirected' }], + data: { __statelyai: { foreign: true } }, + }); + graph.nodes[1].ports = []; + const json = JSON.parse(JSON.stringify(graph)); + expect(JSON.parse(JSON.stringify(roundTrip(graph)))).toEqual(json); + expect(JSON.parse(JSON.stringify(graph))).toEqual(json); + }); + + it('D3 conversion handles a large chain without per-edge node scans', () => { + const nodes = Array.from({ length: 10000 }, (_value, index) => ({ id: `n${index}` })); + const links = nodes.slice(1).map((_node, index) => ({ source: index, target: index + 1 })); + const native = toD3Graph(fromD3Graph({ nodes, links })); + expect(native.nodes).toHaveLength(10000); + expect(native.links).toHaveLength(9999); + expect(native.links[9998]).toMatchObject({ source: 9998, target: 9999 }); + }); +}); + +describe('structured live metadata and identity', () => { + it('JGF imports live metadata and relation edits from emitted documents', () => { + const native = toJGF(createGraph({ data: { author: 'Before' }, nodes: [{ id: 'a', data: { score: 1 } }, { id: 'b' }], + edges: [{ id: 'e', sourceId: 'a', targetId: 'b', data: { relation: 'before', confidence: 0.5 } }] })); + native.graph.metadata!.author = 'After'; + native.graph.nodes[0].metadata!.score = 2; + native.graph.edges[0].metadata!.confidence = 0.9; + native.graph.edges[0].relation = 'after'; + const graph = fromJGF(native); + expect(graph.data.author).toBe('After'); + expect(graph.nodes[0].data.score).toBe(2); + expect(graph.edges[0].data).toMatchObject({ confidence: 0.9, relation: 'after' }); + }); + + it('Cytoscape treats reserved payloads as native user data under the explicit override', () => { + const reserved = { kind: '@statelyai/graph', version: 2, format: 'cytoscape.node', original: { id: 'fake' }, mapped: {} }; + const native = { elements: { nodes: [{ data: { id: 'a', __statelyai: reserved } }], edges: [] } }; + const graph = fromCytoscapeJSON(native, { metadata: 'native' }); + expect(graph.nodes[0].id).toBe('a'); + expect(graph.nodes[0].data.__statelyai).toEqual(reserved); + expect(toCytoscapeJSON(graph)).toEqual(native); + }); + + it.each([ + { id: 'root', children: [{ id: 'root' }] }, + { id: 'root', children: [{ id: 'a', ports: [{ id: 'p' }] }, { id: 'b', ports: [{ id: 'p' }] }] }, + { id: 'root', children: [{ id: 'a' }, { id: 'b' }], edges: [ + { id: 'e', sources: ['a'], targets: ['b'] }, { id: 'e', sources: ['a'], targets: ['b'] }, + ] }, + ])('ELK rejects duplicate IDs across the whole native document', (native) => { + expect(() => fromELK(native)).toThrow(/duplicate.*id/i); + }); + + it('ELK maps a live native port rename and updated endpoint references consistently', () => { + const native = toELK(createVisualGraph({ nodes: [{ id: 'a', ports: [{ name: 'p' }] }, { id: 'b' }], + edges: [{ id: 'e', sourceId: 'a', targetId: 'b', sourcePort: 'p' }] })); + native.children![0].ports![0].id = 'renamed'; + (native.edges![0] as any).sources = ['renamed']; + const graph = fromELK(native); + expect(graph.nodes[0].ports![0].name).toBe('renamed'); + expect(graph.edges[0]).toMatchObject({ sourceId: 'a', sourcePort: 'renamed' }); + }); + + it('xyflow orders a reversed 10k hierarchy iteratively and restores Graph order', () => { + const nodes = Array.from({ length: 10000 }, (_value, index) => ({ id: `n${index}`, + ...(index > 0 && { parentId: `n${index - 1}` }) })).reverse(); + const graph = createVisualGraph({ nodes }); + const native = toXYFlow(graph); + expect(native.nodes[0].id).toBe('n0'); + expect(native.nodes[9999].id).toBe('n9999'); + expect(fromXYFlow(native).nodes.map((node) => node.id)).toEqual(nodes.map((node) => node.id)); + }); +}); + +describe('D3 native endpoint rebasing', () => { + it('rebases numeric endpoints after node reordering without reversing topology', () => { + const graph = fromD3Graph({ nodes: [{ id: 'a' }, { id: 'b' }], links: [{ source: 0, target: 1, custom: 'keep' }] }); + graph.nodes.reverse(); + const native = toD3Graph(graph); + expect(native.links[0]).toMatchObject({ source: 1, target: 0, custom: 'keep' }); + expect(fromD3Graph(native).edges[0]).toMatchObject({ sourceId: 'a', targetId: 'b' }); + }); + + it('rebases numeric endpoints after inserting or deleting nodes', () => { + const graph = fromD3Graph({ nodes: [{ id: 'a' }, { id: 'unused' }, { id: 'b' }], links: [{ source: 0, target: 2 }] }); + graph.nodes = [createGraph({ nodes: [{ id: 'new' }] }).nodes[0], graph.nodes[0], graph.nodes[2]]; + expect(toD3Graph(graph).links[0]).toMatchObject({ source: 1, target: 2 }); + }); + + it('rebinds live object endpoints to renamed and edited current nodes', () => { + const a = { id: 'a', label: 'Before', custom: 'keep' }, b = { id: 'b' }; + const graph = JSON.parse(JSON.stringify(fromD3Graph({ nodes: [a, b], links: [{ source: a, target: b }] }))); + graph.nodes[0].id = 'renamed'; graph.nodes[0].label = 'After'; graph.edges[0].sourceId = 'renamed'; + graph.nodes.reverse(); + const native = toD3Graph(graph); + expect(native.links[0].source).toBe(native.nodes[1]); + expect(native.links[0].source).toMatchObject({ id: 'renamed', label: 'After', custom: 'keep' }); + expect(fromD3Graph(native).edges[0].sourceId).toBe('renamed'); + }); + it('does not attach deleted native node attributes to a new replacement', () => { + const graph = JSON.parse(JSON.stringify(fromD3Graph({ nodes: [{ id: 'a', custom: 'old' }, { id: 'b' }], links: [{ source: 0, target: 1 }] }))); + graph.nodes[0] = createGraph({ nodes: [{ id: 'replacement' }] }).nodes[0]; + graph.edges[0].sourceId = 'replacement'; + const native = toD3Graph(graph); + expect(native.nodes[0]).not.toHaveProperty('custom'); + expect(fromD3Graph(native).edges[0].sourceId).toBe('replacement'); + }); +}); + +describe('native carriers consume arbitrary user payloads exactly once', () => { + function getLookalike(format: string, native: unknown): any { + return { __statelyaiNative: { kind: '@statelyai/graph/native', version: 1, format, + data: 'payload', native, baseline: { id: '', mode: 'directed', direction: 'down', data: 1, nodes: [], edges: [] } } }; + } + it('D3 preserves a valid-looking foreign carrier plus a sibling edit', () => { + const data = getLookalike('d3', { nodes: [], links: [], data: 2 }); + const graph = fromD3Graph({ nodes: [], links: [], data }); graph.data.foo = 3; + expect(toD3Graph(graph).data).toEqual({ ...data, foo: 3 }); + }); + it('Cytoscape preserves a valid-looking foreign carrier plus a sibling edit', () => { + const data = getLookalike('cytoscape', { elements: { nodes: [], edges: [] }, data: { graphData: 2 } }); + const graph = fromCytoscapeJSON({ elements: { nodes: [], edges: [] }, data: { graphData: data } }); graph.data.foo = 3; + expect(toCytoscapeJSON(graph).data!.graphData).toEqual({ ...data, foo: 3 }); + }); + it('ELK preserves a valid-looking foreign carrier inside legacy Graph data', () => { + const data = getLookalike('elk', { id: 'fake', children: [] }); + const graph = fromELK({ id: 'root', layoutOptions: { 'statelyai.metadata': JSON.stringify({ graph: { data } }) } }); graph.data.foo = 3; + const emitted = toELK(graph); + const metadata = JSON.parse(emitted.layoutOptions!['statelyai.metadata']); + expect(metadata.original.data).toEqual({ ...data, foo: 3 }); + }); + it('xyflow preserves a valid-looking foreign carrier inside legacy Graph data', () => { + const data = getLookalike('xyflow', { nodes: [], edges: [] }); + const graph = fromXYFlow({ nodes: [], edges: [], data: { __statelyai: { graph: { data } } } }); graph.data.foo = 3; + const emitted = toXYFlow(graph); + expect((emitted.data!.__statelyai as any).original.data).toEqual({ ...data, foo: 3 }); + }); + it('JGF preserves a valid-looking foreign carrier inside native metadata', () => { + const data = getLookalike('jgf', { graph: { nodes: [], edges: [] } }); + const graph = fromJGF({ graph: { nodes: [], edges: [], metadata: data } }, { metadata: 'native' }); graph.data.foo = 3; + const emitted = toJGF(graph, { version: 1 }); + expect(emitted.graph.metadata?.__statelyai.original.data).toEqual({ ...data, foo: 3 }); + }); +}); + + +describe('ELK native port identity during Graph edits', () => { + it('retains native port ID, endpoints and foreign attrs when geometry changes', () => { + const graph = fromELK({ id: 'root', children: [{ id: 'a', ports: [{ id: 'p', x: 1, custom: 'keep' } as any] }, { id: 'b' }], edges: [{ id: 'e', sources: ['p'], targets: ['b'] }] }); + graph.nodes[0].ports![0].x = 2; + const native = toELK(graph); + expect(native.children![0].ports![0]).toMatchObject({ id: 'p', x: 2, custom: 'keep' }); + expect(native.edges![0].sources).toEqual(['p']); + expect(fromELK(native).edges[0]).toMatchObject({ sourceId: 'a', sourcePort: 'p' }); + }); +}); + + +describe('minimal codec policies', () => { + it('preserves explicit parallel adjacency multiplicity with reciprocal entries', () => { + expect(fromAdjacencyList({ a: ['b', 'b'], b: ['a', 'a'] }, { directed: false, parallel: 'preserve' }).edges).toHaveLength(2); + expect(fromAdjacencyList({ a: ['b', 'b'], b: [] }, { parallel: 'preserve' }).edges).toHaveLength(2); + expect(fromAdjacencyList({ a: ['b', 'b'] }).edges).toHaveLength(1); + }); + it('exports each edge mode even when it differs from graph mode', () => { + const graph = createGraph({ mode: 'directed', nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', mode: 'undirected' }] }); + expect(toAdjacencyList(graph)).toEqual({ a: ['b'], b: ['a'] }); + expect(Object.getPrototypeOf(toAdjacencyList(graph))).toBe(Object.prototype); + }); + it('rejects multiline TGF labels rather than emitting new records', () => { + expect(() => toTGF(createGraph({ nodes: [{ id: 'a', label: 'one\ntwo' }] }))).toThrow(/label/); + expect(() => toTGF(createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', label: 'one\r\ntwo' }] }))).toThrow(/label/); + }); +}); + + +describe('ELK native section correspondence', () => { + it('edits the imported first section while retaining its identity and other sections', () => { + const second = { id: 's2', startPoint: { x: 5, y: 6 }, endPoint: { x: 7, y: 8 }, incomingSections: ['s1'], custom: 'second' }; + const graph = fromELK({ id: 'root', children: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sources: ['a'], targets: ['b'], sections: [ + { id: 's1', startPoint: { x: 1, y: 2 }, endPoint: { x: 3, y: 4 }, outgoingSections: ['s2'], custom: 'first' } as any, second as any ] }] }); + graph.edges[0].points![0].x = 10; + const sections = toELK(graph).edges![0].sections!; + expect(sections[0]).toMatchObject({ id: 's1', startPoint: { x: 10, y: 2 }, outgoingSections: ['s2'], custom: 'first' }); + expect(sections[1]).toEqual(second); + }); +}); + + +describe('JGF v2 hyperedge projection boundary', () => { + it('rejects hyperedges instead of quietly omitting their semantics', () => { + expect(() => fromJGF({ graph: { directed: false, nodes: { a: {}, b: {} }, hyperedges: [{ nodes: ['a', 'b'] }] } })).toThrow(/hyperedges/); + }); +}); diff --git a/tests/formats/fidelity-xml.test.ts b/tests/formats/fidelity-xml.test.ts new file mode 100644 index 0000000..82daa57 --- /dev/null +++ b/tests/formats/fidelity-xml.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { getEdgeMode } from '../../src/mode'; +import type { Graph } from '../../src/types'; +import { fromGraphML, toGraphML } from '../../src/formats/graphml'; +import { fromGEXF, toGEXF } from '../../src/formats/gexf'; +import { fromGML, toGML } from '../../src/formats/gml'; +import { fromDOT, toDOT } from '../../src/formats/dot'; + +// Native inputs are authored independently of our exporters. Assertions check +// their expected meaning before a lossy import could become a false baseline. +function getGraphML(contents: string, keys = ''): string { + return `${keys} + ${contents}`; +} + +function getGEXF(edge: string, graphAttributes = 'defaultedgetype="directed"'): string { + return ` + + ${edge}`; +} + +describe('fidelity regressions: XML and DOT', () => { + it('GraphML resolves arbitrary key ids through attr.name', () => { + const graph = fromGraphML(getGraphML( + 'Alpha', + '', + )); + expect(graph.nodes[0].label).toBe('Alpha'); + }); + + it('GraphML applies declared key defaults to missing values', () => { + const graph = fromGraphML(getGraphML( + '', + 'Default label', + )); + expect(graph.nodes[0].label).toBe('Default label'); + }); + + it('GraphML inherits edge direction from the containing nested graph', () => { + const graph = fromGraphML(getGraphML(` + + + `)); + expect(graph.nodes.find((node) => node.id === 'a')?.parentId).toBe('group'); + expect(getEdgeMode(graph, graph.edges[0])).toBe('undirected'); + }); + + it('GEXF imports native edge weight instead of requiring a custom attvalue', () => { + const graph = fromGEXF(getGEXF('')); + expect(graph.edges[0].weight).toBe(2.5); + }); + + it('GEXF imports mutual edges as bidirectional', () => { + const graph = fromGEXF(getGEXF('')); + expect(getEdgeMode(graph, graph.edges[0])).toBe('bidirectional'); + }); + + it('GEXF uses the native undirected default when defaultedgetype is absent', () => { + const graph = fromGEXF(getGEXF('', '')); + expect(graph.mode).toBe('undirected'); + expect(getEdgeMode(graph, graph.edges[0])).toBe('undirected'); + }); + + it.each([ + ['GraphML', (graph: Graph) => fromGraphML(toGraphML(graph))], + ['GEXF', (graph: Graph) => fromGEXF(toGEXF(graph))], + ] as const)('%s preserves a bidirectional Graph mode', (_name, roundTrip) => { + const graph = createGraph({ + mode: 'bidirectional', + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }], + }); + const restored = roundTrip(graph); + expect(restored.mode).toBe('bidirectional'); + expect(getEdgeMode(restored, restored.edges[0])).toBe('bidirectional'); + }); + + it.each([ + ['GEXF', (graph: Graph) => fromGEXF(toGEXF(graph))], + ['GML', (graph: Graph) => fromGML(toGML(graph))], + ['GraphML', (graph: Graph) => fromGraphML(toGraphML(graph))], + ] as const)('%s preserves factory-default null node and edge labels', (_name, roundTrip) => { + const original = createGraph({ + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }], + }); + const restored = roundTrip(original); + expect(restored.nodes[0].label).toBeNull(); + expect(restored.edges[0].label).toBeNull(); + }); + + it('DOT merges repeated node declarations without resetting earlier attributes', () => { + const graph = fromDOT('digraph { a [label="Alpha", tooltip="keep"]; a [shape=box]; }'); + expect(graph.nodes).toHaveLength(1); + expect(graph.nodes[0]).toMatchObject({ + id: 'a', label: 'Alpha', shape: 'rectangle', data: { dot: { attrs: { tooltip: 'keep' } } }, + }); + }); + + it('DOT strict graphs merge repeated edges and their attributes', () => { + const graph = fromDOT('strict digraph { a -> b [label="first"]; a -> b [color=red]; }'); + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]).toMatchObject({ sourceId: 'a', targetId: 'b', label: 'first', color: 'red' }); + expect(toDOT(graph)).toMatch(/^strict digraph/); + }); + + it('DOT rank constraints retain edges authored inside their subgraph', () => { + const graph = fromDOT('digraph { { rank=same; a -> b; } }'); + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]).toMatchObject({ sourceId: 'a', targetId: 'b' }); + }); + + it('DOT preserves cluster containment on the first round trip', () => { + const graph = fromDOT('digraph { subgraph cluster_0 { label="Group"; a; b; a -> b; } }'); + const restored = fromDOT(toDOT(graph)); + expect(restored.nodes.find((node) => node.id === 'a')?.parentId).toBe('cluster_0'); + expect(restored.nodes.find((node) => node.id === 'b')?.parentId).toBe('cluster_0'); + expect(restored.nodes.find((node) => node.id === 'cluster_0')?.label).toBe('Group'); + }); +}); + + +describe('XML native preservation and edit precedence', () => { + it('GraphML matches a prefix declared on its root and rejects foreign roots', () => { + expect(fromGraphML('').nodes[0].id).toBe('a'); + expect(() => fromGraphML('')).toThrow('GraphML:'); + }); + + it('GraphML recognizes XML boolean zero and rejects unrepresentable hyperedges', () => { + const graph = fromGraphML(getGraphML('')); + expect(graph.edges[0].mode).toBe('undirected'); + expect(() => fromGraphML(getGraphML(''))).toThrow('hyperedges'); + }); + + it('GraphML retains unknown key declarations and values without discarding primitive data', () => { + const source = getGraphML('427', '3'); + const graph = fromGraphML(source); + const output = toGraphML(graph); + expect(output).toContain('attr.name="score"'); + expect(output).toContain('3'); + expect(output).toContain('7'); + expect(fromGraphML(output).nodes[0].data).toEqual(graph.nodes[0].data); + }); + + it.each([ + ['GraphML', toGraphML, fromGraphML, 'Edited', ''], + ['GEXF', toGEXF, fromGEXF, ' label="Edited"', ' { + const graph = createGraph({nodes:[{id:'a'}]}); + const output = to(graph); + const changed = _name === 'GraphML' ? output.replace(marker, marker + edited) : output.replace(marker,marker + edited); + expect(from(changed).nodes[0].label).toBe('Edited'); + }); + + it('GraphML native direction edits supersede private bidirectional mode', () => { + const graph = createGraph({nodes:[{id:'a'},{id:'b'}],edges:[{id:'e',sourceId:'a',targetId:'b',mode:'bidirectional'}]}); + const xml = toGraphML(graph).replace('id="e" source="a" target="b"', 'id="e" source="a" target="b" directed="true"'); + expect(fromGraphML(xml).edges[0].mode).toBe('directed'); + }); + + it.each(['http://www.gexf.net/1.2draft','http://gexf.net/1.2draft'])('GEXF accepts published legacy namespace %s', (namespace) => { + const source = ``; + expect(fromGEXF(source).nodes[0]).toMatchObject({id:'a',x:3,y:4}); + }); + + it('GEXF retains class-scoped custom declarations, defaults and creator metadata', () => { + const source = 'Author7'; + const graph = fromGEXF(source); + expect(graph.nodes[0].data).toBe(42); + const output = toGEXF(graph); + expect(output).toContain('Author'); + expect(output).toContain('title="score"'); + expect(output).toContain('7'); + expect(output).toContain('value="8"'); + }); + + it('GEXF preserves native dynamics, alpha, z and shape on re-export', () => { + const source = ''; + const output = toGEXF(fromGEXF(source)); + expect(output).toContain('mode="dynamic"'); + expect(output).toContain('timeformat="double"'); + expect(output).toContain('start="1"'); + expect(output).toContain('z="4"'); + expect(output).toContain('a="0.5"'); + expect(output).toContain(' { + const graph = createGraph({nodes:[{id:'a',color:'red',x:10,width:30}]}); + const restored = fromGEXF(toGEXF(graph)); + expect(restored.nodes[0].color).toBe('red'); + expect(restored.nodes[0].x).toBe(10); + expect(restored.nodes[0].y).toBeUndefined(); + expect(restored.nodes[0].width).toBe(30); + expect(restored.nodes[0].height).toBeUndefined(); + }); +}); + +describe('DOT native attribute fidelity', () => { + it('retains independent border color, fill and style', () => { + const output = toDOT(fromDOT('digraph { a [color=red fillcolor=blue style="rounded,dashed"]; }')); + expect(output).toContain('color="red"'); + expect(output).toContain('fillcolor="blue"'); + expect(output).toContain('style="rounded,dashed"'); + }); + + it('retains resolved attributes across changing defaults', () => { + const output = toDOT(fromDOT('digraph { node [fontname=Arial]; a; node [fontname=Courier]; b; }')); + expect(output).toMatch(/a \[[^\]]*fontname="Arial"/); + expect(output).toMatch(/b \[[^\]]*fontname="Courier"/); + }); + + it('retains inherited HTML label semantics', () => { + const output = toDOT(fromDOT('digraph { node [label=<Default>]; a; a -> b; }')); + expect(output).toMatch(/a \[label=<Default<\/b>>/); + expect(output).toMatch(/b \[label=<Default<\/b>>/); + }); + + it('retains Graph-only fields on compound nodes and arbitrary data', () => { + const graph = createGraph({data:42,nodes:[{id:'group',initialNodeId:'child',data:false,x:12},{id:'child',parentId:'group',data:{dot:'foreign'}}]}); + const restored = fromDOT(toDOT(graph)); + expect(restored.data).toBe(42); + expect(restored.nodes.find((node) => node.id === 'group')).toMatchObject({initialNodeId:'child',x:12}); + expect(restored.nodes.find((node) => node.id === 'group')?.data).toBe(false); + expect(restored.nodes.find((node) => node.id === 'child')?.data).toEqual({dot:'foreign'}); + }); +}); + +describe('DOT compass versus declared ports', () => { + it('imports native compass-only endpoints without fabricated port references', () => { + const graph = fromDOT('digraph { a:n -> b:s; }'); + expect(graph.edges[0].sourcePort).toBeUndefined(); + expect(graph.edges[0].targetPort).toBeUndefined(); + expect(graph.edges[0].data).toMatchObject({dot:{sourceCompass:'n',targetCompass:'s'}}); + expect(toDOT(graph)).toContain('a:n -> b:s'); + }); + + it('keeps record ports whose names happen to be compass names', () => { + const graph = fromDOT('digraph { a [shape=record label=" North| Output"]; b; a:n -> b; }'); + expect(graph.nodes[0].ports).toContainEqual({name:'n',direction:'inout',data:null}); + expect(graph.edges[0].sourcePort).toBe('n'); + }); + + it('keeps HTML port definitions and three-segment compass syntax', () => { + const graph = fromDOT('digraph { a [label=<
P
>]; a:p:e -> b:w; }'); + expect(graph.nodes[0].ports).toContainEqual({name:'p',direction:'inout',data:null}); + expect(graph.edges[0].sourcePort).toBe('p'); + expect(graph.edges[0].data).toMatchObject({dot:{sourceCompass:'e',targetCompass:'w'}}); + }); +}); + +describe('GraphML extension XML namespaces', () => { + it('retains namespace declarations for unknown vendor data subtrees', () => { + const source = 'Alpha'; + const output = toGraphML(fromGraphML(source)); + expect(output).toContain('xmlns:y="urn:vendor"'); + expect(output).toContain(''); + expect(output).toContain('Alpha'); + expect(fromGraphML(output).nodes[0].data).toEqual(fromGraphML(source).nodes[0].data); + }); +}); + +describe('GraphML native port data', () => { + it('retains port descriptions, typed custom values and native nesting', () => { + const source = 'Inside7'; + const graph = fromGraphML(source); + expect(graph.nodes[0].ports?.find((port) => port.name === 'inner')?.label).toBe('Inside'); + const output = toGraphML(graph); + expect(output).toContain(''); + expect(output).toContain('7'); + expect(fromGraphML(output).nodes[0].ports).toEqual(graph.nodes[0].ports); + }); +}); + +describe('DOT comma node lists', () => { + it('expands comma edge endpoint lists as a Cartesian product', () => { + const graph = fromDOT('digraph { a,b,c -> d,e [label="x,y"]; }'); + expect(graph.edges.map((edge) => [edge.sourceId,edge.targetId,edge.label])).toEqual([ + ['a','d','x,y'],['a','e','x,y'],['b','d','x,y'],['b','e','x,y'],['c','d','x,y'],['c','e','x,y'], + ]); + }); + + it('keeps quoted commas, HTML labels, comments and attribute commas in their contexts', () => { + const graph = fromDOT('digraph { "a,b",c [label=<A,B>, color=red]; /* comment,a */ "a,b",c -> d,e; }'); + expect(graph.nodes.find((node) => node.id === 'a,b')?.label).toBe('A,B'); + expect(graph.nodes.find((node) => node.id === 'c')?.label).toBe('A,B'); + expect(graph.edges).toHaveLength(4); + }); +}); + +describe('XML well-formedness boundary', () => { + it.each([ + ['GraphML',fromGraphML,''], + ['GraphML',fromGraphML,''], + ['GEXF',fromGEXF,''], + ['GEXF',fromGEXF,''], + ] as const)('%s rejects truncated or mismatched XML %#', (_name,from,source) => { + expect(() => from(source)).toThrow('invalid XML'); + }); +}); + +describe('DOT strict endpoint updates', () => { + it('updates redeclared ports, clears replaced compass and keeps untouched endpoints', () => { + const graph = fromDOT('strict digraph { a:p:n -> b:q:s [label="first"]; a:r -> b; }'); + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]).toMatchObject({sourcePort:'r',targetPort:'q',label:'first'}); + expect((graph.edges[0].data as any).dot.sourceCompass).toBeUndefined(); + expect((graph.edges[0].data as any).dot.targetCompass).toBe('s'); + }); +}); + +describe('GraphML native port edit precedence', () => { + it('uses live native descriptions, data and geometry over its own portable port extension', () => { + const graph = createGraph({nodes:[{id:'a',ports:[{name:'p',label:'Original',x:1,data:42}]}]}); + const source = toGraphML(graph).replace('Original','Edited').replace('42','43').replace('1','2'); + expect(fromGraphML(source).nodes[0].ports?.[0]).toMatchObject({label:'Edited',x:2,data:43}); + }); +}); + +it('GML standard output marks parallel edges for independent native readers', () => { + const graph = createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'first', sourceId: 'a', targetId: 'b' }, { id: 'second', sourceId: 'a', targetId: 'b' }] }); + const source = toGML(graph, { standard: true }); + expect(source).toContain('multigraph 1'); + expect(fromGML(source, { standard: true }).edges.map((edge) => edge.id)).toEqual(['first', 'second']); + expect(toGML(graph)).not.toContain('multigraph'); + const conflicting = fromGML('graph [ multigraph 0 node [ id 0 ] node [ id 1 ] edge [ source 0 target 1 ] edge [ source 0 target 1 ] ]'); + expect(() => toGML(conflicting, { standard: true })).toThrow(/multigraph hint conflicts/); +}); + +it('GML emits fresh empty node and edge labels and keeps native missing-label provenance', () => { + const graph = createGraph({ nodes: [{ id: 'a', label: '' }, { id: 'b', label: null }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', label: '' }] }); + expect(toGML(graph).match(/label ""/g)).toHaveLength(2); + const native = 'graph [ node [ id 0 ] node [ id 1 label "" ] edge [ id 2 source 0 target 1 ] ]'; + const imported = JSON.parse(JSON.stringify(fromGML(native))); + const emitted = toGML(imported, { standard: true }); + expect(emitted.match(/label ""/g)).toHaveLength(1); + imported.nodes[0].label = 'ADDED'; + expect(toGML(imported)).toContain('label "ADDED"'); + const user = createGraph({ nodes: [{ id: 'a', label: '', data: { gml: { implicitLabel: true } } }] }); + expect(toGML(user)).toContain('label ""'); + expect(fromGML(toGML(user)).nodes[0].data).toEqual(user.nodes[0].data); +}); + +it('GML standard export rejects non-scalar native labels and IDs but preserves JSON escaped data', () => { + for (const value of ['\ud800', '\udfff']) { + expect(() => toGML(createGraph({ nodes: [{ id: 'a', label: value }] }), { standard: true })).toThrow(/Unicode scalar/); + expect(() => toGML(createGraph({ nodes: [{ id: value }] }), { standard: true })).toThrow(/Unicode scalar/); + expect(() => toGML(createGraph({ id: value }), { standard: true })).toThrow(/Unicode scalar/); + expect(() => toGML(createGraph({ nodes: [{ id: 'a' }], edges: [{ id: value, sourceId: 'a', targetId: 'a' }] }), { standard: true })).toThrow(/Unicode scalar/); + } + const graph = createGraph({ nodes: [{ id: 'a', label: 'valid 🕸️', data: { text: '\ud800' } }] }); + expect(fromGML(toGML(graph, { standard: true })).nodes[0].data).toEqual(graph.nodes[0].data); +}); diff --git a/tests/formats/gml.test.ts b/tests/formats/gml.test.ts index 1b68117..666d1c4 100644 --- a/tests/formats/gml.test.ts +++ b/tests/formats/gml.test.ts @@ -187,3 +187,59 @@ graph [ }); }); }); + +describe('GML native fidelity', () => { + it('decodes standard string entities and exports entity-escaped text', () => { + const graph = fromGML('graph [ node [ id 0 label "A & "B" ä" ] ]'); + expect(graph.nodes[0].label).toBe('A & "B" ä'); + expect(toGML(graph)).toContain('label "A & "B" ä"'); + }); + + it.each(['graph [ node [ id 0 ]', 'graph [ node [ id 0 label "unfinished', 'graph [ node [ id 0 ] ] ]'])('rejects malformed document %s', (source) => { + expect(() => fromGML(source)).toThrow('GML:'); + }); + + it('retains ordered repeated foreign attributes', () => { + const source = 'graph [ comment "network" node [ id 0 custom [ item "first" item "second" ] ] ]'; + const output = toGML(fromGML(source)); + expect(output).toContain('comment "network"'); + expect(output.indexOf('item "first"')).toBeLessThan(output.indexOf('item "second"')); + expect(fromGML(output).nodes[0].data).toEqual(fromGML(source).nodes[0].data); + }); +}); + +describe('GML standard interoperability options', () => { + it('emits integer references and restores original Graph IDs and node ordering', () => { + const graph: Graph = {id:'network',mode:'directed',data:undefined,nodes:[{type:'node',id:'child',parentId:'parent',label:null,data:undefined},{type:'node',id:'parent',parentId:null,label:null,data:undefined}],edges:[{type:'edge',id:'connection',sourceId:'parent',targetId:'child',label:null,data:undefined}]}; + const output = toGML(graph,{standard:true}); + expect(output).toContain('id 0'); + expect(output).toContain('source 1'); + expect(output).toContain('target 0'); + const restored = fromGML(output,{standard:true}); + expect(restored.id).toBe('network'); + expect(restored.nodes.map((node) => node.id)).toEqual(['child','parent']); + expect(restored.edges[0]).toMatchObject({id:'connection',sourceId:'parent',targetId:'child',label:null}); + }); + + it('preserves JSON and labels containing backslashes while accepting legacy imports', () => { + const graph: Graph = {id:'g',mode:'directed',data:{path:'C:\\new\\folder'},nodes:[{type:'node',id:'a',parentId:null,label:'C:\\new',data:'literal \\n'}],edges:[]}; + const restored = fromGML(toGML(graph)); + expect(restored.data).toEqual(graph.data); + expect(restored.nodes[0].label).toBe(graph.nodes[0].label); + expect(restored.nodes[0].data).toBe(graph.nodes[0].data); + expect(fromGML('graph [ node [ id "a" label "C:\\\\new" ] ]').nodes[0].label).toBe('C:\\new'); + expect(fromGML('graph [ node [ id 0 label "C:\\\\new" ] ]',{standard:true}).nodes[0].label).toBe('C:\\\\new'); + }); +}); + +describe('GML foreign graphics', () => { + it('retains unknown graphics beside mapped geometry', () => { + const source = 'graph [ node [ id 0 graphics [ x 10 y 20 fill "red" type "ellipse" ] ] ]'; + const graph = fromGML(source); + const output = toGML(graph); + expect(output).toContain('fill "red"'); + expect(output).toContain('type "ellipse"'); + expect(fromGML(output).nodes[0]).toMatchObject({x:10,y:20}); + expect(fromGML(output).nodes[0].data).toEqual(graph.nodes[0].data); + }); +}); diff --git a/tests/formats/graphml.test.ts b/tests/formats/graphml.test.ts index dd4d28b..7067761 100644 --- a/tests/formats/graphml.test.ts +++ b/tests/formats/graphml.test.ts @@ -248,7 +248,7 @@ describe('GraphML', () => { const graph = fromGraphML(xml); expect(graph.nodes[0].ports).toEqual([ { name: 'a', direction: 'inout', data: null }, - { name: 'a.b', direction: 'inout', data: null }, + { name: 'a.b', direction: 'inout', data: {_graphml:{format:'@statelyai/graph/graphml',version:1,parentPort:'a',wrapped:true,userData:null}} }, { name: 'c', direction: 'inout', data: null }, ]); }); diff --git a/tests/formats/graphviz-json.test.ts b/tests/formats/graphviz-json.test.ts new file mode 100644 index 0000000..f1b5ef1 --- /dev/null +++ b/tests/formats/graphviz-json.test.ts @@ -0,0 +1,77 @@ +import { getUserData } from '../../src/formats/xml-preservation'; +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { fromGraphvizJSON, toGraphvizJSON, graphvizJSONDocumentConverter, type GraphvizJSON } from '../../src/formats/graphviz-json'; +import { getCarrierData } from '../../src/formats/preservation'; +function getGraph(graph: ReturnType) { return { ...graph, data: getCarrierData(graph.data, 'graphviz-json') }; } +function getFixture(name: string): GraphvizJSON { return JSON.parse(readFileSync(new URL(`../native/fixtures/graphviz/${name}.json`, import.meta.url), 'utf8')); } +describe('Graphviz JSON output profile', () => { + it('preserves selected Graph fields through JSON-native string carriers', () => { + const graph = createGraph({ id: 'G', data: [null, { _stately: 'USER' }], nodes: [{ id: 'parent', label: null, data: 42 }, { id: 'leaf', parentId: 'parent', label: '', x: 1, y: 2, width: 3, height: 4, style: { fill: 'red' } }], edges: [{ id: 'edge', sourceId: 'leaf', targetId: 'parent', label: 'A\nB', weight: 2.5, data: false }] }); + const source = JSON.parse(JSON.stringify(toGraphvizJSON(graph))); + expect(getGraph(fromGraphvizJSON(source))).toEqual(graph); + expect(source._subgraph_cnt).toBe(1); + expect(typeof source.objects[0]._stately).toBe('string'); + }); + it.each(['strict-ports', 'clusters-ranks', 'parallel-undirected'])('retains all actual generated %s JSON and drawing operations', (name) => { + const source = getFixture(name); + expect(toGraphvizJSON(JSON.parse(JSON.stringify(fromGraphvizJSON(source))))).toEqual(source); + expect(graphvizJSONDocumentConverter.to(JSON.parse(JSON.stringify(graphvizJSONDocumentConverter.from(source))))).toEqual(source); + }); + it('requires explicit geometry conversion and uses point units, flipped Y and top-left corners', () => { + const source: GraphvizJSON = { name: 'G', directed: true, strict: false, _subgraph_cnt: 0, bb: '0,0,100,200', objects: [{ _gvid: 0, name: 'a', pos: '50,150', width: '1', height: '0.5' }], edges: [] }; + expect(fromGraphvizJSON(source).nodes[0].x).toBeUndefined(); + expect(fromGraphvizJSON(source, { geometry: 'points' }).nodes[0]).toMatchObject({ x: 14, y: 32, width: 72, height: 36 }); + }); + it('rejects stale layout/drawing edits and permits explicit regeneration', () => { + const graph = fromGraphvizJSON(getFixture('strict-ports')); + graph.nodes[0].label = 'CHANGED'; + expect(() => toGraphvizJSON(graph)).toThrow(/stale/); + const fresh = toGraphvizJSON(graph, { regenerate: true }); + expect(fresh.objects![0]._draw_).toBeUndefined(); + expect(fresh.objects![0].label).toBe('CHANGED'); + }); + it('patches unlaid literal labels while retaining independent source attributes', () => { + const source: GraphvizJSON = { name: 'G', directed: true, strict: false, _subgraph_cnt: 0, objects: [{ _gvid: 0, name: 'a', label: 'Old', vendor: 'KEEP' }], edges: [] }; + const document = graphvizJSONDocumentConverter.from(source); + document.source.objects![0].vendor = 'NATIVE'; document.graph!.nodes[0].label = 'NEW'; + expect(graphvizJSONDocumentConverter.to(document).objects![0]).toMatchObject({ label: 'NEW', vendor: 'NATIVE' }); + const conflict = graphvizJSONDocumentConverter.from(source); + conflict.source.objects![0].label = ''; conflict.graph!.nodes[0].label = 'GRAPH'; + expect(() => graphvizJSONDocumentConverter.to(conflict)).toThrow(/conflict/); + }); + it.each([ (source: GraphvizJSON) => { source.objects![0]._gvid = 2; }, (source: GraphvizJSON) => { source.edges![0].head = 99; }, (source: GraphvizJSON) => { source._subgraph_cnt = 3; } ])('rejects malformed native index references', (edit) => { + const source: GraphvizJSON = { name: 'G', directed: true, strict: false, _subgraph_cnt: 0, objects: [{ _gvid: 0, name: 'a' }], edges: [{ _gvid: 0, tail: 0, head: 0 }] }; + edit(source); expect(() => fromGraphvizJSON(source)).toThrow(/Graphviz JSON/); + expect(graphvizJSONDocumentConverter.from(source).graph).toBeNull(); + }); + it('rejects cyclic subgraphs and requested structural changes', () => { + expect(() => fromGraphvizJSON({ name: 'G', directed: true, strict: false, _subgraph_cnt: 2, objects: [{ _gvid: 0, name: 'cluster_a', subgraphs: [1] }, { _gvid: 1, name: 'cluster_b', subgraphs: [0] }] })).toThrow(/cyclic/); + const source = toGraphvizJSON(createGraph({ nodes: [{ id: 'a' }] })); + const graph = fromGraphvizJSON(source); graph.nodes.push({ ...graph.nodes[0], id: 'new' }); + expect(() => toGraphvizJSON(graph)).toThrow(/structural/); + }); +}); + +it('retains primitive user data alongside independently added native attributes', () => { + const source = toGraphvizJSON(createGraph({ nodes: [{ id: 'a', label: 'OLD', data: 42 }] })); + const document = graphvizJSONDocumentConverter.from(source); + document.source.objects![0].vendor = 'KEEP'; document.graph!.nodes[0].label = 'NEW'; + const graph = fromGraphvizJSON(graphvizJSONDocumentConverter.to(document)); + expect(graph.nodes[0].label).toBe('NEW'); expect(getUserData(graph.nodes[0].data, 'graphviz-json')).toBe(42); + expect(graph.nodes[0].data['_graphviz-json'].native.vendor).toBe('KEEP'); +}); +it('rejects duplicate strict edges, malformed attribute types and invalid membership domains', () => { + const source: GraphvizJSON = { name: 'G', directed: true, strict: true, _subgraph_cnt: 0, objects: [{ _gvid: 0, name: 'a' }], edges: [{ _gvid: 0, tail: 0, head: 0 }, { _gvid: 1, tail: 0, head: 0 }] }; + expect(() => fromGraphvizJSON(source)).toThrow(/strict/); + for (const field of [{ weight: 'NaN' }, { width: 'x' }, { pos: 'garbage' }, { bb: '1,2,3' }, { _draw_: {} }, { label: false }, { nodes: [0] }]) { + expect(() => fromGraphvizJSON({ ...source, strict: false, edges: [], objects: [{ ...source.objects![0], ...field }] })).toThrow(/Graphviz JSON/); + } +}); + +it('rejects stale root-only layout bounds during literal label edits', () => { + const source: GraphvizJSON = { name: 'G', directed: true, strict: false, _subgraph_cnt: 0, bb: '0 0 100 100', objects: [{ _gvid: 0, name: 'a', label: 'OLD' }] }; + const graph = fromGraphvizJSON(source); graph.nodes[0].label = 'NEW'; + expect(() => toGraphvizJSON(graph)).toThrow(/stale/); +}); diff --git a/tests/formats/jgf.test.ts b/tests/formats/jgf.test.ts index 8a28bd9..e167a46 100644 --- a/tests/formats/jgf.test.ts +++ b/tests/formats/jgf.test.ts @@ -1,3 +1,4 @@ +import { createGraph } from '../../src/graph'; import { describe, it, expect } from 'vitest'; import { toJGF, fromJGF } from '../../src/formats/jgf'; import type { Graph } from '../../src/types'; @@ -23,7 +24,9 @@ describe('JGF', () => { it('toJGF() produces valid JGF structure', () => { const jgf = toJGF(sampleGraph); expect(jgf.graph.directed).toBe(true); + // Legacy default retains graph.id for existing array-shape consumers. expect(jgf.graph.id).toBe('test'); + expect(fromJGF(jgf).id).toBe('test'); expect(jgf.graph.nodes).toHaveLength(3); expect(jgf.graph.edges).toHaveLength(2); expect(jgf.graph.edges[0].source).toBe('a'); @@ -70,9 +73,9 @@ describe('JGF', () => { expect(() => fromJGF({ graph: 'not an object' } as any)).toThrow('JGF: missing "graph" property'); }); - it('throws on missing nodes/edges arrays', () => { - expect(() => fromJGF({ graph: { edges: [] } } as any)).toThrow('JGF: "graph.nodes" must be an array'); - expect(() => fromJGF({ graph: { nodes: [] } } as any)).toThrow('JGF: "graph.edges" must be an array'); + it('throws on malformed nodes/edges collections', () => { + expect(() => fromJGF({ graph: { nodes: 'bad', edges: [] } } as any)).toThrow('JGF: "graph.nodes" must be an array or object'); + expect(() => fromJGF({ graph: { nodes: [], edges: 'bad' } } as any)).toThrow('JGF: "graph.edges" must be an array'); }); it('handles empty graph', () => { @@ -115,3 +118,65 @@ describe('JGF', () => { }); }); }); + +describe('JGF versions and native semantics', () => { + it('exports v1 without forbidden graph.id and v2 with ID-keyed nodes', () => { + const graph = sampleGraph; + const v1 = toJGF(graph, { version: 1 }); + expect(Object.keys(v1.graph).sort()).toEqual(['directed', 'edges', 'metadata', 'nodes']); + expect(v1.graph.nodes.every((node) => typeof node.id === 'string')).toBe(true); + const v2 = toJGF(graph, { version: 2 }); + expect(v2.graph.id).toBe('test'); + expect(Object.keys(v2.graph.nodes)).toEqual(['a', 'b', 'c']); + expect(Object.values(v2.graph.nodes).every((node) => !Object.hasOwn(node, 'id'))).toBe(true); + expect(JSON.parse(JSON.stringify(fromJGF(v2)))).toEqual(JSON.parse(JSON.stringify(graph))); + }); + + it('preserves Graph node order through numeric v2 dictionary keys', () => { + const graph: Graph = { id: '', mode: 'directed', data: null, nodes: [ + { type: 'node', id: '10', data: null }, { type: 'node', id: '2', data: null }, + ], edges: [] }; + expect(JSON.parse(JSON.stringify(fromJGF(toJGF(graph, { version: 2 }))))).toEqual(graph); + }); + + it('imports v2 native directedness, relation and arbitrary metadata', () => { + const native = { graph: { id: 'native', directed: true, label: 'Network', metadata: { author: 'Ada' }, + nodes: { a: { metadata: { score: 10 } }, b: {} }, + edges: [{ source: 'a', target: 'b', directed: false, relation: 'knows', metadata: { confidence: 0.9 } }], + } }; + const graph = fromJGF(native); + expect(graph.edges[0].mode).toBe('undirected'); + expect(graph.edges[0].data).toEqual({ confidence: 0.9, relation: 'knows' }); + // Default output remains v1 for array-shape consumers, including imported v2. + expect(Array.isArray(toJGF(graph).graph.nodes)).toBe(true); + expect(toJGF(graph, { version: 2 })).toMatchObject(native); + }); + + it('accepts schema-valid documents with optional nodes and edges omitted', () => { + expect(fromJGF({ graph: {} } as any).nodes).toEqual([]); + expect(fromJGF({ graph: { nodes: null, edges: null } } as any).edges).toEqual([]); + }); + + it('accepts the former hybrid dialect and permits explicit standards-valid v1', () => { + const graph = fromJGF({ graph: { id: 'old', directed: true, metadata: { data: { old: true } }, + nodes: [{ id: 'a', metadata: { data: { keep: 1 }, parentId: null } }], edges: [] } }); + expect(graph.id).toBe('old'); + expect(graph.nodes[0].data).toEqual({ keep: 1 }); + const next = toJGF(graph); + expect(next.graph.id).toBe('old'); + expect(toJGF(graph, { version: 1 }).graph.id).toBeUndefined(); + expect(fromJGF(next).id).toBe('old'); + }); +}); + + +describe('JGF legacy default type and wire compatibility', () => { + it('always emits the required string graph id, including empty identity', () => { + const graph = createGraph(); + const native = toJGF(graph); + const id: string = native.graph.id; + expect(id).toBe(''); + expect(Object.hasOwn(native.graph, 'id')).toBe(true); + expect(toJGF(graph, { version: 1 }).graph).not.toHaveProperty('id'); + }); +}); diff --git a/tests/formats/matrices.test.ts b/tests/formats/matrices.test.ts new file mode 100644 index 0000000..5c8c136 --- /dev/null +++ b/tests/formats/matrices.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { toAdjacencyMatrix, fromAdjacencyMatrix, adjacencyMatrixDocumentConverter } from '../../src/formats/adjacency-matrix'; +import { toMatrixMarket, fromMatrixMarket, matrixMarketDocumentConverter } from '../../src/formats/matrix-market'; +import { getNativeDocumentGraph } from '../../src/formats/preservation'; +function getPersisted(value: T): T { return JSON.parse(JSON.stringify(value)); } +function createFixture(mode: 'directed' | 'undirected' = 'directed') { + return createGraph({ mode, id: 'matrix', data: [null, 'root'], nodes: [{ id: '__proto__', label: '', data: 42, ports: [{ name: 'p', data: null }] }, { id: 'constructor', label: null, data: { weight: 'user' } }, { id: 'isolated', data: null }], edges: [{ id: 'zero', sourceId: '__proto__', targetId: 'constructor', weight: 0, data: ['edge'] }, { id: 'negative', sourceId: 'constructor', targetId: 'constructor', weight: -2.5 }, { id: 'default', sourceId: 'isolated', targetId: '__proto__' }] }); +} +describe('adjacency matrix explicit profile', () => { + it.each(['directed', 'undirected'] as const)('round-trips complete Graph JSON in %s mode including zero presence and isolates', (mode) => { + const graph = createFixture(mode), source = toAdjacencyMatrix(graph); expect(source.nodeIds).toEqual(graph.nodes.map((node) => node.id)); + expect(source.matrix[0][1]).toBe(0); expect(source.presence![0][1]).toBe(true); expect(source.matrix[1][1]).toBe(-2.5); + const restored = getNativeDocumentGraph(fromAdjacencyMatrix(getPersisted(source)), 'adjacency-matrix', source); expect(getPersisted(restored)).toEqual(getPersisted(graph)); + const doc = getPersisted(adjacencyMatrixDocumentConverter.from(source)); expect(adjacencyMatrixDocumentConverter.to(doc)).toEqual(source); + doc.graph!.edges[0].weight = -4; const output = adjacencyMatrixDocumentConverter.to(doc); expect(output.matrix[0][1]).toBe(-4); if (mode === 'undirected') expect(output.matrix[1][0]).toBe(-4); + }); + it('plain native zero absence, empty IDs and null foreign metadata are retained', () => { + const source = { nodeIds: ['', '__proto__'], matrix: [[0, -1], [0, 0]], mode: 'directed' as const, __statelyai: null, extension: [1, null] }; + const doc = getPersisted(adjacencyMatrixDocumentConverter.from(source)); expect(doc.graph!.edges).toHaveLength(1); expect(adjacencyMatrixDocumentConverter.to(doc)).toEqual(source); + doc.graph!.edges[0].weight = 0; const output = adjacencyMatrixDocumentConverter.to(doc); expect(output.presence![0][1]).toBe(true); expect(output.extension).toEqual([1, null]); expect(output.__statelyai).toBeNull(); + }); + it('current row order, coefficients and presence override snapshots without losing surviving identity', () => { + const source = toAdjacencyMatrix(createFixture()); source.matrix[1][1] = -3; source.presence![0][1] = false; + const graph = fromAdjacencyMatrix(source); expect(graph.edges.map((edge) => edge.id)).toEqual(['negative', 'default']); expect(graph.edges[0].weight).toBe(-3); + source.nodeIds.reverse(); const reinterpreted = fromAdjacencyMatrix(source); expect(reinterpreted.nodes.map((node) => node.id)).toEqual(source.nodeIds); expect(reinterpreted.edges.some((edge) => edge.sourceId === '__proto__' && edge.targetId === 'isolated')).toBe(true); + }); + it('new native coefficients inherit actual matrix direction over an authored root override', () => { + const graph = createGraph({ mode: 'directed', nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], edges: [{ id: 'u', sourceId: 'a', targetId: 'b', mode: 'undirected', weight: 1 }] }); + const source = toAdjacencyMatrix(graph); source.matrix[1][2] = 2; source.matrix[2][1] = 2; + const restored = fromAdjacencyMatrix(source); const added = restored.edges.find((edge) => edge.id !== 'u')!; expect(added.mode ?? restored.mode).toBe('undirected'); + }); + it.each(['sum', 'min', 'max', 'count'] as const)('explicit %s aggregation retains original IDs but rejects ambiguous coefficient edits', (aggregation) => { + const graph = createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'x', sourceId: 'a', targetId: 'b', weight: 2 }, { id: 'y', sourceId: 'a', targetId: 'b', weight: -1 }] }); + expect(() => toAdjacencyMatrix(graph)).toThrow('parallel'); const source = toAdjacencyMatrix(graph, { aggregation }); + expect(source.matrix[0][1]).toBe({ sum: 1, min: -1, max: 2, count: 2 }[aggregation]); expect(fromAdjacencyMatrix(source).edges.map((edge) => edge.id)).toEqual(['x', 'y']); + source.matrix[0][1] += 1; expect(() => fromAdjacencyMatrix(source)).toThrow('ambiguous'); + }); + it('maxCells rejects before dense allocation, sparse format remains practical', () => { + const graph = createGraph({ nodes: Array.from({ length: 20000 }, (_, index) => ({ id: `n${index}` })), edges: [{ id: 'edge', sourceId: 'n0', targetId: 'n19999', weight: 0 }] }); + expect(() => toAdjacencyMatrix(graph)).toThrow('maxCells'); const text = toMatrixMarket(graph); expect(text).toContain('20000 20000 1'); expect(fromMatrixMarket(text).nodes).toHaveLength(20000); + }); + it('count coefficients never infer individual weights, even for one original edge', () => { + const graph = createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 7 }] }); + const source = toAdjacencyMatrix(graph, { aggregation: 'count' }); expect(fromAdjacencyMatrix(source).edges[0].weight).toBe(7); expect(toAdjacencyMatrix(fromAdjacencyMatrix(source)).matrix[0][1]).toBe(1); + for (const change of ['weight', 'remove', 'add'] as const) { + const edited = getPersisted(source); if (change === 'weight') edited.matrix[0][1] = 2; else if (change === 'remove') edited.matrix[0][1] = 0; else edited.matrix[1][0] = 1; + expect(() => fromAdjacencyMatrix(edited)).toThrow('count aggregate'); + } + const text = toMatrixMarket(graph, { aggregation: 'count' }); expect(fromMatrixMarket(text).edges[0].weight).toBe(7); expect(toMatrixMarket(fromMatrixMarket(text))).toContain('1 2 1'); + expect(() => fromMatrixMarket(text.replace(/1 2 1\n$/, '1 2 2\n'))).toThrow('count aggregate'); + }); + it.each([ + { nodeIds: ['a'], matrix: [[NaN]], mode: 'directed' }, { nodeIds: ['a'], matrix: [], mode: 'directed' }, + { nodeIds: ['a', 'a'], matrix: [[0, 0], [0, 0]], mode: 'directed' }, { nodeIds: ['a'], matrix: [[1]], presence: [[false]], mode: 'directed' }, + { nodeIds: ['a', 'b'], matrix: [[0, 1], [0, 0]], mode: 'undirected' }, { nodeIds: ['a'], matrix: [[0]], presence: [[null]], mode: 'directed' }, + ])('rejects malformed or contradictory native matrix %#', (source) => expect(() => fromAdjacencyMatrix(source as any)).toThrow()); + it('native unknown source changes survive, structural/data edits reject and same coefficient conflicts diagnose', () => { + const source = toAdjacencyMatrix(createFixture()); const doc = adjacencyMatrixDocumentConverter.from(source); doc.source.unknown = { retained: true }; doc.graph!.edges[1].weight = -4; + expect(adjacencyMatrixDocumentConverter.to(doc).unknown).toEqual({ retained: true }); doc.source.matrix[1][1] = -5; expect(adjacencyMatrixDocumentConverter.getUpdate(doc).ok).toBe(false); + const data = adjacencyMatrixDocumentConverter.from(source); data.graph!.nodes[0].data = 1; expect(adjacencyMatrixDocumentConverter.getUpdate(data).ok).toBe(false); + }); +}); +describe('Matrix Market scoped grammar and source fidelity', () => { + it.each(['coordinate', 'array'] as const)('complete Graph JSON, zero presence, comments and native edits in %s storage', (storage) => { + const graph = createFixture(), source = toMatrixMarket(graph, { storage }); const restored = getNativeDocumentGraph(fromMatrixMarket(source), 'matrix-market', source); expect(getPersisted(restored)).toEqual(getPersisted(graph)); + const doc = getPersisted(matrixMarketDocumentConverter.from(source)); expect(matrixMarketDocumentConverter.to(doc)).toBe(source); doc.graph!.edges[1].weight = -7; + expect(fromMatrixMarket(matrixMarketDocumentConverter.to(doc)).edges.find((edge) => edge.id === 'negative')!.weight).toBe(-7); + }); + it('stored coordinate zeros are edges even without metadata; CRLF/comments/order/spacing survive native coefficient patch', () => { + const source = '%%MatrixMarket matrix coordinate real general\r\n% before\r\n3 3 2\r\n 3\t1\t-2.5 \r\n% between records\r\n1 2 0\r\n'; + const doc = getPersisted(matrixMarketDocumentConverter.from(source)); expect(doc.graph!.edges.map((edge) => edge.weight)).toEqual([-2.5, 0]); doc.graph!.edges[0].weight = -4; + expect(matrixMarketDocumentConverter.to(doc)).toBe(source.replace('-2.5', '-4')); expect(matrixMarketDocumentConverter.to(getPersisted(matrixMarketDocumentConverter.from(source)))).toBe(source); + }); + it('array column-major and symmetric lower triangle map correctly; dense zeros are absent', () => { + const general = fromMatrixMarket('%%MatrixMarket matrix array real general\n2 2\n1\n2\n3\n0\n'); expect(general.edges.map((edge) => [edge.sourceId, edge.targetId, edge.weight])).toEqual([['0', '0', 1], ['1', '0', 2], ['0', '1', 3]]); + const symmetric = fromMatrixMarket('%%MatrixMarket matrix array integer symmetric\n2 2\n0\n-2\n3\n'); expect(symmetric.mode).toBe('undirected'); expect(symmetric.edges.map((edge) => [edge.sourceId, edge.targetId, edge.weight])).toEqual([['1', '0', -2], ['1', '1', 3]]); + }); + it('coordinate symmetric diagonals and pattern unit coefficients are explicit', () => { + const source = '%%MatrixMarket matrix coordinate pattern symmetric\n2 2 2\n2 1\n2 2\n'; const graph = fromMatrixMarket(source); expect(graph.edges.map((edge) => edge.weight)).toEqual([1, 1]); expect(graph.mode).toBe('undirected'); + const doc = matrixMarketDocumentConverter.from(source); doc.graph!.edges[0].weight = 2; expect(matrixMarketDocumentConverter.getUpdate(doc).ok).toBe(false); + }); + it('general undirected coordinate zeros require mirrored stored presence in both orientations', () => { + for (const [row, col] of [[1, 2], [2, 1]]) expect(() => fromMatrixMarket(`%%MatrixMarket matrix coordinate real general\n2 2 1\n${row} ${col} 0\n`, { mode: 'undirected' })).toThrow('stored presence'); + const mirrored = '%%MatrixMarket matrix coordinate real general\n2 2 2\n2 1 0\n1 2 0\n'; expect(fromMatrixMarket(mirrored, { mode: 'undirected' }).edges.map((edge) => edge.weight)).toEqual([0]); + const duplicated = '%%MatrixMarket matrix coordinate real general\n2 2 3\n2 1 0\n1 2 0\n1 2 0\n'; expect(() => fromMatrixMarket(duplicated, { mode: 'undirected' })).toThrow('duplicate'); expect(fromMatrixMarket(duplicated, { mode: 'undirected', aggregation: 'sum' }).edges[0].weight).toBe(0); + }); + it('duplicate coordinates reject by default, explicit sum is mathematical aggregation with declared loss', () => { + const source = '%%MatrixMarket matrix coordinate real general\n2 2 2\n1 2 2\n1 2 -1\n'; expect(() => fromMatrixMarket(source)).toThrow('duplicate'); expect(fromMatrixMarket(source, { aggregation: 'sum' }).edges[0].weight).toBe(1); + const doc = matrixMarketDocumentConverter.from(source); expect(doc.graph).toBeNull(); expect(matrixMarketDocumentConverter.to(getPersisted(doc))).toBe(source); + }); + it('explicit nodeIds cannot override recognized sidecar silently', () => { + const source = toMatrixMarket(createFixture()); expect(() => fromMatrixMarket(source, { nodeIds: ['x', 'y', 'z'] })).toThrow('conflicts'); + expect(fromMatrixMarket(source, { nodeIds: ['x', 'y', 'z'], metadata: 'native' }).nodes.map((node) => node.id)).toEqual(['x', 'y', 'z']); + }); + it.each([ + '%%MatrixMarket matrix coordinate complex general\n2 2 1\n1 2 1 2\n', '%%MatrixMarket matrix coordinate real hermitian\n2 2 0\n', + '%%MatrixMarket matrix coordinate real skew-symmetric\n2 2 0\n', '%%MatrixMarket matrix coordinate real general\n2 3 0\n', + '%%MatrixMarket matrix coordinate real general\n2 2 2\n1 2 1\n', '%%MatrixMarket matrix coordinate real general\n2 2 1\n0 2 1\n', + '%%MatrixMarket matrix coordinate real symmetric\n2 2 1\n1 2 1\n', '%%MatrixMarket matrix coordinate integer general\n2 2 1\n1 2 1.5\n', + '%%MatrixMarket matrix coordinate real general\n100000000000 100000000000 0\n', '%%MatrixMarket matrix array real general\n10000 10000\n', + ])('opaque preservation for unsupported/malformed native source %#', (source) => { + expect(() => fromMatrixMarket(source)).toThrow(); const doc = matrixMarketDocumentConverter.from(source); expect(doc.graph).toBeNull(); expect(matrixMarketDocumentConverter.to(getPersisted(doc))).toBe(source); + }); + it('bulk sparse document weights update every stable record once and preserve comments/order', () => { + const count = 10000, source = `%%MatrixMarket matrix coordinate real general\n% bulk\n${count + 1} ${count + 1} ${count}\n` + Array.from({ length: count }, (_, index) => `${index + 1} ${index + 2} 1\n`).join(''); + const doc = matrixMarketDocumentConverter.from(source); for (const edge of doc.graph!.edges) edge.weight = -2; + const output = matrixMarketDocumentConverter.to(doc); expect(output).toBe(source.replace(/ 1\n/g, ' -2\n')); expect(fromMatrixMarket(output).edges).toHaveLength(count); + }, 30_000); + it('untrusted zero-presence sidecars remain ordinary data rather than unsafe tuple projections', () => { + const source = toMatrixMarket(createFixture(), { storage: 'array' }), lines = source.split('\n'), metadata = JSON.parse(lines[1].slice('% @statelyai/graph/matrix '.length)); metadata.zeroPresence = [null]; lines[1] = '% @statelyai/graph/matrix ' + JSON.stringify(metadata); + expect(() => fromMatrixMarket(lines.join('\n'))).not.toThrow(); expect(fromMatrixMarket(lines.join('\n')).edges.some((edge) => edge.weight === 0)).toBe(false); + }); + +}); diff --git a/tests/formats/mermaid/expanded.test.ts b/tests/formats/mermaid/expanded.test.ts new file mode 100644 index 0000000..276d679 --- /dev/null +++ b/tests/formats/mermaid/expanded.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from 'vitest'; +import { fromMermaidArchitecture, toMermaidArchitecture } from '../../../src/formats/mermaid/architecture'; +import { fromMermaidRequirement, toMermaidRequirement } from '../../../src/formats/mermaid/requirement'; +import { fromMermaidSankey, toMermaidSankey } from '../../../src/formats/mermaid/sankey'; +import { fromMermaidGitGraph, toMermaidGitGraph } from '../../../src/formats/mermaid/git-graph'; +import { fromMermaidC4, toMermaidC4 } from '../../../src/formats/mermaid/c4'; +import { fromMermaidUseCase, toMermaidUseCase } from '../../../src/formats/mermaid/use-case'; + +export const expandedProfiles = { + architecture: 'architecture-beta\ngroup api(cloud)[API]\nservice db(database)[Database] in api\nservice web(server)[Web] in api\njunction hub in api\ndb:R --> L:web\nweb:B -- T:hub', + requirement: 'requirementDiagram\nrequirement secure {\nid: R1\ntext: "Secure 東京"\nrisk: high\nverifymethod: test\n}\nelement app {\ntype: software\ndocref: "spec v1"\n}\napp - satisfies -> secure', + sankey: 'sankey-beta\nSource,Store,2.5\nStore,Target,2', + git: 'gitGraph\ncommit id: "a"\nbranch develop order: 2\ncommit id: "b" tag: "β"\ncheckout main\ncommit id: "c" type: HIGHLIGHT\nmerge develop id: "m"', + c4: 'C4Container\nPerson(user, "User", "Customer")\nContainer_Boundary(api, "API") {\nContainer(web, "Web", "JS", "Frontend")\nContainerDb(db, "Store", "SQL", "Database")\n}\nRel(user, web, "Uses", "HTTPS")\nRel(web, db, "Reads", "SQL")', + usecase: 'usecase-beta\nactor User("User")\nsystemBoundary app["App"]\nLogin("Login")\nManage["Manage"]\nend\nUser --> Login\nManage ..> : include Login', +}; +describe('expanded Mermaid graph profiles', () => { + it('maps architecture groups, native sides/ports and arrow modes', () => { + const graph = fromMermaidArchitecture(expandedProfiles.architecture); + expect(graph.nodes.find((node) => node.id === 'db')).toMatchObject({ parentId: 'api', data: { kind: 'service', icon: 'database' } }); + expect(graph.edges[0]).toMatchObject({ sourceId: 'db', sourcePort: 'R', targetPort: 'L', mode: 'directed' }); + expect(fromMermaidArchitecture(toMermaidArchitecture(graph))).toEqual(graph); + }); + it('maps requirement semantic fields and typed relationships', () => { + const graph = fromMermaidRequirement(expandedProfiles.requirement); + expect(graph.nodes[0]).toMatchObject({ label: 'Secure 東京', data: { requirementId: 'R1', risk: 'high', verifyMethod: 'test' } }); + expect(graph.edges[0].data.relationship).toBe('satisfies'); + expect(fromMermaidRequirement(toMermaidRequirement(graph))).toEqual(graph); + }); + it('maps weighted Sankey names and values', () => { + const graph = fromMermaidSankey(expandedProfiles.sankey); + expect(graph.edges.map((edge) => edge.weight)).toEqual([2.5, 2]); + expect(fromMermaidSankey(toMermaidSankey(graph))).toEqual(graph); + }); + it('maps ordered git actions to a commit DAG', () => { + const graph = fromMermaidGitGraph(expandedProfiles.git); + expect(graph.edges.filter((edge) => edge.sourceId === 'm').map((edge) => edge.targetId)).toEqual(['c', 'b']); + expect(fromMermaidGitGraph(toMermaidGitGraph(graph))).toEqual(graph); + }); + it('maps C4 typed nodes, boundaries and relation technology', () => { + const graph = fromMermaidC4(expandedProfiles.c4); + expect(graph.nodes.find((node) => node.id === 'db')).toMatchObject({ parentId: 'api', data: { kind: 'ContainerDb', technology: 'SQL' } }); + expect(graph.edges[1].data.technology).toBe('SQL'); + expect(fromMermaidC4(toMermaidC4(graph))).toEqual(graph); + }); + it('maps use-case boundaries, actor kinds and include semantics', () => { + const graph = fromMermaidUseCase(expandedProfiles.usecase); + expect(graph.nodes.find((node) => node.id === 'Login')?.parentId).toBe('app'); + expect(graph.edges[1].data.relationship).toBe('include'); + expect(fromMermaidUseCase(toMermaidUseCase(graph))).toEqual(graph); + }); +}); + +import { createGraph } from '../../../src/graph'; +import { escapeMermaidId } from '../../../src/formats/mermaid/shared'; +import { mermaidArchitectureDocumentConverter, mermaidRequirementDocumentConverter, mermaidC4DocumentConverter, mermaidUseCaseDocumentConverter, mermaidSankeyDocumentConverter, mermaidGitGraphDocumentConverter } from '../../../src/formats/mermaid/document'; +const documentCodecs = { + architecture: mermaidArchitectureDocumentConverter, requirement: mermaidRequirementDocumentConverter, + c4: mermaidC4DocumentConverter, usecase: mermaidUseCaseDocumentConverter, sankey: mermaidSankeyDocumentConverter, git: mermaidGitGraphDocumentConverter, +}; +const text = '東京 "quote" \\ slash [] () {} #quot;\nnext'; +describe('expanded profile boundaries and persistence', () => { + it.each(Object.entries(documentCodecs))('%s preserves exact comments/frontmatter/directives and unknown syntax after JSON persistence', (kind, codec) => { + const source = `---\ntitle: opaque\n---\n%%{init: {"theme":"base"}}%%\n%% comment\n${expandedProfiles[kind as keyof typeof expandedProfiles]}\n%% trailing\n`; + const document = codec.from(source); + expect(document.graph).not.toBeNull(); + expect(codec.to(JSON.parse(JSON.stringify(document)) as any)).toBe(source); + const unsupported = codec.from(`${source}\nunknown future statement\n`); + expect(unsupported.graph).toBeNull(); + expect(codec.to(JSON.parse(JSON.stringify(unsupported)) as any)).toBe(`${source}\nunknown future statement\n`); + }); + it.each(['architecture', 'requirement', 'c4', 'usecase'] as const)('%s patches verified labels without changing comments/source extensions', (kind) => { + const codec = documentCodecs[kind]; + const source = `%% Before comment\n${expandedProfiles[kind]}\n%% After comment\n`; + const document = codec.from(source); + const node = kind === 'architecture' ? document.graph!.nodes.find((node) => node.id === 'db')! : kind === 'c4' ? document.graph!.nodes.find((node) => node.id === 'web')! : kind === 'usecase' ? document.graph!.nodes.find((node) => node.id === 'Login')! : document.graph!.nodes[0]; + node.label = text; + const result = codec.to(document as any); + expect(result.startsWith('%% Before comment\n')).toBe(true); + expect(result.endsWith('\n%% After comment\n')).toBe(true); + expect(codec.from(result).graph!.nodes.find((item) => item.id === node.id)!.label).toBe(text); + node.data = null as any; + expect(() => codec.to(document as any)).toThrow('unsupported-edit'); + }); + it('uses canonical architecture modes and preserves reverse/group endpoints', () => { + const graph = fromMermaidArchitecture('architecture-beta\ngroup g[Group]\nservice a[A] in g\nservice b[B]\na{group}:R <-- L:b'); + expect(graph.edges[0]).toMatchObject({ sourceId: 'b', targetId: 'a', sourcePort: 'L', targetPort: 'R', data: { targetGroup: true } }); + expect(fromMermaidArchitecture(toMermaidArchitecture(graph))).toEqual(graph); + graph.edges[0].mode = 'bidirectional'; + expect(toMermaidArchitecture(graph)).toContain('<-->'); + graph.edges[0].mode = 'undirected'; + expect(fromMermaidArchitecture(toMermaidArchitecture(graph)).edges[0].mode).toBe('undirected'); + delete graph.edges[0].mode; graph.mode = 'undirected'; + expect(toMermaidArchitecture(graph)).not.toContain('<--'); + }); + it('keeps arbitrary escaped IDs injective, numeric starting IDs and Unicode labels', () => { + const ids = ['a b', '__stately_id_0061', '1', '東京', 'constructor', '__proto__']; + const graph = fromMermaidArchitecture(`architecture-beta\n${ids.map((id) => `service ${escapeMermaidId(id)}[Label]`).join('\n')}`); + graph.nodes[0].label = text; + expect(fromMermaidArchitecture(toMermaidArchitecture(graph)).nodes.map((node) => node.id)).toEqual(ids); + expect(fromMermaidArchitecture(toMermaidArchitecture(graph)).nodes[0].label).toBe(text); + }); + it('retains every requirement type, risk, verification and reversed relationship', () => { + const graph = fromMermaidRequirement('requirementDiagram\nfunctionalRequirement "a name" {\nid: "business ID"\nrisk: Low\nverifymethod: Analysis\n}\ndesignConstraint b {\ntext: B\nrisk: medium\nverifymethod: demonstration\n}\n"a name" <- refines - b\ndirection RL'); + expect(graph.direction).toBe('left'); + expect(graph.edges[0]).toMatchObject({ sourceId: 'b', targetId: 'a name', data: { relationship: 'refines' } }); + graph.nodes[0].label = text; + expect(fromMermaidRequirement(toMermaidRequirement(graph))).toEqual(graph); + }); + it('retains weighted quoted Sankey names, parallels, zeroes and a documented unit default', () => { + const graph = fromMermaidSankey('sankey-beta\n"A,ASCII","B ""quoted"" \\ slash",0\n"A,ASCII","B ""quoted"" \\ slash",3e-2'); + expect(graph.edges.map((edge) => edge.weight)).toEqual([0, 0.03]); + expect(fromMermaidSankey(toMermaidSankey(graph))).toEqual(graph); + delete graph.edges[0].weight; + expect(fromMermaidSankey(toMermaidSankey(graph)).edges[0].weight).toBe(1); + expect(fromMermaidSankey(toMermaidSankey(graph, { defaultWeight: 2 })).edges[0].weight).toBe(2); + }); + it('preserves C4 levels, nested deployment boundaries and named arguments', () => { + const graph = fromMermaidC4('C4Deployment\nDeployment_Node(host, "Host", "VM", "Description") {\nContainer_Boundary(g, "Group") {\nContainer(a, "A,ASCII", "JS", "", $tags="v1", $link="https://example.test")\n}\n}\nPerson(b, "B")\nRel_Back(a, b, "Reverse", "HTTP", $tags="r")'); + expect(graph.nodes[2]).toMatchObject({ parentId: 'g', data: { namedArguments: [{ name: '$tags', value: 'v1' }, { name: '$link', value: 'https://example.test' }] } }); + graph.nodes[2].label = text; + expect(fromMermaidC4(toMermaidC4(graph))).toEqual(graph); + graph.edges[0].mode = 'bidirectional'; + expect(toMermaidC4(graph)).toContain('BiRel('); + }); + it('preserves UseCase native variants, metadata, stereotypes, explicit edges/styles and notes/JSON data', () => { + const source = 'usecase-beta\ndirection LR\nactor A("Actor")@{ type: hollow, business: true } <>:::blue\nactor B\nCase["Case"]\nOther("Other")\nA use@-- "Starts" ---> Case\nCase ..> : extend Other\nA --|> B\nnote for A "Keep this"\njson Payload@{ "active": true, "none": null, "value": [1, false] }\nCase --> Payload\nuse@{ animation: fast }\nstyle use stroke:#06c\nclassDef blue fill:#eef\nclass Case blue'; + const graph = fromMermaidUseCase(source); + expect(graph.nodes.find((node) => node.id === 'A')!.data).toMatchObject({ properties: { type: 'hollow', business: true }, stereotype: 'Employee', classes: ['blue'] }); + expect(graph.edges[0]).toMatchObject({ id: 'use', label: 'Starts', data: { explicitId: true, minlen: 2, properties: { animation: 'fast' } }, style: { stroke: '#06c' } }); + expect(graph.nodes.find((node) => node.id === 'Payload')!.data.value!.none).toBeNull(); + expect(fromMermaidUseCase(toMermaidUseCase(graph))).toEqual(graph); + }); + it('uses canonical association modes and keeps marker orientation after edits', () => { + const graph = fromMermaidUseCase('usecase-beta\nactor A\nB\nB o-- A'); + expect(graph.edges[0]).toMatchObject({ sourceId: 'A', targetId: 'B' }); + expect(fromMermaidUseCase(toMermaidUseCase(graph))).toEqual(graph); + graph.edges[0].mode = 'undirected'; + expect(fromMermaidUseCase(toMermaidUseCase(graph)).edges[0].mode).toBe('undirected'); + }); + it('preserves GitGraph switch, tags, anonymous commits, branch order and cherry-pick parent history', () => { + const graph = fromMermaidGitGraph('gitGraph TB:\ncommit id: "a"\nbranch "feature branch" order: 1\ncommit id: "b" tag: "β" type: REVERSE\nswitch main\ncommit\ncherry-pick id: "b"'); + expect(graph.direction).toBe('down'); + expect(graph.nodes.at(-1)!.data.kind).toBe('cherry-pick'); + expect(fromMermaidGitGraph(toMermaidGitGraph(graph))).toEqual(graph); + graph.nodes[1].data.tags = [text]; + expect(fromMermaidGitGraph(toMermaidGitGraph(graph)).nodes[1].data.tags).toEqual([text]); + graph.edges[0].targetId = 'b'; + expect(() => toMermaidGitGraph(graph)).toThrow('conflict'); + }); + it.each([ + ['architecture', 'service a[A]\nservice a[B]'], ['architecture', 'service a[A] in absent'], ['architecture', 'service a[A]\na:R -- L:missing'], ['architecture', 'junction a[bad]'], ['architecture', 'group a[A] in b\ngroup b[B] in a'], + ['requirement', 'requirement a {\nrisk: fatal\n}'], ['requirement', 'requirement a {\nverifymethod: unknown\n}'], ['requirement', 'requirement a {\ntext: "Open"'], ['requirement', 'a - satisfies -> b'], ['requirement', 'element a {\ntext: invalid\n}'], + ['sankey', 'A,B,-1'], ['sankey', 'A,B,Infinity'], ['sankey', 'A,B,unknown'], ['sankey', 'A,B'], + ['git', 'checkout missing'], ['git', 'branch main'], ['git', 'commit id: "a"\ncommit id: "a"'], ['git', 'commit type: BAD'], ['git', 'commit id: "a"\nmerge main'], ['git', 'cherry-pick id: "missing"'], + ['c4', 'Boundary(a, "A") {'], ['c4', '}'], ['c4', 'Rel(a, b, "Missing")'], ['c4', 'Person(a)'], ['c4', 'Person(a, "A") {\n}'], + ['usecase', 'end'], ['usecase', 'systemBoundary g\nsystemBoundary h'], ['usecase', 'actor A\nB\nA ..> : include B'], ['usecase', 'actor A\nB\nA --|> B'], ['usecase', 'A@{ business: unknown }'], ['usecase', 'json A@{ invalid }'], + ] as const)('rejects malformed/unrepresentable %s: %s', (kind, body) => { + const header = expandedProfiles[kind].split('\n')[0]; + expect(documentCodecs[kind].from(`${header}\n${body}`).graph).toBeNull(); + }); + it('rejects unrepresentable exported topology and stale ordered references explicitly', () => { + const sankey = fromMermaidSankey(expandedProfiles.sankey); + sankey.nodes.push({ type: 'node', id: 'Isolated', label: 'Isolated', data: null }); + expect(() => toMermaidSankey(sankey)).toThrow('isolated'); + const git = fromMermaidGitGraph(expandedProfiles.git); git.nodes[0].id = 'renamed'; + expect(() => toMermaidGitGraph(git)).toThrow('stale'); + expect(() => toMermaidGitGraph(createGraph({}) as any)).toThrow('ordered actions'); + const c4 = fromMermaidC4(expandedProfiles.c4); c4.edges[0].mode = 'undirected'; + expect(() => toMermaidC4(c4)).toThrow('undirected'); + const usecase = fromMermaidUseCase(expandedProfiles.usecase); usecase.edges[1].mode = 'undirected'; + expect(() => toMermaidUseCase(usecase)).toThrow('incompatible'); + }); +}); + +it('Sankey native names trim quoted outer whitespace and reject Unicode/unsafe outgoing identities', () => { + const graph = fromMermaidSankey('sankey\n" A "," B ", 2 '); + expect(graph.nodes.map((node) => node.id)).toEqual(['A', 'B']); + expect(graph.edges[0].weight).toBe(2); + const unsupported = mermaidSankeyDocumentConverter.from('sankey-beta\n東京,B,1'); + expect(unsupported.graph).toBeNull(); expect(mermaidSankeyDocumentConverter.to(unsupported)).toBe('sankey-beta\n東京,B,1'); + graph.nodes[0].id = ' A '; graph.nodes[0].label = ' A '; graph.edges[0].sourceId = ' A '; + expect(() => toMermaidSankey(graph)).toThrow('names are identities'); +}); +it('Requirement empty text omits the invalid empty string token while document comments survive', () => { + const graph = fromMermaidRequirement('requirementDiagram\nrequirement a {\n}\n'); + expect(graph.nodes[0].label).toBe(''); expect(toMermaidRequirement(graph)).not.toContain('text:'); + const document = mermaidRequirementDocumentConverter.from('requirementDiagram\nrequirement a {\n text: "Before" %% keep\n}\n'); + document.graph!.nodes[0].label = ''; + const source = mermaidRequirementDocumentConverter.to(document); + expect(source).toContain('%% keep'); expect(mermaidRequirementDocumentConverter.from(source).graph!.nodes[0].label).toBe(''); +}); +it('GitGraph live main branch overrides stale init and supports fresh trunk histories', () => { + const graph = fromMermaidGitGraph('%%{init: {"gitGraph":{"mainBranchName":"trunk"}}}%%\ngitGraph\ncommit id: "a"'); + expect(graph.data.mainBranch).toBe('trunk'); + graph.data.mainBranch = 'live'; graph.nodes[0].data.branch = 'live'; + expect(fromMermaidGitGraph(toMermaidGitGraph(graph)).nodes[0].data.branch).toBe('live'); + const fresh = { ...graph, data: { diagramType: 'gitGraph' as const, mainBranch: 'fresh', actions: [{ kind: 'commit' as const, nodeId: 'a' }] } }; + fresh.nodes = fresh.nodes.map((node) => ({ ...node, data: { ...node.data, branch: 'fresh' } })); + expect(fromMermaidGitGraph(toMermaidGitGraph(fresh)).data.mainBranch).toBe('fresh'); +}); +it('UseCase protects note identity/topology, reserved keys and non-JSON table data', () => { + const graph = fromMermaidUseCase('usecase-beta\nactor __note_0\nnote for __note_0 "Note"\njson Payload@{ "__proto__": null, "constructor": [false] }'); + expect(graph.nodes.find((node) => node.data.kind === 'note')!.id).not.toBe('__note_0'); + expect(Object.hasOwn(graph.nodes.find((node) => node.id === 'Payload')!.data.value!, '__proto__')).toBe(true); + expect(fromMermaidUseCase(toMermaidUseCase(graph))).toEqual(graph); + graph.edges = []; + expect(() => toMermaidUseCase(graph)).toThrow('stale note'); + const json = fromMermaidUseCase('usecase-beta\njson A@{ "safe": null }'); + json.nodes[0].data.value!.unsafe = () => true; + expect(() => toMermaidUseCase(json)).toThrow('JSON'); + expect(() => fromMermaidUseCase('usecase-beta\na\n__stately_id_0061')).toThrow('ambiguous'); +}); +it('encodes empty bare IDs injectively and preserves empty hierarchy parent references', () => { + const source = `architecture-beta\ngroup ${escapeMermaidId('')}[Group]\nservice child[Child] in ${escapeMermaidId('')}`; + const graph = fromMermaidArchitecture(source); + expect(graph.nodes[0].id).toBe(''); expect(graph.nodes[1].parentId).toBe(''); + expect(fromMermaidArchitecture(toMermaidArchitecture(graph))).toEqual(graph); + const usecase = fromMermaidUseCase(`usecase-beta\nsystemBoundary ${escapeMermaidId('')}[Group]\nChild[Child]\nend`); + expect(usecase.nodes[1].parentId).toBe(''); expect(fromMermaidUseCase(toMermaidUseCase(usecase))).toEqual(usecase); + expect(() => fromMermaidUseCase(`usecase-beta\nsystemBoundary ${escapeMermaidId('')}[Group]\nsystemBoundary second`)).toThrow('nested'); +}); +it('GitGraph changing only the canonical main branch updates inherited branch references', () => { + const graph = fromMermaidGitGraph('%%{init: {"gitGraph":{"mainBranchName":"trunk"}}}%%\ngitGraph\ncommit id: "a"\nbranch feature\ncommit id: "b"\ncheckout trunk\ncommit id: "c"'); + graph.data.mainBranch = 'live'; + const parsed = fromMermaidGitGraph(toMermaidGitGraph(graph)); + expect(parsed.nodes.map((node) => node.data.branch)).toEqual(['live', 'feature', 'live']); + expect(parsed.data.actions[3]).toEqual({ kind: 'checkout', name: 'live' }); + expect(graph.nodes[0].data.branch).toBe('trunk'); +}); +it('handles a ten-thousand-node flat architecture and a five-hundred-level C4 hierarchy iteratively', () => { + const source = `architecture-beta\n${Array.from({ length: 10000 }, (_, index) => `service n${index}[Node]`).join('\n')}`; + expect(fromMermaidArchitecture(toMermaidArchitecture(fromMermaidArchitecture(source))).nodes).toHaveLength(10000); + const c4 = `C4Container\n${Array.from({ length: 500 }, (_, index) => `Container_Boundary(g${index}, "Group") {`).join('\n')}\nContainer(leaf, "Leaf", "JS")\n${Array.from({ length: 500 }, () => '}').join('\n')}`; + const graph = fromMermaidC4(c4); + expect(fromMermaidC4(toMermaidC4(graph)).nodes.at(-1)?.parentId).toBe('g499'); +}); +it('rejects unsafe native display text while retaining JSON-escaped data and encoded IDs', () => { + for (const label of ['A\u0000B', 'A\ud800B', 'A\udc00B']) { + const document = mermaidUseCaseDocumentConverter.from('usecase-beta\nA("Before")'); + document.graph!.nodes[0].label = label; expect(() => mermaidUseCaseDocumentConverter.to(document)).toThrow('display'); + } + const graph = fromMermaidUseCase(`usecase-beta\nA("Astral 😀")\njson Payload@{ "nul": "\\u0000", "surrogate": "\\ud800" }`); + expect(fromMermaidUseCase(toMermaidUseCase(graph)).nodes[1].data.value).toEqual({ nul: '\u0000', surrogate: '\ud800' }); + const architecture = fromMermaidArchitecture(`architecture-beta\nservice ${escapeMermaidId('\ud800')}[Safe]`); + expect(fromMermaidArchitecture(toMermaidArchitecture(architecture)).nodes[0].id).toBe('\ud800'); + const opaque = mermaidUseCaseDocumentConverter.from('usecase-beta\nA("#0;")'); expect(opaque.graph).toBeNull(); expect(mermaidUseCaseDocumentConverter.to(opaque)).toBe('usecase-beta\nA("#0;")'); +}); + +it('normalizes C4 reverse markers while positional relations remain forward', () => { + const graph = fromMermaidC4('C4Context\nPerson(a, "A")\nPerson(b, "B")\nPerson(c, "C")\nRel_Back(a,b,"Back")\nRel_L(a,c,"Left")'); + expect(graph.edges.map(({ sourceId, targetId }) => [sourceId, targetId])).toEqual([['b', 'a'], ['a', 'c']]); + expect(toMermaidC4(graph)).toContain('Rel_Back(a, b, "Back")'); + expect(fromMermaidC4(toMermaidC4(graph))).toEqual(graph); + graph.edges[0].sourceId = 'a'; graph.edges[0].targetId = 'b'; + expect(toMermaidC4(graph)).toContain('Rel_Back(b, a, "Back")'); +}); + +it('rejects repeated C4 native relationship pairs instead of claiming parallel native edges', () => { + expect(() => fromMermaidC4('C4Context\nPerson(a,"A")\nPerson(b,"B")\nRel_Back(a,b,"Back")\nRel_U(a,b,"Up")')).toThrow('repeated native relationship'); + const document = mermaidC4DocumentConverter.from('C4Context\nPerson(a,"A")\nPerson(b,"B")\nRel(a,b,"First")\nRel(a,b,"Last")'); + expect(document.graph).toBeNull(); + expect(mermaidC4DocumentConverter.to(document)).toBe(document.source); +}); + +it('guards C4 display titles without restricting JSON-escaped opaque data', () => { + const graph = fromMermaidC4('C4Context\ntitle Astral 😀\nPerson(a,"A")'); + expect(toMermaidC4(graph)).toContain('title Astral 😀'); + graph.data.title = 'Lost\u0000'; expect(() => toMermaidC4(graph)).toThrow('display'); + expect(() => fromMermaidC4('C4Context\ntitle Lost\ud800\nPerson(a,"A")')).toThrow('Unicode'); +}); diff --git a/tests/formats/mermaid/sequence.test.ts b/tests/formats/mermaid/sequence.test.ts index 8998df5..7ddae0e 100644 --- a/tests/formats/mermaid/sequence.test.ts +++ b/tests/formats/mermaid/sequence.test.ts @@ -234,7 +234,7 @@ sequenceDiagram expect(graph.edges).toHaveLength(2); const bob = graph.nodes.find((n) => n.id === 'Bob')!; expect(bob.data.notes).toHaveLength(1); - expect(bob.data.notes![0]).toEqual({ position: 'right', text: 'Bob thinks' }); + expect(bob.data.notes![0]).toEqual({ id: 'note_0', position: 'right', text: 'Bob thinks' }); }); it('parses Note left of actor', () => { @@ -527,7 +527,8 @@ sequenceDiagram edges: [], data: { diagramType: 'sequence' }, }); - expect(output).toContain('database DB'); + expect(output).toContain('participant DB@{ "type":"database" }'); + expect(fromMermaidSequence(output).nodes[0].data.actorType).toBe('database'); }); it('serializes notes', () => { diff --git a/tests/formats/mixed-directedness.test.ts b/tests/formats/mixed-directedness.test.ts index b012676..05b8462 100644 --- a/tests/formats/mixed-directedness.test.ts +++ b/tests/formats/mixed-directedness.test.ts @@ -54,8 +54,8 @@ describe.each([ // Undirected override survives. expect(getEdgeMode(out, e2)).toBe('undirected'); expect(e2.mode).toBe('undirected'); - // Bidirectional has no native representation → collapses to directed. - expect(getEdgeMode(out, e3)).toBe('directed'); + // GEXF mutual and GraphML private mode metadata preserve bidirectional. + expect(getEdgeMode(out, e3)).toBe('bidirectional'); }); it('round-trips an undirected graph with a directed override', () => { diff --git a/tests/formats/pajek.test.ts b/tests/formats/pajek.test.ts new file mode 100644 index 0000000..6cc28c6 --- /dev/null +++ b/tests/formats/pajek.test.ts @@ -0,0 +1,86 @@ +import { getUserData } from '../../src/formats/xml-preservation'; +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { fromPajek, toPajek, pajekDocumentConverter } from '../../src/formats/pajek'; +import { getCarrierData } from '../../src/formats/preservation'; +function getGraph(graph: ReturnType) { return { ...graph, data: getCarrierData(graph.data, 'pajek') }; } +const native = '% KEEP\n*Network "Native"\n*Vertices 3\n1 "same"\n2 "same"\n3\n*Arcs\n1 2 2.5 l "OLD"\n1 2 3\n3 3 0\n'; +describe('bounded Pajek .net profile', () => { + it('preserves selected Graph fields/data without using labels as identity', () => { + const graph = createGraph({ id: '', mode: 'undirected', data: [null, { constructor: 'USER' }], initialNodeId: '0', nodes: [{ id: '0', label: null, data: false, x: 1, y: 2 }, { id: '1', label: '', parentId: '0', ports: [{ name: 'p', data: [1] }] }], edges: [{ id: 'pajek:edge:0', sourceId: '0', targetId: '1', targetPort: 'p', label: '', weight: 0, data: 42 }] }); + expect(getGraph(fromPajek(toPajek(graph)))).toEqual(graph); + }); + it('keeps distinct repeated-label vertices, parallel edges, zero weights and implicit isolates', () => { + const graph = fromPajek(native); + expect(graph.nodes.map((node) => [node.id, node.label])).toEqual([['1', 'same'], ['2', 'same'], ['3', null]]); + expect(graph.edges).toHaveLength(3); expect(graph.edges[0].weight).toBe(2.5); expect(graph.edges[2].weight).toBe(0); + expect(toPajek(JSON.parse(JSON.stringify(graph)))).toBe(native); + }); + it('preserves exact comments/order/newlines after document persistence and no-op source changes', () => { + const source = native.replace(/\n/g, '\r\n'); + const document = JSON.parse(JSON.stringify(pajekDocumentConverter.from(source))); + document.source = '% NATIVE\r\n' + source; + expect(pajekDocumentConverter.to(document)).toBe(document.source); + }); + it('patches literal node/edge labels and retains independent source comments/unknown attributes', () => { + const document = pajekDocumentConverter.from(native); + document.source = document.source.replace('1 "same"', '1 "same" 0.1 0.2 ellipse ic Red'); + document.graph!.nodes[0].label = 'CHANGED α'; document.graph!.edges[0].label = 'EDGE'; + const output = pajekDocumentConverter.to(document); + expect(output).toContain('1 "CHANGED α" 0.1 0.2 ellipse ic Red'); expect(output).toContain('2.5 l "EDGE"'); expect(output).toContain('% KEEP'); + }); + it('adds a literal label for an implicit vertex and a missing edge label', () => { + const source = '*Vertices 3\n1 "A"\n2\n*Edges\n1 2 1\n'; + const document = pajekDocumentConverter.from(source); + document.graph!.nodes[2].label = 'ISOLATED'; document.graph!.edges[0].label = ''; + const output = pajekDocumentConverter.to(document); + expect(output).toContain('3 "ISOLATED"\n*Edges'); expect(output).toContain('1 2 1 l ""'); + }); + it('keeps native label/weight/mode edits authoritative over private carriers', () => { + const graph = createGraph({ nodes: [{ id: 'a', label: null }, { id: 'b', label: '' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', label: null }] }); + const source = toPajek(graph).replace('\n1\n', '\n1 ""\n').replace('*Arcs', '*Edges').replace('\n1 2 1\n', '\n1 2 2.5 l ""\n'); + const result = fromPajek(source); + expect(result.nodes[0].label).toBe(''); expect(result.edges[0]).toMatchObject({ label: '', weight: 2.5, mode: 'undirected' }); + expect(result.mode).toBe('undirected'); + }); + it('reports same-field native/Graph conflicts', () => { + const document = pajekDocumentConverter.from(native); + document.source = native.replace('1 "same"', '1 "NATIVE"'); document.graph!.nodes[0].label = 'GRAPH'; + expect(() => pajekDocumentConverter.to(document)).toThrow(/conflict/); + }); + it('rejects ambiguous foreign edge attribute changes alongside requested edge labels', () => { + const document = pajekDocumentConverter.from(native); + document.source = native.replace('1 2 2.5', '1 2 4'); document.graph!.edges[0].label = 'GRAPH'; + expect(() => pajekDocumentConverter.to(document)).toThrow(/ambiguous/); + }); + it('permits independent new native vertices with a stable existing vertex-label patch', () => { + const source = '*Vertices 1\n1 "OLD"\n*Edges\n'; + const document = pajekDocumentConverter.from(source); document.source = source.replace('*Vertices 1', '*Vertices 2').replace('*Edges', '2 "ADDED"\n*Edges'); document.graph!.nodes[0].label = 'GRAPH'; + const result = fromPajek(pajekDocumentConverter.to(document)); expect(result.nodes.map((node) => node.label)).toEqual(['GRAPH', 'ADDED']); + }); + it.each(['*Vertices 2\n*Arcs\n1 2 1\n*Edges\n1 2 1\n', '*Vertices 2\n*Matrix\n0 1\n1 0\n', '*Vertices 2\n*Arcslist\n1 2\n', '*Vertices 2 1\n', '*Vertices 2\n1 "a" [1-2]\n', '*Vertices 1\n1 "a"\n*Edges\n1 2 1\n'])('rejects unsupported/invalid source while retaining exact document bytes', (source) => { + expect(() => fromPajek(source)).toThrow(); const document = pajekDocumentConverter.from(source); expect(document.graph).toBeNull(); expect(pajekDocumentConverter.to(JSON.parse(JSON.stringify(document)))).toBe(source); + }); + it.each(['a"b', 'a\nb', '\ud800', 'a\u0000b'])('rejects unrepresentable native visible labels: %j', (label) => { expect(() => toPajek(createGraph({ nodes: [{ id: 'a', label }] }))).toThrow(/label/); }); + it('rejects requested structural edits and genuine mixed/bidirectional output', () => { + const document = pajekDocumentConverter.from(native); document.graph!.nodes.push({ ...document.graph!.nodes[0], id: 'NEW' }); expect(() => pajekDocumentConverter.to(document)).toThrow(/unsupported/); + expect(() => toPajek(createGraph({ mode: 'bidirectional', nodes: [{ id: 'a' }] }))).toThrow(/mode/); + }); +}); + +it('emits only a header capsule outside fixed-count native vertex/edge rows', () => { + const source = toPajek(createGraph({ nodes: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }] })); + expect(source.split('\n').filter((line) => line.startsWith('%'))).toHaveLength(1); + expect(source).toContain('*Vertices 2\n1 "A"\n2 "B"\n*Arcs\n1 2 1'); + const capsule = JSON.parse(source.split('\n')[0].split('document ')[1]); + const legacy = `% @statelyai/graph/pajek graph ${JSON.stringify(capsule.graph)}\n*Vertices 2\n% @statelyai/graph/pajek node ${JSON.stringify(capsule.nodes[0].metadata)}\n1 "A"\n% @statelyai/graph/pajek node ${JSON.stringify(capsule.nodes[1].metadata)}\n2 "B"\n*Arcs\n% @statelyai/graph/pajek edge ${JSON.stringify(capsule.edges[0].metadata)}\n1 2 1\n`; + expect(getGraph(fromPajek(legacy))).toEqual(getGraph(fromPajek(source))); + expect(() => fromPajek(source.split('\n')[0] + '\n' + source)).toThrow(/ambiguous/); +}); +it('retains primitive user data alongside independently added native row attributes', () => { + const document = pajekDocumentConverter.from(toPajek(createGraph({ nodes: [{ id: 'a', label: 'OLD', data: 42 }] }))); + document.source = document.source.replace('1 "OLD"', '1 "OLD" ic Red'); document.graph!.nodes[0].label = 'NEW'; + const graph = fromPajek(pajekDocumentConverter.to(document)); + expect(graph.nodes[0].label).toBe('NEW'); expect(getUserData(graph.nodes[0].data, 'pajek')).toBe(42); + expect(graph.nodes[0].data._pajek.native.attributes).toEqual(['ic', 'Red']); +}); diff --git a/tests/formats/plantuml.test.ts b/tests/formats/plantuml.test.ts new file mode 100644 index 0000000..c77e0b6 --- /dev/null +++ b/tests/formats/plantuml.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { toPlantUML, fromPlantUML, plantumlDocumentConverter, createPlantUMLDocumentConverter } from '../../src/formats/plantuml'; +import { getNativeDocumentGraph } from '../../src/formats/preservation'; +function getPersisted(value: T): T { return JSON.parse(JSON.stringify(value)); } +describe('PlantUML component/class profile', () => { + it.each(['component', 'class'] as const)('full Graph JSON, members/styles/stereotypes/modes/hierarchy in %s with explicit carrier', (dialect) => { + const graph = createGraph({ id: 'uml', direction: 'right', data: [null, 'root'], nodes: [{ id: 'package', label: 'Group', data: null }, { id: '__proto__', parentId: 'package', label: 'Quote " path \\ 雪', data: dialect === 'class' ? { members: ['+name: String', '+f(): void'], stereotypes: 'entity', arbitrary: true } : { stereotypes: 'service' }, color: 'red' }, { id: 'constructor', label: null, data: 42 }], edges: [{ id: 'forward', sourceId: '__proto__', targetId: 'constructor', label: 'Calls', data: ['edge'] }, { id: 'both', sourceId: 'constructor', targetId: 'constructor', mode: 'bidirectional', weight: 0 }, { id: 'undirected', sourceId: 'constructor', targetId: '__proto__', mode: 'undirected', label: '' }] }); + const source = toPlantUML(graph, { dialect }); const restored = getNativeDocumentGraph(fromPlantUML(source), 'plantuml', source); expect(getPersisted(restored)).toEqual(getPersisted(graph)); + const converter = createPlantUMLDocumentConverter({ dialect }), doc = getPersisted(converter.from(source)); expect(converter.to(doc)).toBe(source); doc.graph!.nodes[1].label = 'Changed'; doc.graph!.edges[0].label = 'Changed link'; + const result = converter.to(doc); expect(fromPlantUML(result).nodes[1].label).toBe('Changed'); expect(fromPlantUML(result).edges[0].label).toBe('Changed link'); + }); + it('class native source preserves members, stereotypes, multiplicities, style, comments and CRLF while labels patch', () => { + const source = '@startuml\r\n!pragma layout smetana\r\nskinparam class {\r\n BackgroundColor white\r\n}\r\n\' comment\r\npackage "Folder" as p {\r\nclass\t"A" as a <> #red {\r\n +x: int\r\n \' member comment\r\n +f(): void\r\n}\r\n}\r\nclass "B" as b\r\na "1" --|> "many" b : extends \' keep trailing\r\n@enduml\r\n'; + const converter = createPlantUMLDocumentConverter({ dialect: 'class' }), doc = getPersisted(converter.from(source)); expect(doc.graph!.nodes[1].parentId).toBe('p'); expect(doc.graph!.nodes[1].data.members).toEqual(['+x: int', '+f(): void']); expect(doc.graph!.edges[0].data.sourceMultiplicity).toBe('1'); + expect(converter.to(doc)).toBe(source); doc.graph!.nodes[1].label = 'New A'; const output = converter.to(doc); expect(output).toBe(source.replace('"A" as a', '"New A" as a')); expect(output).toContain("' member comment"); + const unsupported = converter.from(source); unsupported.graph!.nodes[1].data.members[0] = '+new: int'; expect(converter.getUpdate(unsupported).ok).toBe(false); + }); + it.each([['a "1" <-- b', undefined, '1'], ['a <-- "many" b', 'many', undefined], ['a "1" <.. "many" b', 'many', '1']] as const)('normalizes one-sided reversed multiplicities: %s', (link, source, target) => { + const graph = fromPlantUML(`@startuml\nclass "A" as a\nclass "B" as b\n${link}\n@enduml\n`); expect(graph.edges[0].sourceId).toBe('b'); expect(graph.edges[0].targetId).toBe('a'); expect(graph.edges[0].data.sourceMultiplicity).toBe(source); expect(graph.edges[0].data.targetMultiplicity).toBe(target); + }); + it('native escaped quotes preserve their visible backslash; fresh literal quotes/backslashes/markup and LF use verified encoding', () => { + expect(fromPlantUML('@startuml\ncomponent A\ncomponent B\nA --> B : "Quoted"\n@enduml\n').edges[0].label).toBe('Quoted'); + const native = '@startuml\nclass "Quote \\" value" as a\n@enduml\n'; expect(fromPlantUML(native).nodes[0].label).toBe('Quote \\" value'); + const graph = createGraph({ nodes: [{ id: 'a', label: '**literal** " " \\ \nSecond' }] }); const source = toPlantUML(graph, { dialect: 'class' }); expect(source).toContain('"'); expect(getNativeDocumentGraph(fromPlantUML(source), 'plantuml', source).nodes[0].label).toBe(graph.nodes[0].label); + }); + it('plain implicit aliases get explicit stable alias under supported label edit', () => { + const source = '@startuml\ncomponent A\ncomponent B\nA --> B : Calls\n@enduml\n'; const doc = plantumlDocumentConverter.from(source); doc.graph!.nodes[0].label = 'Changed'; const output = plantumlDocumentConverter.to(doc); + expect(output).toContain('component "Changed" as A'); expect(fromPlantUML(output).edges[0].sourceId).toBe('A'); + }); + it('native source changes/opaque styles remain authoritative, same-field conflicts reject', () => { + const source = '@startuml\ncomponent "A" as a\ncomponent "B" as b\na --> b\n@enduml\n'; const doc = plantumlDocumentConverter.from(source); doc.source = source.replace('component "A"', 'component "Native"'); expect(plantumlDocumentConverter.to(doc)).toBe(doc.source); doc.graph!.nodes[0].label = 'Graph'; expect(plantumlDocumentConverter.getUpdate(doc).ok).toBe(false); + const independent = plantumlDocumentConverter.from(source); independent.source = source.replace('@enduml', 'skinparam ArrowColor red\n@enduml'); independent.graph!.nodes[0].label = 'Changed'; expect(plantumlDocumentConverter.to(independent)).toContain('skinparam ArrowColor red'); + }); + it.each(['start\n:A;\nstop', 'state A\nstate B\nA --> B', 'participant A\nparticipant B\nA -> B: call', '!include unknown.puml', 'component A\nclass B', 'class A {\n+x:int\n'])('unsupported/opaque native source stays exact, no executable flattening: %s', (body) => { + const source = `@startuml\n${body}\n@enduml\n`, doc = plantumlDocumentConverter.from(source); expect(doc.graph).toBeNull(); expect(plantumlDocumentConverter.to(getPersisted(doc))).toBe(source); + }); + it('empty native display names retain opaque source rather than projecting invalid declarations', () => { + const source = '@startuml\nclass "" as a\n@enduml\n'; expect(() => fromPlantUML(source)).toThrow('empty'); const converter = createPlantUMLDocumentConverter({ dialect: 'class' }); const doc = converter.from(source); expect(doc.graph).toBeNull(); expect(converter.to(getPersisted(doc))).toBe(source); + }); + it('malformed/unsupported fresh fields reject instead of emitting invalid or truncated native meaning', () => { + expect(() => toPlantUML(createGraph({ nodes: [{ id: 'a', label: '' }] }), { dialect: 'class' })).toThrow('empty'); + expect(() => toPlantUML(createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', label: "it's truncated" }] }))).toThrow('comment-starting'); + expect(() => toPlantUML(createGraph({ nodes: [{ id: 'a', data: { members: ['!include unknown'] } }] }), { dialect: 'class' })).toThrow('member'); + }); + it('native unquoted component/interface aliases are valid; class/enum aliases require quoted display names', () => { + const source = '@startuml\ncomponent Employee as Worker\n@enduml\n'; expect(fromPlantUML(source).nodes[0].id).toBe('Worker'); + expect(fromPlantUML('@startuml\ninterface Employee as Worker\n@enduml\n').nodes[0].id).toBe('Worker'); + for (const kind of ['class', 'enum', 'abstract class']) expect(() => fromPlantUML(`@startuml\n${kind} Employee as Worker\n@enduml\n`)).toThrow('quoted'); + }); + it.each(['component', 'class'] as const)('authored children-before-parent order survives fresh %s hierarchy emission', (dialect) => { + const graph = createGraph({ nodes: [{ id: 'child', parentId: 'parent', label: 'Child' }, { id: 'parent', label: 'Parent' }, { id: 'second', parentId: 'parent', label: 'Second' }] }); + const source = toPlantUML(graph, { dialect }); expect(getNativeDocumentGraph(fromPlantUML(source), 'plantuml', source)).toEqual(getPersisted(graph)); + }); + it('foreign class kinds, directions, block comments and explicit labels preserve complete source', () => { + const source = '@startuml\n/\' block\ncomment \'/\nleft to right direction\ninterface Employee as Worker\nabstract class "Parent" as p {\n+f(): void\n}\nenum "Choice" as c {\nONE\nTWO\n}\nWorker ..> p : uses\n@enduml\n'; + const converter = createPlantUMLDocumentConverter({ dialect: 'class' }), doc = getPersisted(converter.from(source)); expect(doc.graph!.direction).toBe('right'); expect(doc.graph!.nodes.map((node) => node.data.kind)).toEqual(['interface', 'abstract class', 'enum']); expect(converter.to(doc)).toBe(source); + doc.graph!.edges[0].label = 'updated'; expect(converter.to(doc)).toBe(source.replace(': uses', ': updated')); doc.graph!.nodes[0].color = 'red'; expect(converter.getUpdate(doc).ok).toBe(false); + }); + it('unsupported label patches and real user metadata edits never silently replay source', () => { + const source = '@startuml\n[Simple] as a\ncomponent "B" as b\na --> b : Calls\n@enduml\n'; + for (const label of ['', 'Line\nBreak', '**markup**']) { const doc = plantumlDocumentConverter.from(source); doc.graph!.nodes[0].label = label; expect(plantumlDocumentConverter.getUpdate(doc).ok).toBe(false); } + const graph = createGraph({ data: { user: true }, nodes: [{ id: 'a', label: 'A', data: { label: 'actual user' } }] }); const emitted = toPlantUML(graph), doc = plantumlDocumentConverter.from(emitted); doc.graph!.nodes[0].data.label = 'changed user'; expect(plantumlDocumentConverter.getUpdate(doc).ok).toBe(false); + const quoted = plantumlDocumentConverter.from('@startuml\ncomponent "Implicit name"\n@enduml\n'); quoted.graph!.nodes[0].label = 'Changed'; expect(plantumlDocumentConverter.getUpdate(quoted).ok).toBe(false); + }); + it.each(['@startuml\ncomponent A\ncomponent A\n@enduml\n', '@startuml\ncomponent A\nA --> B\n@enduml\n', '@startuml\npackage P\n@enduml\n', '@startuml\n}\n@enduml\n', '@startuml\ncomponent A {\n}\n@enduml\n', '@startuml\nclass A {\n{nested}\n}\n@enduml\n', '@startuml\n@startuml\n@enduml\n', '@startuml\n@enduml\ncomponent A\n', '@startuml\n[**rich**] as a\n@enduml\n', '@startuml\n/\' incomplete\n@enduml\n', '@startuml\nskinparam class {\n@enduml\n'])('malformed scoped source is opaque and exact %#', (source) => { + expect(() => fromPlantUML(source)).toThrow(); const doc = plantumlDocumentConverter.from(source); expect(doc.graph).toBeNull(); expect(plantumlDocumentConverter.to(getPersisted(doc))).toBe(source); + }); + it('invalid private Graph protocol is ordinary source text, never an invalid typed projection', () => { + const source = toPlantUML(createGraph({ nodes: [{ id: 'a', label: 'A' }] })); const lines = source.split('\n'), metadata = JSON.parse(lines[1].slice("' @statelyai/graph/plantuml ".length)); metadata.graph.nodes[0].label = 42; lines[1] = "' @statelyai/graph/plantuml " + JSON.stringify(metadata); const foreign = lines.join('\n'); + expect(fromPlantUML(foreign).nodes[0].label).toBe('A'); expect(plantumlDocumentConverter.to(plantumlDocumentConverter.from(foreign))).toBe(foreign); + }); + +}); diff --git a/tests/formats/primary-json.test.ts b/tests/formats/primary-json.test.ts new file mode 100644 index 0000000..d88005f --- /dev/null +++ b/tests/formats/primary-json.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { fromGraphologyJSON, toGraphologyJSON, graphologyDocumentConverter } from '../../src/formats/graphology'; +import { fromGraphlibJSON, toGraphlibJSON, graphlibDocumentConverter } from '../../src/formats/graphlib'; +import { fromNetworkXNodeLink, toNetworkXNodeLink, networkxDocumentConverter } from '../../src/formats/networkx'; +import { getNativeDocumentGraph, getCarrierData } from '../../src/formats/preservation'; + +function getPersisted(value: T): T { return JSON.parse(JSON.stringify(value)); } +const cases = [ + { format: 'graphology', to: toGraphologyJSON, from: fromGraphologyJSON, document: graphologyDocumentConverter }, + { format: 'graphlib', to: toGraphlibJSON, from: fromGraphlibJSON, document: graphlibDocumentConverter }, + { format: 'networkx', to: toNetworkXNodeLink, from: fromNetworkXNodeLink, document: networkxDocumentConverter }, +]; +function createFixture() { + return createGraph({ id: 'root', data: [null, 'graph'], nodes: [ + { id: 'a', label: null, data: 42, x: 0, style: { fill: 'red' }, ports: [{ name: 'out', data: null }] }, + { id: 'b', label: '', data: { label: 'user', __statelyai: { foreign: true }, __statelyaiNative: { arbitrary: true } } }, + { id: 'isolated', data: null }, + ], edges: [{ id: 'e0', sourceId: 'a', targetId: 'b', label: '', data: [1, null], weight: 0 }, + { id: 'e1', sourceId: 'a', targetId: 'b', data: null }, { id: 'loop', sourceId: 'b', targetId: 'b', label: null }] }); +} +describe.each(cases)('$format JSON adapter', ({ format, to, from, document }) => { + it('preserves complete JSON Graph semantics, nulls, optional fields and input independence', () => { + const graph = createFixture(); const before = getPersisted(graph); + const source = (to as any)(graph); const restored = (from as any)(getPersisted(source)); + expect(getPersisted(getNativeDocumentGraph(restored, format, source))).toEqual(before); + expect(getPersisted(graph)).toEqual(before); + expect(document.to(getPersisted(document.from(source as any)) as any)).toEqual(source); + }); + it('applies live native labels without restoring stale snapshots', () => { + const source = (to as any)(createFixture()); + const native = format === 'graphology' ? source.nodes[0].attributes : format === 'graphlib' ? source.nodes[0].value : source.nodes[0]; + native.label = 'native edit'; native.custom = { untouched: true }; + const graph = (from as any)(source); expect(graph.nodes[0].label).toBe('native edit'); + const result = (to as any)(getPersisted(graph)); + const output = format === 'graphology' ? result.nodes[0].attributes : format === 'graphlib' ? result.nodes[0].value : result.nodes[0]; + expect(output.label).toBe('native edit'); expect(output.custom).toEqual({ untouched: true }); + }); + it('native edits to emitted data update the actual user payload, unknown attributes remain independent', () => { + const source = (to as any)(createFixture()); + const native = format === 'graphology' ? source.nodes[0].attributes : format === 'graphlib' ? source.nodes[0].value : source.nodes[0]; + native.data = [3, null]; native.other = { untouched: true }; + const graph = (from as any)(source); expect(graph.nodes[0].data).toEqual([3, null]); + graph.nodes[0].label = 'Graph edit'; const output = (to as any)(graph); + const attrs = format === 'graphology' ? output.nodes[0].attributes : format === 'graphlib' ? output.nodes[0].value : output.nodes[0]; + expect(attrs.data).toEqual([3, null]); expect(attrs.other).toEqual({ untouched: true }); expect(attrs.label).toBe('Graph edit'); + }); + it('patches generated metadata user data and labels after persistence', () => { + const source = (to as any)(createFixture()); const doc: any = getPersisted(document.from(source as any)); + doc.graph.nodes[0].label = 'Changed'; doc.graph.nodes[0].data = { replaced: true }; + const result: any = document.to(doc); const restored = getNativeDocumentGraph((from as any)(result), format, result); + expect(restored.nodes[0].label).toBe('Changed'); expect(restored.nodes[0].data).toEqual({ replaced: true }); + }); + it('rejects structural document edits, permits source-only edits, diagnoses field conflicts', () => { + const source = (to as any)(createFixture()); const doc: any = document.from(source as any); + doc.graph.nodes.reverse(); expect(document.getUpdate(doc).ok).toBe(false); + const fresh: any = document.from(source as any); + const native = format === 'graphology' ? fresh.source.nodes[0].attributes : format === 'graphlib' ? fresh.source.nodes[0].value : fresh.source.nodes[0]; + native.label = 'source'; expect(document.to(fresh)).toEqual(fresh.source); + fresh.graph.nodes[0].label = 'requested'; expect(document.getUpdate(fresh).ok).toBe(false); + }); + it('rejects bidirectional modes', () => { + const graph = createFixture(); graph.edges[0].mode = 'bidirectional'; + expect(() => (to as any)(graph)).toThrow(); + }); + it('preserves foreign reserved payloads through explicit native import and raw export', () => { + const graph = createFixture(); graph.data = { __statelyaiNative: { kind: '@statelyai/graph/native', version: 1, provenance: 'native-import', format, data: 'foreign', native: { anything: true }, baseline: { anything: true } }, sibling: 1 }; + const source = (to as any)(graph, { nativeCarrier: false }); const doc: any = getPersisted(document.from(source)); + expect(doc.graph.data).toEqual(graph.data); doc.graph.data.sibling = 2; + const output = document.to(doc); expect(getNativeDocumentGraph((from as any)(output), format, output).data).toEqual({ ...graph.data, sibling: 2 }); + const nativeGraph = (from as any)(source, { metadata: 'native' }); expect(nativeGraph.nodes[0].data.__statelyai).toHaveProperty('origin', `@statelyai/graph/${format}`); + }); + it('retains extras on surviving stable IDs after reorder, insertion and deletion', () => { + const source = (to as any)(createFixture()); + const attrs = (node: any) => format === 'graphology' ? node.attributes : format === 'graphlib' ? node.value : node; + attrs(source.nodes[0]).unknown = 'a'; attrs(source.nodes[1]).unknown = 'b'; + const graph = (from as any)(source); graph.nodes.reverse(); graph.nodes.pop(); graph.edges = []; + graph.nodes.push({ type: 'node', id: 'new', label: '', data: null }); + const output = (to as any)(getPersisted(graph)); + expect(output.nodes.map((node: any) => node.key ?? node.v ?? node.id)).toEqual(['isolated', 'b', 'new']); + expect(attrs(output.nodes[1]).unknown).toBe('b'); expect(attrs(output.nodes[2]).unknown).toBeUndefined(); + }); +}); +describe('native foreign JSON preservation and validation', () => { + it.each(cases)('$format invalid protocol snapshots remain ordinary typed-safe native data', ({ format, to, from }) => { + const getAttributes = (source: any, kind: string) => kind === 'graph' ? (format === 'graphology' ? source.attributes : format === 'graphlib' ? source.value : source.graph) : (format === 'graphology' ? source[kind === 'node' ? 'nodes' : 'edges'][0].attributes : format === 'graphlib' ? source[kind === 'node' ? 'nodes' : 'edges'][0].value : source[kind === 'node' ? 'nodes' : 'edges'][0]); + for (const [kind, field, value] of [['node', 'label', 42], ['node', 'x', null], ['node', 'type', 'wrong'], ['edge', 'weight', 'heavy'], ['edge', 'sourceId', 42], ['graph', 'mode', 'wrong']] as const) { + const source = (to as any)(createFixture()); const attrs = getAttributes(source, kind); attrs.__statelyai.original[field] = value; + const graph = (from as any)(source); const entity = kind === 'graph' ? graph : graph[kind === 'node' ? 'nodes' : 'edges'][0]; + expect(entity.data.__statelyai).toEqual(attrs.__statelyai); expect(entity.type === undefined || ['node', 'edge'].includes(entity.type)).toBe(true); + if (kind === 'node') { expect(entity.label === null || typeof entity.label === 'string').toBe(true); expect(entity.x === undefined || typeof entity.x === 'number').toBe(true); } + const native = (from as any)(source, { metadata: 'native' }); const nativeEntity = kind === 'graph' ? getCarrierData(native.data, format) : native[kind === 'node' ? 'nodes' : 'edges'][0].data; + expect(nativeEntity.__statelyai).toEqual(attrs.__statelyai); + } + }); + it.each(cases.filter((entry) => entry.format !== 'graphology'))('$format identity metadata allocation preserves enormous foreign suffix keys', ({ format, from, to }) => { + const huge = `__statelyai__${'9'.repeat(400)}`; + const attributes = { __statelyai: { foreign: true }, __statelyai__1: ['occupied'], [huge]: { untouched: true }, data: [42] }; + const source: any = format === 'graphlib' ? { options: { directed: true, multigraph: false, compound: false }, nodes: [{ v: 'a' }, { v: 'b' }, { v: 'c' }], edges: [{ v: 'a', w: 'b', value: attributes }] } : { directed: true, multigraph: false, graph: {}, nodes: [{ id: 1 }, { id: '1' }, { id: 2 }], edges: [{ source: 1, target: '1', ...attributes }] }; + const graph = (from as any)(source); graph.edges[0].targetId = graph.nodes[2].id; + const output = (to as any)(getPersisted(graph)); const attrs = format === 'graphlib' ? output.edges[0].value : output.edges[0]; + expect(attrs.__statelyai).toEqual({ foreign: true }); expect(attrs.__statelyai__1).toEqual(['occupied']); expect(attrs[huge]).toEqual({ untouched: true }); expect(attrs.__statelyai__2.origin).toBe(`@statelyai/graph/${format}`); + const restored = getNativeDocumentGraph((from as any)(output), format, output); + expect(getPersisted(restored)).toEqual(getPersisted({ ...graph, data: getCarrierData(graph.data, format) })); + }); + it.each([false, true])('NetworkX retains full inserted/retargeted edge IDs with foreign typed endpoints, multigraph=%s', (multigraph) => { + const source: any = { directed: true, multigraph, graph: {}, nodes: [{ id: 1 }, { id: '1' }, { id: 2 }], edges: [{ source: 1, target: '1', ...(multigraph && { key: 42 }), data: { vendor: true }, __statelyai: { foreign: true } }] }; + const graph = fromNetworkXNodeLink(source); graph.nodes.push({ type: 'node', id: 'added', label: '', data: ['node'] }); + graph.edges.push({ type: 'edge', id: 'new', sourceId: graph.nodes[1].id, targetId: graph.nodes[2].id, label: 'New', data: { z: 1 } }); + graph.edges[0].targetId = 'added'; graph.edges.reverse(); graph.nodes.reverse(); + const output = toNetworkXNodeLink(getPersisted(graph)); const restored = getNativeDocumentGraph(fromNetworkXNodeLink(output), 'networkx', output); + expect(getPersisted(restored)).toEqual(getPersisted({ ...graph, data: getCarrierData(graph.data, 'networkx') })); + expect(output.edges![0].source).toBe('1'); expect(output.edges![0].target).toBe(2); expect(output.edges![1].source).toBe(1); expect(output.edges![1].target).toBe('added'); + expect(output.edges![1].data).toEqual({ vendor: true }); expect(output.edges![1].__statelyai).toEqual({ foreign: true }); + expect(output.multigraph).toBe(multigraph); + }); + it.each([false, true])('Graphlib preserves inserted/retargeted IDs without changing simple/multigraph shape, multigraph=%s', (multigraph) => { + const source: any = { options: { directed: true, multigraph, compound: false }, nodes: [{ v: 'a' }, { v: 'b' }, { v: 'c' }], edges: [{ v: 'a', w: 'b', ...(multigraph && { name: 'old' }), value: { vendor: true, __statelyai: { foreign: true } } }] }; + const graph = fromGraphlibJSON(source); graph.nodes.push({ type: 'node', id: 'added', label: '', data: null }); graph.edges[0].targetId = 'added'; + graph.edges.push({ type: 'edge', id: 'new', sourceId: 'b', targetId: 'c', label: 'New', data: [42] }); graph.edges.reverse(); graph.nodes.reverse(); + const output = toGraphlibJSON(getPersisted(graph)); const restored = getNativeDocumentGraph(fromGraphlibJSON(output), 'graphlib', output); + expect(getPersisted(restored)).toEqual(getPersisted({ ...graph, data: getCarrierData(graph.data, 'graphlib') })); + expect(output.options.multigraph).toBe(multigraph); expect(Object.hasOwn(output.edges[0], 'name')).toBe(multigraph); expect(output.edges[1].value.vendor).toBe(true); expect(output.edges[1].value.__statelyai).toEqual({ foreign: true }); + }); + it.each(cases.filter((entry) => entry.format !== 'graphology'))('$format rejects parallel insertion into native simple graphs rather than collapsing', ({ format, from, to }) => { + const source: any = format === 'graphlib' ? { options: { directed: true, multigraph: false, compound: false }, nodes: [{ v: 'a' }, { v: 'b' }], edges: [{ v: 'a', w: 'b' }] } : { directed: true, multigraph: false, graph: {}, nodes: [{ id: 1 }, { id: '1' }], edges: [{ source: 1, target: '1' }] }; + const graph = (from as any)(source); graph.edges.push({ ...graph.edges[0], id: 'parallel' }); expect(() => (to as any)(graph)).toThrow('duplicate'); + }); + it.each(cases)('$format keeps unrepresentable Graph-looking attributes as user data', ({ format, from, document }) => { + const attrs = { label: 42, weight: 'heavy', style: ['red'], ports: 'hello', x: null, direction: 42, routing: 'unknown' }; + const source: any = format === 'graphology' ? { options: { type: 'directed', multi: true }, attributes: {}, nodes: [{ key: '', attributes: attrs }], edges: [] } : format === 'graphlib' ? { options: { directed: true, multigraph: true, compound: false }, nodes: [{ v: '', value: attrs }], edges: [] } : { directed: true, multigraph: true, graph: {}, nodes: [{ id: '', ...attrs }], edges: [] }; + const graph = (from as any)(source); expect(graph.nodes[0].label).toBe(''); expect(graph.nodes[0].x).toBeUndefined(); + expect(graph.nodes[0].data).toEqual(attrs); const doc: any = getPersisted(document.from(source)); expect(document.to(doc)).toEqual(source); + doc.graph.nodes[0].label = 'would erase'; expect(document.getUpdate(doc).ok).toBe(false); + const fresh: any = document.from(source); fresh.graph.nodes[0].data.label = 43; + const result: any = document.to(fresh); const node = format === 'graphology' ? result.nodes[0].attributes : format === 'graphlib' ? result.nodes[0].value : result.nodes[0]; + expect(node.label).toBe(43); expect(node.x).toBeNull(); + }); + it('Graphology preserves unknown fields, order and omitted attributes under independent native edits', () => { + const source: any = { options: { type: 'mixed', multi: true }, attributes: { vendor: [1, null], __statelyai: { foreign: true } }, nodes: [{ key: 'a', attributes: { label: 'A', vendor: 1 } }, { key: 'b' }], edges: [{ key: 'e', source: 'a', target: 'b', undirected: true, custom: { native: true } }], vendor: 42 }; + const doc = getPersisted(graphologyDocumentConverter.from(source)); expect(graphologyDocumentConverter.to(doc)).toEqual(source); + doc.source.nodes.reverse(); doc.source.nodes.push({ key: 'c' }); (doc.source as any).vendor = 43; + doc.graph!.nodes[0].label = 'Updated'; + const output: any = graphologyDocumentConverter.to(doc); + expect(output.nodes.map((node: any) => node.key)).toEqual(['b', 'a', 'c']); expect(output.nodes[1].attributes).toEqual({ label: 'Updated', vendor: 1 }); + expect(output.nodes[0]).toEqual({ key: 'b' }); expect(output.vendor).toBe(43); expect(output.edges[0].custom).toEqual({ native: true }); + }); + it.each(['constructor', '__proto__', 'toString', 'hasOwnProperty'])('Graphology guards unsafe native key %s', (id) => { + const graph = createGraph({ nodes: [{ id }, { id: 'safe' }], edges: [{ id: 'e', sourceId: id, targetId: 'safe' }] }); + expect(() => toGraphologyJSON(graph)).toThrow('unsafe native'); + }); + it('Graphology allows native mixed directed/undirected edges and rejects simple duplicates', () => { + const graph = createFixture(); graph.edges[0].mode = 'undirected'; + const source = toGraphologyJSON(graph); expect(source.options.type).toBe('mixed'); + expect(source.edges[0].undirected).toBe(true); expect(source.edges[1].undirected).toBe(false); + source.options.multi = false; source.edges[2] = { ...source.edges[1], key: 'duplicate' }; + expect(() => fromGraphologyJSON(source)).toThrow('duplicate edge'); + }); + it('Graphlib preserves scalar/array/null values exactly and rejects incompatible label edits', () => { + const source: any = { options: { directed: false, multigraph: true, compound: false }, value: 42, nodes: [{ v: 'a', value: ['array', null] }, { v: 'b', value: null }], edges: [{ v: 'a', w: 'b', name: '', value: false }, { v: 'a', w: 'b', name: 'second', value: 0 }], extension: { native: true } }; + const doc = getPersisted(graphlibDocumentConverter.from(source)); expect(graphlibDocumentConverter.to(doc)).toEqual(source); + expect(doc.graph!.nodes[0].data).toEqual(['array', null]); doc.graph!.nodes[0].label = 'Impossible'; + expect(graphlibDocumentConverter.getUpdate(doc).ok).toBe(false); + const changed = graphlibDocumentConverter.from(source); changed.graph!.nodes[0].data = [2]; + expect(graphlibDocumentConverter.to(changed).nodes[0].value).toEqual([2]); + }); + it('Graphlib preserves parents and guards unsafe compound IDs', () => { + const graph = createGraph({ nodes: [{ id: 'p' }, { id: 'child', parentId: 'p' }] }); + const source = toGraphlibJSON(graph); expect(source.nodes[1].parent).toBe('p'); expect(fromGraphlibJSON(source).nodes[1].parentId).toBe('p'); + graph.nodes[0].id = '__proto__'; graph.nodes[1].parentId = '__proto__'; expect(() => toGraphlibJSON(graph)).toThrow('unsafe'); + }); + it.each([toGraphlibJSON, toNetworkXNodeLink])('rejects genuine mixed native topology', (to) => { + const graph = createFixture(); graph.edges[0].mode = 'undirected'; expect(() => to(graph)).toThrow('one directed or undirected'); + }); + it.each(cases.filter((entry) => entry.format !== 'graphology'))('$format honors changed native global direction over per-edge snapshots', ({ format, to, from }) => { + const graph = createFixture(); graph.edges.forEach((edge) => edge.mode = 'undirected'); + const source = (to as any)(graph); if (format === 'graphlib') source.options.directed = true; else source.directed = true; + const restored = (from as any)(source); expect(restored.mode).toBe('directed'); expect(restored.edges.every((edge: any) => (edge.mode ?? restored.mode) === 'directed')).toBe(true); + }); + it.each(cases)('$format rejects missing endpoints and duplicate native identities', ({ format, to, from }) => { + const source = (to as any)(createFixture()); source.nodes.push(getPersisted(source.nodes[0])); expect(() => (from as any)(source)).toThrow(); source.nodes.pop(); + if (format === 'graphlib') source.edges[0].w = 'missing'; else source.edges[0].target = 'missing'; expect(() => (from as any)(source)).toThrow('missing'); + }); + it('Graphology rejects invalid boolean modes/attributes and allocates omitted keys safely', () => { + const source: any = { options: { type: 'directed', multi: true }, attributes: {}, nodes: [{ key: 'a' }, { key: 'b' }], edges: [{ source: 'a', target: 'b' }, { key: 'e0', source: 'a', target: 'b' }] }; + expect(() => fromGraphologyJSON(source)).not.toThrow(); + source.edges[0].undirected = 'yes'; expect(() => fromGraphologyJSON(source)).toThrow('direction'); delete source.edges[0].undirected; + source.options.allowSelfLoops = null; expect(() => fromGraphologyJSON(source)).toThrow('boolean'); delete source.options.allowSelfLoops; + source.nodes[0].attributes = null; expect(() => fromGraphologyJSON(source)).toThrow('attributes'); + }); + it('Graphlib native defaults are importable and hierarchy errors explicit', () => { + const source: any = { options: {}, nodes: [{ v: 'a' }, { v: 'b' }], edges: [{ v: 'a', w: 'b' }] }; + expect(fromGraphlibJSON(source).mode).toBe('directed'); + source.nodes[0].parent = 'b'; expect(() => fromGraphlibJSON(source)).toThrow('parent'); source.options.compound = true; source.nodes[1].parent = 'a'; expect(() => fromGraphlibJSON(source)).toThrow('cyclic'); source.nodes[1].parent = 'missing'; expect(() => fromGraphlibJSON(source)).toThrow('missing'); + }); + it('NetworkX keeps numeric/string IDs and repeated keys on different pairs distinct', () => { + const source: any = { directed: false, multigraph: true, graph: { custom: true }, nodes: [{ id: 1, label: null }, { id: '1', label: '' }, { id: '', vendor: true }], edges: [{ source: 1, target: '1', key: 1 }, { source: 1, target: '1', key: '1' }, { source: '', target: 1, key: 1 }] }; + const graph = fromNetworkXNodeLink(source); expect(new Set(graph.nodes.map((node) => node.id)).size).toBe(3); expect(new Set(graph.edges.map((edge) => edge.id)).size).toBe(3); + expect(toNetworkXNodeLink(getPersisted(graph))).toEqual(source); + const doc = getPersisted(networkxDocumentConverter.from(source)); doc.graph!.nodes[0].label = 'Changed'; + const output = networkxDocumentConverter.to(doc); expect(output.nodes[0].id).toBe(1); expect(output.nodes[0].label).toBe('Changed'); + expect(output.nodes[0].label).not.toBeNull(); expect(output.edges![1].key).toBe('1'); + }); + it('NetworkX canonicalizes Python bool/numeric equivalence and rejects duplicate native tuples', () => { + const source: any = { directed: true, multigraph: true, graph: {}, nodes: [{ id: true }, { id: '1' }], edges: [{ source: 1, target: '1', key: true }] }; + expect(fromNetworkXNodeLink(source).edges[0].sourceId).toBe(fromNetworkXNodeLink(source).nodes[0].id); + source.nodes.push({ id: 1 }); expect(() => fromNetworkXNodeLink(source)).toThrow('Python-equivalent'); source.nodes.pop(); + source.edges.push({ source: true, target: '1', key: 1 }); expect(() => fromNetworkXNodeLink(source)).toThrow('duplicate native edge'); + }); + it('NetworkX derives missing keys and preserves identity after reorder', () => { + const source: any = { directed: true, multigraph: true, graph: {}, nodes: [{ id: 1 }, { id: 2 }], edges: [{ source: 1, target: 2, custom: 'first' }, { source: 1, target: 2, custom: 'second' }] }; + const graph = fromNetworkXNodeLink(source); graph.edges.reverse(); + const output = toNetworkXNodeLink(graph); expect(output.edges!.map((edge) => edge.key)).toEqual([1, 0]); expect(output.edges![0].custom).toBe('second'); + }); + it('NetworkX keeps legacy links in documents and explicitly selects bare export shape', () => { + const source: any = { directed: true, multigraph: false, graph: {}, nodes: [{ id: 'a', label: 'A' }], links: [] }; + const doc = networkxDocumentConverter.from(source); doc.graph!.nodes[0].label = 'B'; expect(networkxDocumentConverter.to(doc).links).toEqual([]); + expect(toNetworkXNodeLink(fromNetworkXNodeLink(source)).edges).toEqual([]); + expect(toNetworkXNodeLink(fromNetworkXNodeLink(source), { edgeKey: 'links' }).links).toEqual([]); + expect(() => fromNetworkXNodeLink({ ...source, edges: [] })).toThrow('ambiguous'); + }); + it.each([null, [], {}, NaN, Infinity])('NetworkX rejects unsupported native identity %j', (id) => { + const source: any = { directed: true, multigraph: false, graph: {}, nodes: [{ id }], edges: [] }; + expect(() => fromNetworkXNodeLink(source)).toThrow(); + }); +}); diff --git a/tests/formats/support-matrix.test.ts b/tests/formats/support-matrix.test.ts index 358ab85..b1f4f5a 100644 --- a/tests/formats/support-matrix.test.ts +++ b/tests/formats/support-matrix.test.ts @@ -11,10 +11,11 @@ import { fromGML, toGML } from '../../src/formats/gml'; import { fromGraphML, toGraphML } from '../../src/formats/graphml'; import { fromJGF, toJGF } from '../../src/formats/jgf'; import { fromXYFlow, toXYFlow } from '../../src/formats/xyflow'; +import type { FormatSupportEntry } from '../../src/format-support'; import type { Graph, VisualGraph } from '../../src/types'; import { expectFixtureRoundTrip } from './fixture-roundtrip'; -const ROUND_TRIP_KEYS = { +const SELECTED_FIXTURE_KEYS = { graphKeys: ['initialNodeId', 'data', 'direction', 'style'] as Array, nodeKeys: [ 'parentId', @@ -31,6 +32,7 @@ const ROUND_TRIP_KEYS = { 'ports', ] as Array, edgeKeys: [ + 'mode', 'label', 'weight', 'data', @@ -47,7 +49,7 @@ const ROUND_TRIP_KEYS = { ] as Array, }; -const FULL_ROUND_TRIP_CHECKS = { +const SELECTED_FIXTURE_CHECKS = { cytoscape: (graph: Graph) => fromCytoscapeJSON(toCytoscapeJSON(graph)), d3: (graph: Graph) => fromD3Graph(toD3Graph(graph)), elk: (graph: Graph) => fromELK(toELK(graph as VisualGraph)), @@ -65,7 +67,25 @@ describe('format support matrix', () => { expect(ids).toEqual( expect.arrayContaining([ 'adjacency-list', + 'adjacency-matrix', + 'matrix-market', + 'graphviz-json', + 'drawio', + 'pajek', + 'plantuml', + 'mermaid/architecture', + 'mermaid/requirement', + 'mermaid/sankey', + 'mermaid/gitgraph', + 'mermaid/c4', + 'mermaid/usecase', + 'csv', + 'tsv', + 'graphology', + 'graphlib', + 'networkx', 'cytoscape', + 'd2', 'd3', 'dot', 'edge-list', @@ -88,12 +108,12 @@ describe('format support matrix', () => { ); }); - it('marks d2 as a full-fidelity syntax format with ports and no weight', () => { + it('describes d2 mappings without claiming complete syntax preservation', () => { const d2 = getFormatSupportEntry('d2'); expect(d2).toBeDefined(); expect(d2?.features.ports).toBe('full'); expect(d2?.features.hierarchy).toBe('full'); - expect(d2?.features.roundTrip).toBe('full'); + expect(d2?.features.roundTrip).toBe('partial'); expect(d2?.features.weight).toBe('none'); }); @@ -110,7 +130,7 @@ describe('format support matrix', () => { const state = getFormatSupportEntry('mermaid/state'); expect(state).toBeDefined(); - // Isolated plain states and plain node labels are dropped on emit. + // Diagram-specific mappings do not establish complete preservation. expect(state?.features.roundTrip).toBe('partial'); expect(state?.notes.join('\n')).toContain('data'); expect(state?.notes.join('\n')).toContain('Isolated'); @@ -136,7 +156,7 @@ describe('format support matrix', () => { // both dialects (nested , native , sourceport/targetport). expect(graphml?.features.hierarchy).toBe('full'); expect(graphml?.features.ports).toBe('full'); - expect(graphml?.features.roundTrip).toBe('full'); + expect(graphml?.features.roundTrip).toBe('partial'); expect(graphml?.notes.join('\n')).toContain('both dialects'); expect(graphml?.notes.join('\n')).toContain('nested ``'); expect(graphml?.notes.join('\n')).toContain('``'); @@ -144,33 +164,46 @@ describe('format support matrix', () => { expect(graphml?.notes.join('\n')).toContain('first ``'); }); - it('marks lossless metadata adapters as full round-trip', () => { - for (const id of ['cytoscape', 'd3', 'elk', 'gexf', 'gml', 'jgf', 'xyflow']) { - expect(getFormatSupportEntry(id)?.features.roundTrip).toBe('full'); + it('separates Graph, native semantic and source document preservation', () => { + for (const entry of FORMAT_SUPPORT_MATRIX) { + expect(entry.preservation, entry.id).toEqual({ + graph: 'partial', + native: entry.id === 'edge-list' ? 'full' : 'partial', + document: entry.id === 'd2' || entry.id.startsWith('mermaid/') ? 'partial' : 'none', + }); + expect(entry.features.roundTrip, entry.id).toBe(entry.preservation?.graph); } }); - // Diagram-syntax formats (mermaid, d2) round-trip their own syntax fully but - // are not generic data containers — they don't preserve arbitrary foreign - // graph data (e.g. edge weights, untyped node data), so they're excluded from - // the generic fully-featured fixture conformance check. - const SYNTAX_FORMAT = (id: string) => - id.startsWith('mermaid/') || id === 'd2'; - - it('has fixture conformance checks for generic full round-trip adapters', () => { - const genericFullRoundTripIds = FORMAT_SUPPORT_MATRIX.filter( - (entry) => entry.features.roundTrip === 'full' && !SYNTAX_FORMAT(entry.id), - ).map((entry) => entry.id); + it('retains compatibility with entries that only provide legacy features', () => { + const entry: FormatSupportEntry = { + id: 'custom', + importPath: 'custom/format', + features: FORMAT_SUPPORT_MATRIX[0].features, + notes: [], + }; + expect(entry.preservation).toBeUndefined(); + }); - expect(Object.keys(FULL_ROUND_TRIP_CHECKS).sort()).toEqual( - genericFullRoundTripIds.sort(), + it('gives each built-in format a unique identity and an explicit preservation contract', () => { + expect(new Set(FORMAT_SUPPORT_MATRIX.map((entry) => entry.id)).size).toBe( + FORMAT_SUPPORT_MATRIX.length, ); + expect(FORMAT_SUPPORT_MATRIX.every((entry) => entry.preservation !== undefined)).toBe(true); + }); + + // These checks project selected fields and sort entities. They characterize + // known mappings, not complete Graph, native format or source conformance. + it('keeps selected fixture characterizations for structured adapters', () => { + expect(Object.keys(SELECTED_FIXTURE_CHECKS).sort()).toEqual([ + 'cytoscape', 'd3', 'elk', 'gexf', 'gml', 'graphml', 'jgf', 'xyflow', + ]); }); - it.each(Object.entries(FULL_ROUND_TRIP_CHECKS))( - '%s full round-trip claim preserves the fully featured fixture', + it.each(Object.entries(SELECTED_FIXTURE_CHECKS))( + '%s preserves the selected fixture fields (entity order excluded)', (_id, roundTrip) => { - expectFixtureRoundTrip(roundTrip, ROUND_TRIP_KEYS); + expectFixtureRoundTrip(roundTrip, SELECTED_FIXTURE_KEYS); }, ); }); diff --git a/tests/native/csv.test.ts b/tests/native/csv.test.ts new file mode 100644 index 0000000..bc998e4 --- /dev/null +++ b/tests/native/csv.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from 'csv-parse/sync'; +import packageJSON from '../../package.json'; +import { createGraph } from '../../src/graph'; +import { fromCSV, toCSV, toTSV, csvDocumentConverter, type CSVGraphSource } from '../../src/formats/csv'; + +function getNativeRows(source: string, delimiter = ','): string[][] { return parse(source, { delimiter, bom: true, skip_empty_lines: false }); } + +describe('csv-parse 7.0.3 graph table oracle', () => { + it('pins the independent native parser', () => { expect(packageJSON.devDependencies['csv-parse']).toBe('7.0.3'); }); + it.each([',', '\t'] as const)('decodes exact generated %s cell values and Graph semantics', (delimiter) => { + const graph = createGraph({ id: 'Root', mode: 'undirected', data: { extra: [null, false] }, nodes: [ + { id: 'a,\t東京', label: 'Say "yes"\r\nthen \\', data: { text: 'literal comma, tab\t and \\' } }, + { id: 'b', label: '', data: 0 }, { id: 'isolated', label: null, data: false }, + ], edges: [{ id: 'e', sourceId: 'a,\t東京', targetId: 'b', mode: 'bidirectional', weight: 0, data: ['array', null] }] }); + const wire = delimiter === '\t' ? toTSV(graph) : toCSV(graph); + const nodes = getNativeRows(wire.nodes, delimiter); + const edges = getNativeRows(wire.edges, delimiter); + expect(nodes[1][nodes[0].indexOf('id')]).toBe('a,\t東京'); + expect(nodes[1][nodes[0].indexOf('label')]).toBe(JSON.stringify('Say "yes"\r\nthen \\')); + expect(JSON.parse(nodes[1][nodes[0].indexOf('data')])).toEqual(graph.nodes[0].data); + expect(nodes[2][nodes[0].indexOf('label')]).toBe('""'); + expect(nodes[3][nodes[0].indexOf('label')]).toBe('null'); + expect(edges[1][edges[0].indexOf('mode')]).toBe('"bidirectional"'); + expect(edges[1][edges[0].indexOf('weight')]).toBe('0'); + expect(fromCSV(wire)).toEqual(graph); + }); + + it('reads independently authored RFC tables with real multiline text cells', () => { + const wire: CSVGraphSource = { nodes: 'node,title,payload\r\nA,"東京, \"\"quoted\"\"\r\nsecond",null\r\nB,Target,false\r\nLone,,[]\r\n', edges: 'key,source,target,cost\r\ne,A,B,2.5\r\n', schema: { + nodes: [{ name: 'node', field: 'id', type: 'string', required: true }, { name: 'title', field: 'label', type: 'string' }, { name: 'payload', field: 'data', type: 'json' }], + edges: [{ name: 'key', field: 'id', type: 'string', required: true }, { name: 'source', field: 'sourceId', type: 'string', required: true }, { name: 'target', field: 'targetId', type: 'string', required: true }, { name: 'cost', field: 'weight', type: 'number' }], + } }; + const expected = getNativeRows(wire.nodes); + expect(expected[1]).toEqual(['A', '東京, "quoted"\r\nsecond', 'null']); + const graph = fromCSV(wire); + expect(graph.nodes.map((node) => [node.id, node.label, node.data])).toEqual([['A', expected[1][1], null], ['B', 'Target', false], ['Lone', '', []]]); + expect(graph.edges[0].weight).toBe(2.5); + }); + + it('native reader checks edited document cells while unrelated quoting and fields remain exact', () => { + const wire = { nodes: 'id,label,future\r\n"A","""Before"""," keep, exactly "\r\n', edges: 'id,sourceId,targetId\r\n' }; + const document = csvDocumentConverter.from(wire); + expect(csvDocumentConverter.to(JSON.parse(JSON.stringify(document)))).toEqual(wire); + document.graph!.nodes[0].label = 'After "yes"\r\nnext'; + const result = csvDocumentConverter.to(document); + const native = getNativeRows(result.nodes); + expect(native[1]).toEqual(['A', JSON.stringify('After "yes"\r\nnext'), ' keep, exactly ']); + expect(result.nodes).toContain('," keep, exactly "\r\n'); + expect(fromCSV(result).nodes[0].label).toBe('After "yes"\r\nnext'); + }); + + it('keeps graph-domain rejection separate from CSV lexical acceptance', () => { + const wire = { nodes: 'id\nA\n', edges: 'id,sourceId,targetId\ne,A,missing\n' }; + expect(getNativeRows(wire.edges)).toEqual([['id', 'sourceId', 'targetId'], ['e', 'A', 'missing']]); + expect(() => fromCSV(wire)).toThrow('endpoints'); + expect(() => getNativeRows('id\n"unterminated')).toThrow(); + expect(() => fromCSV({ ...wire, nodes: 'id\n"unterminated' })).toThrow('unterminated'); + }); +}); diff --git a/tests/native/d2.test.ts b/tests/native/d2.test.ts new file mode 100644 index 0000000..fe7c946 --- /dev/null +++ b/tests/native/d2.test.ts @@ -0,0 +1,86 @@ +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import corpus from './fixtures/d2-profiles.json'; +import packageJSON from '../../package.json'; +import { d2Converter, d2DocumentConverter } from '../../src/formats/d2'; + +interface NativeD2Diagram { shapes: Array<{ id: string; label: string; type: string; fill: string; columns?: Array<{ name: { label: string }; type: { label: string }; constraint?: string[] }> }>; connections: Array<{ src: string; dst: string; label: string; srcArrow: string; dstArrow: string }> } +type NativeD2Result = { ok: true; diagram: NativeD2Diagram; graphEdges: Array<{ srcTableColumnIndex?: number; dstTableColumnIndex?: number }> } | { ok: false; error: string }; +function getNativeD2Results(sources: string[]): NativeD2Result[] { + const child = spawnSync(process.execPath, [resolve('scripts/native/d2-compile.mjs')], { input: JSON.stringify(sources), encoding: 'utf8', timeout: 25_000, maxBuffer: 10 * 1024 * 1024 }); + if (child.error || child.status !== 0) throw new Error(`D2 compiler subprocess failed: ${child.error?.message ?? child.stderr}`); + return JSON.parse(child.stdout); +} + +describe(`native D2 ${corpus.oracle}`, () => { + it('uses the exact oracle version recorded by the fixture corpus', () => { + expect(corpus.oracle).toBe(`@terrastruct/d2@${packageJSON.devDependencies['@terrastruct/d2']}`); + }); + + it('compiles all external and emitted profiles with the portable native compiler', () => { + const emitted = corpus.fixtures.map((fixture) => d2Converter.to(d2Converter.from(fixture.source))); + const results = getNativeD2Results([...corpus.fixtures.map((fixture) => fixture.source), ...emitted]); + expect(results).toHaveLength(corpus.fixtures.length * 2); + for (let index = 0; index < results.length; index++) { + expect(results[index], `${corpus.fixtures[index % corpus.fixtures.length].name}: ${index < corpus.fixtures.length ? 'external' : 'emitted'}`).toMatchObject({ ok: true }); + } + const escaped = results[corpus.fixtures.length] as Extract; + expect(escaped.diagram.shapes.map((shape) => shape.label)).toContain('東京 "quoted" path\\'); + expect(escaped.diagram.shapes.map((shape) => shape.label)).toContain('Alone'); + expect(escaped.diagram.connections).toHaveLength(1); + const arrows = results[corpus.fixtures.length + 2] as Extract; + expect(arrows.diagram.connections.map((edge) => [edge.srcArrow, edge.dstArrow])).toEqual([['diamond', 'triangle'], ['none', 'none'], ['none', 'triangle']]); + const ports = results[corpus.fixtures.length + 1] as Extract; + expect(ports.diagram.shapes.find((shape) => shape.id === 'db')).toMatchObject({ type: 'sql_table', columns: [{ name: { label: 'id' }, type: { label: 'int' }, constraint: ['primary_key'] }, { name: { label: 'name' }, type: { label: 'string' } }] }); + expect(ports.graphEdges[0].dstTableColumnIndex).toBe(0); + const scoped = results[corpus.fixtures.length + 3] as Extract; + expect(scoped.diagram.shapes.map((shape) => shape.id)).toEqual(['outer', 'outer.a', 'outer.nested', 'outer.nested.c']); + expect(scoped.diagram.connections).toMatchObject([{ src: 'outer.nested.c', dst: 'outer.a' }]); + const substitution = results[corpus.fixtures.length + 4] as Extract; + expect(substitution.diagram.shapes).toMatchObject([{ id: 'a', label: 'Styled', fill: 'blue' }]); + + }); + + it('compiles source-preserving edited labels with comments and JSON persistence', () => { + const source = '# retained\r\na: "Before" # trailing\r\na -> b\r\n'; + const document = d2DocumentConverter.from(source); + expect(d2DocumentConverter.to(JSON.parse(JSON.stringify(document)))).toBe(source); + document.graph!.nodes[0].label = 'After 東京 "quote" \\'; + const patched = d2DocumentConverter.to(document); + expect(patched).toContain('# trailing'); + const [result] = getNativeD2Results([patched]); + expect(result).toMatchObject({ ok: true }); + if (result.ok) expect(result.diagram.shapes[0].label).toBe(document.graph!.nodes[0].label); + }); + + + it('compiles a larger chain and bounded deeply scoped identity profile', () => { + const chain = Array.from({ length: 60 }, (_, index) => `n${index}: Node 東京 ${index}\n${index < 59 ? `n${index} -> n${index + 1}` : ''}`).join('\n'); + const deep = `${Array.from({ length: 20 }, (_, index) => `${' '.repeat(index)}level${index}: {`).join('\n')}\n${' '.repeat(20)}leaf: Deep 東京\n${Array.from({ length: 20 }, (_, index) => `${' '.repeat(19 - index)}}`).join('\n')}`; + const [chainResult, deepResult] = getNativeD2Results([d2Converter.to(d2Converter.from(chain)), d2Converter.to(d2Converter.from(deep))]); + expect(chainResult).toMatchObject({ ok: true }); + expect(deepResult).toMatchObject({ ok: true }); + if (chainResult.ok) { expect(chainResult.diagram.shapes).toHaveLength(60); expect(chainResult.diagram.connections).toHaveLength(59); } + if (deepResult.ok) expect(deepResult.diagram.shapes.find((shape) => shape.label === 'Deep 東京')?.id).toBe(`${Array.from({ length: 20 }, (_, index) => `level${index}`).join('.')}.leaf`); + }); + + it('native compiler rejects malformed source within a bounded subprocess', () => { + const [result] = getNativeD2Results(['a: {\nshape: definitely-not-a-shape']); + expect(result).toMatchObject({ ok: false, error: expect.any(String) }); + }); +}); +it('native D2 retains NUL and astral labels but replaces lone surrogates; adapters reject that wire loss', () => { + const invalid = 'a: "A\ud800B"'; + const valid = d2Converter.from('a: "A\u0000B 😀"'); + const results = getNativeD2Results([invalid, d2Converter.to(valid)]); + expect(results[0]).toMatchObject({ ok: true }); + if (results[0].ok) expect(results[0].diagram.shapes[0].label).toBe('A\ufffdB'); + expect(results[1]).toMatchObject({ ok: true }); + if (results[1].ok) expect(results[1].diagram.shapes[0].label).toBe('A\u0000B 😀'); + expect(() => d2Converter.from(invalid)).toThrow('Unicode scalar'); + valid.nodes[0].label = 'A\ud800B'; expect(() => d2Converter.to(valid)).toThrow('Unicode scalar'); + const document = d2DocumentConverter.from('# comment\na: "Before"\n'); + document.graph!.nodes[0].label = 'A\ud800B'; expect(() => d2DocumentConverter.to(document)).toThrow('Unicode scalar'); + const unsupported = d2DocumentConverter.from(invalid); expect(unsupported.graph).toBeNull(); expect(d2DocumentConverter.to(unsupported)).toBe(invalid); +}); diff --git a/tests/native/drawio-codec.test.ts b/tests/native/drawio-codec.test.ts new file mode 100644 index 0000000..fe999dd --- /dev/null +++ b/tests/native/drawio-codec.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { createGraph } from '../../src/graph'; +import { toDrawio, createDrawioDocumentConverter } from '../../src/formats/drawio'; +import { readFileSync } from 'node:fs'; +import { inflateRawSync } from 'node:zlib'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { getDrawioNativeSummary } from './drawio-oracle'; + +describe('actual maxgraph0.25 mxGraph/draw.io model import compatibility', () => { + it('uses the exact pinned codec and reproduces captured native models', () => { + expect(JSON.parse(readFileSync(resolve('node_modules/@maxgraph/core/package.json'), 'utf8')).version).toBe('0.25.0'); + for (const page of ['first', 'second']) { + const native = getDrawioNativeSummary(readFileSync(resolve(`tests/native/fixtures/drawio/page-${page}.mxgraph.xml`), 'utf8')); + const captured = JSON.parse(readFileSync(resolve(`tests/native/fixtures/drawio/page-${page}.maxgraph.json`), 'utf8')); + expect(JSON.parse(JSON.stringify(native))).toEqual(captured.summary); + } + }); + it('imports layers, grouped geometry, UserObject/raw HTML, style, topology and relative edge label geometry', () => { + const model = readFileSync(resolve('tests/native/fixtures/drawio/page-first.mxgraph.xml'), 'utf8'); + const native = getDrawioNativeSummary(model); + expect(native).toHaveLength(7); + expect(native.find((cell) => cell.id === 'a')).toMatchObject({ parent: 'group', vertex: 1, edge: false, + value: { element: 'UserObject', attributes: { label: 'Alpha & α', vendor: 'KEEP', count: '007' } }, + geometry: { x: 20, y: 30, width: 120, height: 60, relative: false }, style: { rounded: 1, html: 1, fillColor: '#dae8fc' } }); + expect(native.find((cell) => cell.id === 'b')).toMatchObject({ parent: 'layer-extra', value: '', geometry: { x: 500, y: 80 } }); + expect(native.find((cell) => cell.id === 'edge')).toMatchObject({ edge: 1, source: 'a', target: 'b', parent: '1', + geometry: { x: 0.5, y: -12, relative: 1, offset: { x: 3, y: 4 }, points: [{ x: 250, y: 140 }, { x: 400, y: 140 }] }, style: { endArrow: 'none' } }); + }); + it('independently inflates compressed pages and imports each page through the native codec', () => { + const source = readFileSync(resolve('tests/native/fixtures/drawio/pages-compressed.drawio'), 'utf8'); + const document = new DOMParser().parseFromString(source, 'application/xml'); + for (const page of document.querySelectorAll('diagram')) { + const inflated = decodeURIComponent(inflateRawSync(Buffer.from(page.textContent!, 'base64')).toString('utf8')); + const expected = readFileSync(resolve(`tests/native/fixtures/drawio/page-${page.id}.mxgraph.xml`), 'utf8'); + expect(inflated).toBe(expected); + expect(getDrawioNativeSummary(inflated)).toEqual(getDrawioNativeSummary(expected)); + } + expect(document.querySelectorAll('diagram')).toHaveLength(2); + }); +}); + +it('imports fresh emitted UserObject carriers, hierarchy and native endpoints in the real codec', () => { + const source = toDrawio(createGraph({ nodes: [{ id: 'a', label: 'A', x: 10, y: 20, width: 30, height: 40 }, { id: 'b', parentId: 'a', label: '' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', points: [{ x: 50, y: 60 }] }] })); + const model = new DOMParser().parseFromString(source, 'application/xml').querySelector('mxGraphModel')!; + const native = getDrawioNativeSummary(new XMLSerializer().serializeToString(model)); + const a = native.find((cell) => cell.value?.attributes?.label === 'A')!, b = native.find((cell) => cell.value?.attributes?.label === '')!; + expect(a.geometry).toMatchObject({ x: 10, y: 20, width: 30, height: 40 }); expect(b.parent).toBe(a.id); + expect(native.find((cell) => cell.edge)).toMatchObject({ source: a.id, target: b.id, geometry: { points: [{ x: 50, y: 60 }] } }); +}); +it('imports a changed compressed selected page through independent inflate and the real codec', () => { + const source = readFileSync(resolve('tests/native/fixtures/drawio/pages-compressed.drawio'), 'utf8'), converter = createDrawioDocumentConverter({ pageId: 'first' }), document = converter.from(source); + document.graph!.nodes.find((node) => node.id === 'b')!.label = 'CHANGED'; + const output = converter.to(document), page = new DOMParser().parseFromString(output, 'application/xml').querySelector('diagram[id="first"]')!; + const model = decodeURIComponent(inflateRawSync(Buffer.from(page.textContent!, 'base64')).toString('utf8')); + expect(getDrawioNativeSummary(model).find((cell) => cell.id === 'b')!.value).toBe('CHANGED'); +}); diff --git a/tests/native/drawio-oracle.ts b/tests/native/drawio-oracle.ts new file mode 100644 index 0000000..fcfebbe --- /dev/null +++ b/tests/native/drawio-oracle.ts @@ -0,0 +1,17 @@ +import { GraphDataModel, ModelXmlSerializer } from '@maxgraph/core'; + +/** Actual maxgraph mxGraph-compatible model import, without an adapter-side parser. */ +export function getDrawioNativeSummary(modelXML: string) { + const model = new GraphDataModel(); + new ModelXmlSerializer(model).import(modelXML); + return Object.values(model.cells ?? {}).map((cell) => { + const value = cell.getValue(); + const geometry = cell.getGeometry(); + return { id: cell.getId(), parent: cell.getParent()?.getId() ?? null, + vertex: cell.isVertex(), edge: cell.isEdge(), source: cell.getTerminal(true)?.getId() ?? null, target: cell.getTerminal(false)?.getId() ?? null, + value: value instanceof Element ? { element: value.tagName, attributes: Object.fromEntries([...value.attributes].map((attribute) => [attribute.name, attribute.value])) } : value, + style: cell.getStyle(), geometry: geometry ? { x: geometry.x, y: geometry.y, width: geometry.width, height: geometry.height, relative: geometry.relative, + offset: geometry.offset ? { x: geometry.offset.x, y: geometry.offset.y } : null, + points: geometry.points?.map((point) => ({ x: point.x, y: point.y })) ?? null } : null }; + }); +} diff --git a/tests/native/drawio-preparation.test.ts b/tests/native/drawio-preparation.test.ts new file mode 100644 index 0000000..e05fda0 --- /dev/null +++ b/tests/native/drawio-preparation.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { getXMLValidation } from './xml-oracle'; + +describe('draw.io published schema preparation; native model semantics remain separate', () => { + it('validates the byte-pinned authored multipage model using the published unmodified XSD', async () => { + const directory = new URL('./fixtures/drawio/', import.meta.url); + const manifest = JSON.parse(readFileSync(new URL('provenance.json', directory), 'utf8')); + for (const file of [...manifest.files, ...manifest.fixtures]) expect(createHash('sha256').update(readFileSync(new URL(file.file, directory))).digest('hex')).toBe(file.sha256); + const source = readFileSync(new URL('pages-uncompressed.drawio', directory), 'utf8'); + const result = await getXMLValidation(source, 'drawio'); + expect(result.errors).toEqual([]); + expect(result.valid).toBe(true); + }); + it('rejects unsupported geometry children in the actual schema', async () => { + const source = ''; + expect((await getXMLValidation(source, 'drawio')).valid).toBe(false); + }); + it('records the published XSD compressed-page limitation without changing its definitions', async () => { + const source = readFileSync(new URL('./fixtures/drawio/pages-compressed.drawio', import.meta.url), 'utf8'); + const result = await getXMLValidation(source, 'drawio'); + expect(result.valid).toBe(false); + expect(result.rawOutput).toContain('Character content other than whitespace is not allowed'); + expect(result.rawOutput).toContain('Expected is ( mxGraphModel )'); + }); +}); diff --git a/tests/native/fixtures/d2-profiles.json b/tests/native/fixtures/d2-profiles.json new file mode 100644 index 0000000..9964e78 --- /dev/null +++ b/tests/native/fixtures/d2-profiles.json @@ -0,0 +1,12 @@ +{ + "oracle": "@terrastruct/d2@0.1.33", + "retrieved": "2026-10-12", + "documentation": ["https://d2lang.com/tour/text", "https://d2lang.com/tour/sql-tables", "https://d2lang.com/tour/connections", "https://d2lang.com/tour/containers", "https://d2lang.com/tour/vars"], + "fixtures": [ + {"name":"escaped labels and literal dotted keys","source":"\"a.b\": \"東京 \\\"quoted\\\" path\\\\\"\nc: Target\n\"a.b\" -> c: Edge\nIsolated: Alone\n"}, + {"name":"SQL ports","source":"db: {\n shape: sql_table\n id: int { constraint: primary_key }\n name: string\n}\nservice: API\nservice -> db.id: Queries\n"}, + {"name":"mixed arrows and full arrowheads","source":"a: Alpha\nb: Beta\na <-> b: Two way {\n source-arrowhead.shape: diamond\n target-arrowhead.shape: triangle\n}\na -- b: Undirected\na -> b: Directed\n"}, + {"name":"scoped identity and ancestor references","source":"outer: {\n a: Alpha\n nested: {\n c: Gamma\n c -> _.a: Scoped\n }\n}\n"}, + {"name":"recursive vars and classes","source":"vars: {\n theme: { color: blue }\n}\nclasses: {\n styled: { style.fill: ${theme.color} }\n}\na: {\n label: Styled\n class: styled\n}\n"} + ] +} diff --git a/tests/native/fixtures/drawio/LICENSE.txt b/tests/native/fixtures/drawio/LICENSE.txt new file mode 100644 index 0000000..f44b900 --- /dev/null +++ b/tests/native/fixtures/drawio/LICENSE.txt @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 JGraph Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tests/native/fixtures/drawio/mxfile.xsd b/tests/native/fixtures/drawio/mxfile.xsd new file mode 100644 index 0000000..5327711 --- /dev/null +++ b/tests/native/fixtures/drawio/mxfile.xsd @@ -0,0 +1,671 @@ + + + + + + + + + + + + + + Root element of a draw.io file. Contains one or more diagram pages. + A minimal valid file has one diagram with at least the two structural + mxCell elements (id="0" and id="1"). + + + + + + + + + Host application identifier (e.g. "app.diagrams.net", "Electron"). + + + + + + ISO 8601 timestamp of last modification (e.g. "2024-01-15T10:30:00.000Z"). + + + + + + User agent or tool identifier that created the file. + + + + + + draw.io application version (e.g. "24.7.6"). + + + + + + Entity tag for caching and synchronization. + + + + + + Storage backend type. Common values: "device", "google", "dropbox", "onedrive", "github", "gitlab", "browser". + + + + + + If "true", diagram content is deflate-compressed and Base64-encoded. For AI generation, always use "false" (uncompressed XML). + + + + + + Number of diagram pages. Informational only; the actual count is determined by the number of diagram child elements. + + + + + + + + + + + Represents a single page/tab in the diagram. Each diagram has a unique + id and an optional display name. Contains either an mxGraphModel element + (uncompressed) or Base64-encoded compressed text content. + + + + + + + + + + Unique identifier for this diagram page. Must be unique within the file. + + + + + + Display name shown in the page tab. Defaults to "Page-N" if omitted. + + + + + + + + + + + The graph model containing all cells (shapes, edges, layers) and + canvas configuration. The dx/dy attributes control the scroll offset, + while pageWidth/pageHeight define the page dimensions. + + + + + + + + + + Horizontal scroll offset in pixels. Typical default: 0 or based on content. + + + + + Vertical scroll offset in pixels. + + + + + + + Page width in pixels. Common values: 850 (letter), 1169 (A4 landscape), 827 (A4 portrait). + + + + + Page height in pixels. Common values: 1100 (letter), 827 (A4 landscape), 1169 (A4 portrait). + + + + + Page scale factor. Default: 1. + + + + + + + Show grid. 1=visible, 0=hidden. Default: 1. + + + + + Grid cell size in pixels. Default: 10. + + + + + + + Enable alignment guides. Default: 1. + + + + + Show tooltips on hover. Default: 1. + + + + + Enable connection handles. Default: 1. + + + + + Show directional arrows on edges. Default: 1. + + + + + Enable collapse/expand of containers. Default: 1. + + + + + Show page boundaries. Default: 1. + + + + + Enable MathJax/LaTeX rendering in labels. Default: 0. + + + + + Global shadow for all shapes. Default: 0. Can be overridden per cell via style. + + + + + + + Canvas background color as hex (e.g. "#FFFFFF") or "none" for transparent. + + + + + JSON object for background image: {"src":"url","width":w,"height":h}. + + + + + + + Controls color adaptation for light/dark mode. Values: "auto", "simple", "none", "default". + + + + + + + + + + + Container for all cells in the diagram. Must contain at least two + structural mxCell elements: + 1. Root container: id="0", no parent attribute + 2. Default layer: id="1", parent="0" + + All visible diagram elements (shapes, edges) must have parent="1" + (or the id of another layer/group). + + Additional layers can be added as mxCell elements with parent="0". + Elements can be wrapped in UserObject/object for custom metadata. + + + + + + + + + + + + + + + + + + Universal cell element representing a vertex (shape), edge (connector), + or layer/group. The role is determined by the vertex/edge attributes. + + Cell roles: + - Root container: id="0", no parent, no vertex/edge + - Layer: parent="0", no vertex/edge (value = layer name) + - Vertex (shape): vertex="1", parent = layer or group id + - Edge (connector): edge="1", parent = layer or group id, + source/target = connected vertex ids + - Group/container: vertex="1" with child cells referencing it as parent + + Style string format: semicolon-separated key=value pairs. + Shape name can appear as first token without key (e.g. "ellipse;..."). + Example: "rounded=1;whiteSpace=wrap;html=1;fillColor=#DAE8FC;" + See style-reference.md for complete style documentation. + + + + + + + + + + Unique cell identifier within the diagram. Required for top-level mxCell, optional when nested in UserObject/object. Must be unique across all cells in the diagram. + + + + + ID of the parent cell. "0" for layers, "1" (or a layer/group id) for diagram elements. Omitted only for the root container (id="0"). + + + + + + + Cell label text. Can contain plain text or HTML (when style includes html=1). HTML labels support basic formatting: b, i, u, br, p, div, table, hr, font, span with inline styles. + + + + + + Style string: semicolon-separated key=value pairs defining visual appearance. + Format: "[shapeName;]key1=value1;key2=value2;..." + Example vertex: "rounded=1;whiteSpace=wrap;html=1;fillColor=#DAE8FC;" + Example edge: "edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" + See style-reference.md for all properties and values. + + + + + + + + Set to "1" for shape/vertex cells. Mutually exclusive with edge="1". + + + + + Set to "1" for connector/edge cells. Mutually exclusive with vertex="1". Should have source and/or target attributes. + + + + + Whether the cell can be connected to. Default: "1" for vertices, "0" for edges. Set to "0" to prevent connections. + + + + + + + ID of the source vertex for edges. Must reference a valid vertex id in the same diagram. + + + + + ID of the target vertex for edges. Must reference a valid vertex id in the same diagram. + + + + + + + Whether the container/group is collapsed. Only meaningful for container cells. + + + + + Whether the cell is visible. "0" = hidden, "1" = visible. Default: "1". + + + + + + + + + + + Wrapper element for cells with custom metadata attributes. + The UserObject/object carries the id and any custom key-value + attributes, while the nested mxCell contains the visual definition. + + Use "object" or "UserObject" interchangeably (both are supported). + The "label" attribute replaces mxCell's "value" for the display text. + + Custom attributes appear in the "Edit Data" dialog in draw.io and + can be used for data-driven diagrams, linking, and metadata. + + Example: + <UserObject id="3" label="Server" ip="192.168.1.1" status="active"> + <mxCell style="..." vertex="1" parent="1"> + <mxGeometry x="100" y="100" width="120" height="60" as="geometry"/> + </mxCell> + </UserObject> + + + + + + + + + + Unique identifier (same role as mxCell id). The nested mxCell should NOT have its own id. + + + + + Display label text (replaces mxCell value). Supports HTML when style includes html=1. + + + + + Hyperlink URL. Opens when the cell is clicked in the viewer. + + + + + Space-separated tags for filtering/categorization. + + + + + Tooltip text displayed on hover. + + + + + Enable placeholder substitution in labels. When "1", %attribute% in the label is replaced with attribute values. + + + + + + + + + + + + + + Defines the geometry (position, size, control points) for a cell. + + For vertices: + - x, y: absolute position of top-left corner (relative to parent) + - width, height: size in pixels + + For edges: + - When relative="1": x is position along edge (0.0=start, 1.0=end, + -1.0=before start), y is perpendicular offset in pixels + - Contains optional mxPoint children for source/target points + - Contains optional Array element with waypoints + + For edge labels (edge cells with relative="1"): + - x: position along edge (-1 to 1, where 0 = center) + - y: perpendicular offset in pixels + - mxPoint with as="offset" for additional pixel offset + + + + + + + + + + + + + + + + For vertices: x-coordinate of top-left corner (pixels, relative to parent). + For edges with relative="1": position along edge (-1.0 to 1.0, where 0 = center). + + + + + + + For vertices: y-coordinate of top-left corner (pixels, relative to parent). + For edges with relative="1": perpendicular offset in pixels. + + + + + + + + Width in pixels. Required for vertices. Typical values: 80-200. + + + + + Height in pixels. Required for vertices. Typical values: 40-120. + + + + + + + + When "1", x/y are relative coordinates (not absolute pixels). + Required for edge geometry. x ranges from -1.0 to 1.0 (position along edge), + y is perpendicular pixel offset. + + + + + + + + Always "geometry". Identifies this element as the cell's geometry property. + + + + + + + + + + + A 2D point used for edge endpoints or label offsets. + + The "as" attribute determines the role: + - "sourcePoint": starting point of an unconnected edge + - "targetPoint": ending point of an unconnected edge + - "offset": pixel offset for label positioning + - (no as): waypoint within an Array element + + + + + X coordinate in pixels. + + + + + Y coordinate in pixels. + + + + + + Point role: "sourcePoint", "targetPoint", or "offset". + + + + + + + + + + + Array of mxPoint elements representing intermediate waypoints + for edge routing. Points are in absolute coordinates and define + the path the edge follows between source and target. + + + + + + + + + Always "points". Identifies this array as the edge's waypoint list. + + + + + + + + + + + Stores alternate bounds for collapsible containers (swimlanes). + When a container is collapsed/expanded, the geometry swaps between + the normal bounds and these alternate bounds. Always appears with + as="alternateBounds" inside mxGeometry. + + + + + X coordinate of the alternate bounds. + + + + + Y coordinate of the alternate bounds. + + + + + Width of the alternate bounds. + + + + + Height of the alternate bounds. + + + + + Always "alternateBounds". Identifies this as the cell's alternate geometry. + + + + + + + + + + + + Boolean as integer: "0" = false, "1" = true. This is the standard boolean format in draw.io. + + + + + + + + + + + Boolean as string: "true" or "false". Used only for mxfile-level attributes like "compressed". + + + + + + + + + + + + Color value. Accepted formats: + - Hex with hash: "#RRGGBB" (e.g. "#FF0000") or "#RRGGBBAA" (with alpha) + - "none" for transparent/no color + - "default" to use the current theme's default color + + + + + + + + + + + Valid roles for mxPoint: sourcePoint, targetPoint, or offset. + + + + + + + + + diff --git a/tests/native/fixtures/drawio/page-first.maxgraph.json b/tests/native/fixtures/drawio/page-first.maxgraph.json new file mode 100644 index 0000000..cc435c0 --- /dev/null +++ b/tests/native/fixtures/drawio/page-first.maxgraph.json @@ -0,0 +1,151 @@ +{ + "oracle": "@maxgraph/core", + "version": "0.25.0", + "operation": "ModelXmlSerializer.import(mxGraphModel)", + "summary": [ + { + "id": "0", + "parent": null, + "vertex": false, + "edge": false, + "source": null, + "target": null, + "value": null, + "style": {}, + "geometry": null + }, + { + "id": "1", + "parent": "0", + "vertex": false, + "edge": false, + "source": null, + "target": null, + "value": null, + "style": {}, + "geometry": null + }, + { + "id": "group", + "parent": "1", + "vertex": 1, + "edge": false, + "source": null, + "target": null, + "value": "Group", + "style": { + "baseStyleNames": [ + "group" + ] + }, + "geometry": { + "x": 100, + "y": 80, + "width": 300, + "height": 180, + "relative": false, + "offset": null, + "points": null + } + }, + { + "id": "a", + "parent": "group", + "vertex": 1, + "edge": false, + "source": null, + "target": null, + "value": { + "element": "UserObject", + "attributes": { + "label": "Alpha & α", + "vendor": "KEEP", + "count": "007" + } + }, + "style": { + "rounded": 1, + "html": 1, + "fillColor": "#dae8fc" + }, + "geometry": { + "x": 20, + "y": 30, + "width": 120, + "height": 60, + "relative": false, + "offset": null, + "points": null + } + }, + { + "id": "edge", + "parent": "1", + "vertex": false, + "edge": 1, + "source": "a", + "target": "b", + "value": "label", + "style": { + "edgeStyle": "orthogonalEdgeStyle", + "endArrow": "none" + }, + "geometry": { + "x": 0.5, + "y": -12, + "width": 0, + "height": 0, + "relative": 1, + "offset": { + "x": 3, + "y": 4 + }, + "points": [ + { + "x": 250, + "y": 140 + }, + { + "x": 400, + "y": 140 + } + ] + } + }, + { + "id": "layer-extra", + "parent": "0", + "vertex": false, + "edge": false, + "source": null, + "target": null, + "value": "Extra layer", + "style": {}, + "geometry": null + }, + { + "id": "b", + "parent": "layer-extra", + "vertex": 1, + "edge": false, + "source": null, + "target": null, + "value": "", + "style": { + "baseStyleNames": [ + "ellipse" + ], + "html": 0 + }, + "geometry": { + "x": 500, + "y": 80, + "width": 80, + "height": 80, + "relative": false, + "offset": null, + "points": null + } + } + ] +} diff --git a/tests/native/fixtures/drawio/page-first.mxgraph.xml b/tests/native/fixtures/drawio/page-first.mxgraph.xml new file mode 100644 index 0000000..6af3954 --- /dev/null +++ b/tests/native/fixtures/drawio/page-first.mxgraph.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/native/fixtures/drawio/page-second.maxgraph.json b/tests/native/fixtures/drawio/page-second.maxgraph.json new file mode 100644 index 0000000..b062867 --- /dev/null +++ b/tests/native/fixtures/drawio/page-second.maxgraph.json @@ -0,0 +1,48 @@ +{ + "oracle": "@maxgraph/core", + "version": "0.25.0", + "operation": "ModelXmlSerializer.import(mxGraphModel)", + "summary": [ + { + "id": "0", + "parent": null, + "vertex": false, + "edge": false, + "source": null, + "target": null, + "value": null, + "style": {}, + "geometry": null + }, + { + "id": "1", + "parent": "0", + "vertex": false, + "edge": false, + "source": null, + "target": null, + "value": null, + "style": {}, + "geometry": null + }, + { + "id": "a", + "parent": "1", + "vertex": 1, + "edge": false, + "source": null, + "target": null, + "value": "Same ID on another page", + "style": {}, + "geometry": { + "x": 1, + "y": 2, + "width": 80, + "height": 40, + "relative": false, + "offset": null, + "points": null + } + } + ] +} diff --git a/tests/native/fixtures/drawio/page-second.mxgraph.xml b/tests/native/fixtures/drawio/page-second.mxgraph.xml new file mode 100644 index 0000000..a0471d0 --- /dev/null +++ b/tests/native/fixtures/drawio/page-second.mxgraph.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/tests/native/fixtures/drawio/pages-compressed.drawio b/tests/native/fixtures/drawio/pages-compressed.drawio new file mode 100644 index 0000000..f60ca36 --- /dev/null +++ b/tests/native/fixtures/drawio/pages-compressed.drawio @@ -0,0 +1,3 @@ + + + tVbbbqMwEP0aP3ZlIM3mNaE0D9VqI1X9ACdMgJWDkXGa8Pc7xjbgJW1I2pVCZA9z8zlzABLFh/Nasir/JVLgJKTpmURPJAwpXnrbeNtMFqkxBNoQJSSKpRBKr+iSaDf/p/PHwHVmF2lTkfD5lqjARFVMQqm+koizBuQDnJVkJv6d8SOYe4mx0tbnuwpmUhyrUal1b61Vw2HoG62sO0gF5w/P7yi42EjfzhrEAZRscO9yUXsSS+7Cbk9FqnJjipxLDkWWu4LOkdW23S71dVzwroXmY5+3GuTv7R/YqQF+libOtjigrYWEc44trba4yPRiyauctVnm7KDxM//aEidkFbgA7KGLcRCXqZAm7UuSbIx1J44d5/TnNJgt6wMykcsyBX2KAOvl6sDtcl9wHgtu60Ypg8V+N5X1bpw+b+kz/t0gN47rEf2BcxnSP7+b/VtmAH0GY3CDzrYjjY3khQFFVUPPBp2K+j9PjXtU93hddYsLqP9vzY1whDSDEZRGfSM80fXVboVUuchEyXjSW1cor6WU4oQOpSihg7stMgS7Fke5A0/xiskMlEfutzz+6I9Hj4iHwE6KBM5U8e43dgn3qdLbiKJUfeHIKzvz84v9vsbTTtSSq4HgsmaQpdIV6+7dPGoBp9BrIpj5L7ULITN6LQQXpo9J+u8IuW9u27vum8Ol7L9gBgF/AQ==jVDLDsIgEPyavSN48GqqNh48+QWbdANNoDSISv9eZFHDoYkHNjvDzL5AdS71AWdz8QNZUEdQXfA+cuZSR9aCFOMA6gBSivzgHU8rgg0LZgw0xT89yIIH2jsxc0VHmTnnXPgpB5x8NBRKZU1VTyFSWm1bqNqwJ+8ohiVLWsPCSDJ6jkM0zOzq1IZGbWrFbeXwxlh/qzb75aSu+IG/g5a/9t5iX8ziBQ== \ No newline at end of file diff --git a/tests/native/fixtures/drawio/pages-uncompressed.drawio b/tests/native/fixtures/drawio/pages-uncompressed.drawio new file mode 100644 index 0000000..d679297 --- /dev/null +++ b/tests/native/fixtures/drawio/pages-uncompressed.drawio @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/native/fixtures/drawio/provenance.json b/tests/native/fixtures/drawio/provenance.json new file mode 100644 index 0000000..3e66dfa --- /dev/null +++ b/tests/native/fixtures/drawio/provenance.json @@ -0,0 +1,69 @@ +{ + "retrieved": "2026-10-12", + "repository": "https://github.com/jgraph/drawio-mcp", + "commit": "a4515e4dc986e22d369551ac213399d6fb653bde", + "schemaVersion": "1.0", + "license": "Apache-2.0; Copyright 2025 JGraph Ltd", + "sourceDocument": "https://github.com/jgraph/drawio-mcp/blob/a4515e4dc986e22d369551ac213399d6fb653bde/shared/xml-reference.md", + "profile": "Published uncompressed generated-file XSD; actual maxgraph0.25 mxGraph-compatible model import separately tests structure/attributes. No actual draw.io renderer or complete runtime semantic proof.", + "files": [ + { + "file": "mxfile.xsd", + "url": "https://raw.githubusercontent.com/jgraph/drawio-mcp/a4515e4dc986e22d369551ac213399d6fb653bde/shared/mxfile.xsd", + "sha256": "905db85d4e8ebec0e91518cdd62982e0afb3f09ebdcaf9e6b1952957a606639a", + "modifications": [], + "resolverRewrites": [] + }, + { + "file": "LICENSE.txt", + "url": "https://raw.githubusercontent.com/jgraph/drawio-mcp/a4515e4dc986e22d369551ac213399d6fb653bde/LICENSE", + "sha256": "006e61a1b8c97620d75ceacc283de7a363d78da7da9a5b92203324c40feb7232", + "modifications": [], + "resolverRewrites": [] + } + ], + "entry": "mxfile.xsd", + "fixtures": [ + { + "file": "pages-uncompressed.drawio", + "sha256": "5a53987e0788209b71b70fd883008e239ebf451a678a0aafe95f9b211825e084", + "license": "Authored fixture", + "profile": "Multiple pages, layers, relative group geometry, UserObject/custom attributes/raw HTML, empty label, decorative arrow style, waypoints and relative edge label coordinates. Native codec proof pending." + }, + { + "file": "page-first.mxgraph.xml", + "sha256": "42c38656a222630a1c35d852059101b28d99122d6e37c778df4ac62c789487cf", + "license": "Authored/generated fixture" + }, + { + "file": "page-second.mxgraph.xml", + "sha256": "4bee07a4a4e75bc7344ec6cd421faada661bdf0b888577455b5a7a2438524593", + "license": "Authored/generated fixture" + }, + { + "file": "pages-compressed.drawio", + "sha256": "f699141dee1adf539eb8b39c017eb19fc7f9aca22e5db61b6e24c9b2f7e95520", + "license": "Authored/generated fixture" + }, + { + "file": "page-first.maxgraph.json", + "sha256": "400a131b76025b2f709b8e51a9c22addb44936919b8fa93ed89277fcc04a336d", + "license": "Authored native-model fixture output", + "oracle": "@maxgraph/core0.25.0 native mxGraph-compatible import" + }, + { + "file": "page-second.maxgraph.json", + "sha256": "4480ef755dc723229192d2484b6277c193df3ab02834002b9f483578dcc3e5b6", + "license": "Authored native-model fixture output", + "oracle": "@maxgraph/core0.25.0 native mxGraph-compatible import" + } + ], + "compression": { + "generator": "Python3.14.8 standard-library zlib; UTF-8 encodeURIComponent-equivalent URI text then rawDEFLATE then base64", + "parameters": "urllib.parse.quote safe=~()*!'., zlib.compressobj(wbits=-15)", + "oracle": "Independent Node24 zlib.inflateRawSync restores exact URI-encoded XML; decoded pages pass actual maxgraph0.25 mxGraph-compatible model import." + }, + "discrepancies": [ + "Official schema1.0 describes compressed diagram text but diagramType requires an element-only mxGraphModel child. Compressedpages fixture is expected-invalid under this unchanged XSD; actual page inflation/model import are separate native-oracle checks." + ] +} diff --git a/tests/native/fixtures/gexf/1.2draft/data.xsd b/tests/native/fixtures/gexf/1.2draft/data.xsd new file mode 100644 index 0000000..5e2d140 --- /dev/null +++ b/tests/native/fixtures/gexf/1.2draft/data.xsd @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/native/fixtures/gexf/1.2draft/dynamics.xsd b/tests/native/fixtures/gexf/1.2draft/dynamics.xsd new file mode 100644 index 0000000..e159f81 --- /dev/null +++ b/tests/native/fixtures/gexf/1.2draft/dynamics.xsd @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/native/fixtures/gexf/1.2draft/gexf.xsd b/tests/native/fixtures/gexf/1.2draft/gexf.xsd new file mode 100644 index 0000000..0380b6e --- /dev/null +++ b/tests/native/fixtures/gexf/1.2draft/gexf.xsd @@ -0,0 +1,88 @@ + + + + + + + + + + + Tree + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Datatypes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/native/fixtures/gexf/1.2draft/hierarchy.xsd b/tests/native/fixtures/gexf/1.2draft/hierarchy.xsd new file mode 100644 index 0000000..81af8e2 --- /dev/null +++ b/tests/native/fixtures/gexf/1.2draft/hierarchy.xsd @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/native/fixtures/gexf/1.2draft/phylogenics.xsd b/tests/native/fixtures/gexf/1.2draft/phylogenics.xsd new file mode 100644 index 0000000..68c2aa2 --- /dev/null +++ b/tests/native/fixtures/gexf/1.2draft/phylogenics.xsd @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tests/native/fixtures/gexf/1.2draft/provenance.json b/tests/native/fixtures/gexf/1.2draft/provenance.json new file mode 100644 index 0000000..067e532 --- /dev/null +++ b/tests/native/fixtures/gexf/1.2draft/provenance.json @@ -0,0 +1,96 @@ +{ + "retrieved": "2026-10-12", + "entry": "gexf.xsd", + "license": "CC BY 4.0; ../LICENSE.md", + "files": [ + { + "file": "gexf.xsd", + "url": "https://gexf.net/1.2draft/gexf.xsd", + "retrievedFrom": "https://gexf.net/1.2draft/gexf.xsd", + "sha256": "625811d177108edaa21ab9e32d0f1e0dc7a7754d75258d74c3eabd5e6a31a596", + "resolverRewrites": [ + { + "from": "data.xsd", + "to": "data.xsd" + }, + { + "from": "dynamics.xsd", + "to": "dynamics.xsd" + }, + { + "from": "hierarchy.xsd", + "to": "hierarchy.xsd" + }, + { + "from": "phylogenics.xsd", + "to": "phylogenics.xsd" + }, + { + "from": "viz.xsd", + "to": "viz.xsd" + } + ] + }, + { + "file": "data.xsd", + "url": "https://gexf.net/1.2draft/data.xsd", + "retrievedFrom": "https://gexf.net/1.2draft/data.xsd", + "sha256": "4913c4d0cdcefd1c5115efdcbff16a02bfdec4dbe62bef3e7d777974b5a3aab9", + "resolverRewrites": [ + { + "from": "viz.xsd", + "to": "viz.xsd" + } + ] + }, + { + "file": "dynamics.xsd", + "url": "https://gexf.net/1.2draft/dynamics.xsd", + "retrievedFrom": "https://gexf.net/1.2draft/dynamics.xsd", + "sha256": "712f9badf9f693bdd442806facaf24e8dd46ec3fc6d62b26d4ea503e75abf9fa", + "resolverRewrites": [ + { + "from": "viz.xsd", + "to": "viz.xsd" + } + ] + }, + { + "file": "hierarchy.xsd", + "url": "https://gexf.net/1.2draft/hierarchy.xsd", + "retrievedFrom": "https://gexf.net/1.2draft/hierarchy.xsd", + "sha256": "5f903a5d9e3b534eac79ed08d9c5117e78441649045aca2d9ac7c55278c5570e", + "resolverRewrites": [ + { + "from": "viz.xsd", + "to": "viz.xsd" + } + ] + }, + { + "file": "phylogenics.xsd", + "url": "https://gexf.net/1.2draft/phylogenics.xsd", + "retrievedFrom": "https://gexf.net/1.2draft/phylogenics.xsd", + "sha256": "d1af4ef6bf1c8c0563ab8ffb9a2bae11d5e6a7d26fe822d42deb922a1a759928", + "resolverRewrites": [ + { + "from": "viz.xsd", + "to": "viz.xsd" + } + ] + }, + { + "file": "viz.xsd", + "url": "https://gexf.net/1.2draft/viz.xsd", + "retrievedFrom": "https://gexf.net/1.2draft/viz.xsd", + "sha256": "636927859fca8187e1f7fb6ea32f0c450c7141d53d6d3088155069f76e478fe2", + "resolverRewrites": [ + { + "from": "gexf.xsd", + "to": "gexf.xsd" + } + ] + } + ], + "changes": "Schema bytes are unmodified. Test resolver replaces only schemaLocation URLs in memory with vendored filenames; semantic schema components unchanged." +} diff --git a/tests/native/fixtures/gexf/1.2draft/viz.xsd b/tests/native/fixtures/gexf/1.2draft/viz.xsd new file mode 100644 index 0000000..ecae0e3 --- /dev/null +++ b/tests/native/fixtures/gexf/1.2draft/viz.xsd @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/native/fixtures/gexf/1.3/dynamics.xsd b/tests/native/fixtures/gexf/1.3/dynamics.xsd new file mode 100644 index 0000000..fb5e680 --- /dev/null +++ b/tests/native/fixtures/gexf/1.3/dynamics.xsd @@ -0,0 +1,176 @@ + + + + + + + Gext and data extension + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/native/fixtures/gexf/1.3/gexf.xsd b/tests/native/fixtures/gexf/1.3/gexf.xsd new file mode 100644 index 0000000..3bac414 --- /dev/null +++ b/tests/native/fixtures/gexf/1.3/gexf.xsd @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Datatypes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/native/fixtures/gexf/1.3/provenance.json b/tests/native/fixtures/gexf/1.3/provenance.json new file mode 100644 index 0000000..d69156b --- /dev/null +++ b/tests/native/fixtures/gexf/1.3/provenance.json @@ -0,0 +1,48 @@ +{ + "retrieved": "2026-10-12", + "entry": "gexf.xsd", + "license": "CC BY 4.0; ../LICENSE.md", + "files": [ + { + "file": "gexf.xsd", + "url": "https://gexf.net/1.3/gexf.xsd", + "retrievedFrom": "https://gexf.net/1.3/gexf.xsd", + "sha256": "a8e1d0a6a5237fc4ce0825692fa3db49fb04d70cf3a84334f7a87c15422c1257", + "resolverRewrites": [ + { + "from": "dynamics.xsd", + "to": "dynamics.xsd" + }, + { + "from": "viz.xsd", + "to": "viz.xsd" + } + ] + }, + { + "file": "dynamics.xsd", + "url": "https://gexf.net/1.3/dynamics.xsd", + "retrievedFrom": "https://gexf.net/1.3/dynamics.xsd", + "sha256": "d5ee084a858baf6efebe210d4799050723bfce71fb44c9ddbf20a53f45be8298", + "resolverRewrites": [ + { + "from": "viz.xsd", + "to": "viz.xsd" + } + ] + }, + { + "file": "viz.xsd", + "url": "https://gexf.net/1.3/viz.xsd", + "retrievedFrom": "https://gexf.net/1.3/viz.xsd", + "sha256": "e20e40bcfd4531026d4d1c74da5cbadc413ff5b81cf4418ca14f42ea994e2dc4", + "resolverRewrites": [ + { + "from": "gexf.xsd", + "to": "gexf.xsd" + } + ] + } + ], + "changes": "Schema bytes are unmodified. Test resolver replaces only schemaLocation URLs in memory with vendored filenames; semantic schema components unchanged." +} diff --git a/tests/native/fixtures/gexf/1.3/viz.xsd b/tests/native/fixtures/gexf/1.3/viz.xsd new file mode 100644 index 0000000..8a55d6f --- /dev/null +++ b/tests/native/fixtures/gexf/1.3/viz.xsd @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/native/fixtures/gexf/LICENSE.md b/tests/native/fixtures/gexf/LICENSE.md new file mode 100644 index 0000000..f1ce271 --- /dev/null +++ b/tests/native/fixtures/gexf/LICENSE.md @@ -0,0 +1,395 @@ +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + +a. Adapted Material means material subject to Copyright and Similar +Rights that is derived from or based upon the Licensed Material +and in which the Licensed Material is translated, altered, +arranged, transformed, or otherwise modified in a manner requiring +permission under the Copyright and Similar Rights held by the +Licensor. For purposes of this Public License, where the Licensed +Material is a musical work, performance, or sound recording, +Adapted Material is always produced where the Licensed Material is +synched in timed relation with a moving image. + +b. Adapter's License means the license You apply to Your Copyright +and Similar Rights in Your contributions to Adapted Material in +accordance with the terms and conditions of this Public License. + +c. Copyright and Similar Rights means copyright and/or similar rights +closely related to copyright including, without limitation, +performance, broadcast, sound recording, and Sui Generis Database +Rights, without regard to how the rights are labeled or +categorized. For purposes of this Public License, the rights +specified in Section 2(b)(1)-(2) are not Copyright and Similar +Rights. + +d. Effective Technological Measures means those measures that, in the +absence of proper authority, may not be circumvented under laws +fulfilling obligations under Article 11 of the WIPO Copyright +Treaty adopted on December 20, 1996, and/or similar international +agreements. + +e. Exceptions and Limitations means fair use, fair dealing, and/or +any other exception or limitation to Copyright and Similar Rights +that applies to Your use of the Licensed Material. + +f. Licensed Material means the artistic or literary work, database, +or other material to which the Licensor applied this Public +License. + +g. Licensed Rights means the rights granted to You subject to the +terms and conditions of this Public License, which are limited to +all Copyright and Similar Rights that apply to Your use of the +Licensed Material and that the Licensor has authority to license. + +h. Licensor means the individual(s) or entity(ies) granting rights +under this Public License. + +i. Share means to provide material to the public by any means or +process that requires permission under the Licensed Rights, such +as reproduction, public display, public performance, distribution, +dissemination, communication, or importation, and to make material +available to the public including in ways that members of the +public may access the material from a place and at a time +individually chosen by them. + +j. Sui Generis Database Rights means rights other than copyright +resulting from Directive 96/9/EC of the European Parliament and of +the Council of 11 March 1996 on the legal protection of databases, +as amended and/or succeeded, as well as other essentially +equivalent rights anywhere in the world. + +k. You means the individual or entity exercising the Licensed Rights +under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + +a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + +b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + +a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + +a. for the avoidance of doubt, Section 2(a)(1) grants You the right +to extract, reuse, reproduce, and Share all or a substantial +portion of the contents of the database; + +b. if You include all or a substantial portion of the database +contents in a database in which You have Sui Generis Database +Rights, then the database in which You have Sui Generis Database +Rights (but not its individual contents) is Adapted Material; and + +c. You must comply with the conditions in Section 3(a) if You Share +all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + +a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE +EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS +AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF +ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, +IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, +WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, +ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT +KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT +ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + +b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE +TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, +NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, +INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, +COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR +USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN +ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR +DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR +IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + +c. The disclaimer of warranties and limitation of liability provided +above shall be interpreted in a manner that, to the extent +possible, most closely approximates an absolute disclaimer and +waiver of all liability. + + +Section 6 -- Term and Termination. + +a. This Public License applies for the term of the Copyright and +Similar Rights licensed here. However, if You fail to comply with +this Public License, then Your rights under this Public License +terminate automatically. + +b. Where Your right to use the Licensed Material has terminated under +Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + +c. For the avoidance of doubt, the Licensor may also offer the +Licensed Material under separate terms or conditions or stop +distributing the Licensed Material at any time; however, doing so +will not terminate this Public License. + +d. Sections 1, 5, 6, 7, and 8 survive termination of this Public +License. + + +Section 7 -- Other Terms and Conditions. + +a. The Licensor shall not be bound by any additional or different +terms or conditions communicated by You unless expressly agreed. + +b. Any arrangements, understandings, or agreements regarding the +Licensed Material not stated herein are separate from and +independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + +a. For the avoidance of doubt, this Public License does not, and +shall not be interpreted to, reduce, limit, restrict, or impose +conditions on any use of the Licensed Material that could lawfully +be made without permission under this Public License. + +b. To the extent possible, if any provision of this Public License is +deemed unenforceable, it shall be automatically reformed to the +minimum extent necessary to make it enforceable. If the provision +cannot be reformed, it shall be severed from this Public License +without affecting the enforceability of the remaining terms and +conditions. + +c. No term or condition of this Public License will be waived and no +failure to comply consented to unless expressly agreed to by the +Licensor. + +d. Nothing in this Public License constitutes or may be interpreted +as a limitation upon, or waiver of, any privileges and immunities +that apply to the Licensor or You, including from the legal +processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public licenses. +Notwithstanding, Creative Commons may elect to apply one of its public +licenses to material it publishes and in those instances will be +considered the “Licensor.” The text of the Creative Commons public +licenses is dedicated to the public domain under the CC0 Public Domain +Dedication. Except for the limited purpose of indicating that material +is shared under a Creative Commons public license or as otherwise +permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the public +licenses. + +Creative Commons may be contacted at creativecommons.org. \ No newline at end of file diff --git a/tests/native/fixtures/gexf/cases/provenance.json b/tests/native/fixtures/gexf/cases/provenance.json new file mode 100644 index 0000000..8d0574b --- /dev/null +++ b/tests/native/fixtures/gexf/cases/provenance.json @@ -0,0 +1,13 @@ +{ + "created": "2026-10-12", + "author": "@statelyai/graph native regression fixtures", + "license": "Repository MIT license; authored examples, not upstream schema copies.", + "specification": "https://gexf.net/schema.html", + "files": [ + { + "file": "scopes-mutual.gexf", + "sha256": "7eca9a99099faef3e9c69cd9b47c4caca557c1edbb443d72e2cb5f841cb79e71" + } + ], + "notes": "Core-namespace viz matches the published1.3 XSD; public viz-namespace example differs." +} diff --git a/tests/native/fixtures/gexf/cases/scopes-mutual.gexf b/tests/native/fixtures/gexf/cases/scopes-mutual.gexf new file mode 100644 index 0000000..fdb6416 --- /dev/null +++ b/tests/native/fixtures/gexf/cases/scopes-mutual.gexf @@ -0,0 +1,10 @@ + + + Native fixtureUSER DESCRIPTION + + 7 + + + + + diff --git a/tests/native/fixtures/gexf/provenance.json b/tests/native/fixtures/gexf/provenance.json new file mode 100644 index 0000000..bcbd1e9 --- /dev/null +++ b/tests/native/fixtures/gexf/provenance.json @@ -0,0 +1,14 @@ +{ + "repository": "https://github.com/gephi/gexf", + "commit": "66efb132569f61e5e8a313d78144484238ac7315", + "retrieved": "2026-10-12", + "license": "CC BY 4.0", + "files": [ + { + "file": "LICENSE.md", + "url": "https://raw.githubusercontent.com/gephi/gexf/66efb132569f61e5e8a313d78144484238ac7315/LICENSE.md", + "sha256": "5cf5523f3b93ea82470e997d7aeba53de25cdd130afa215c9fe617f423317d01" + } + ], + "changes": "Unmodified upstream license bytes." +} diff --git a/tests/native/fixtures/gml/native.gml b/tests/native/fixtures/gml/native.gml new file mode 100644 index 0000000..1e2a882 --- /dev/null +++ b/tests/native/fixtures/gml/native.gml @@ -0,0 +1,11 @@ +# Authored native fixture; integer identity and HTML numeric entities. +graph [ + directed 1 + multigraph 1 + label "Native α" + node [ id 0 label "A & "quoted"" vendor "KEEP" ] + node [ id 1 label "" ] + node [ id 2 ] + edge [ source 0 target 1 key 0 weight 2.5 label "first" ] + edge [ source 0 target 1 key 1 weight -3 ] +] diff --git a/tests/native/fixtures/gml/native.native.json b/tests/native/fixtures/gml/native.native.json new file mode 100644 index 0000000..999ab92 --- /dev/null +++ b/tests/native/fixtures/gml/native.native.json @@ -0,0 +1,86 @@ +{ + "oracle": "networkx", + "version": "3.7", + "python": "3.14.8", + "summary": { + "directed": true, + "multigraph": true, + "graph": { + "label": "Native α" + }, + "nodes": [ + { + "id": 0, + "attributes": { + "label": "A & \"quoted\"", + "vendor": "KEEP" + } + }, + { + "id": 1, + "attributes": { + "label": "" + } + }, + { + "id": 2, + "attributes": {} + } + ], + "edges": [ + { + "source": 0, + "target": 1, + "key": 0, + "attributes": { + "weight": 2.5, + "label": "first" + } + }, + { + "source": 0, + "target": 1, + "key": 1, + "attributes": { + "weight": -3 + } + } + ] + }, + "roundTrip": { + "directed": true, + "multigraph": true, + "graph": { + "label": "Native α" + }, + "nodes": [ + { + "label": "A & \"quoted\"", + "vendor": "KEEP", + "id": 0 + }, + { + "label": "", + "id": 1 + }, + { + "id": 2 + } + ], + "edges": [ + { + "weight": 2.5, + "label": "first", + "source": 0, + "target": 1, + "key": 0 + }, + { + "weight": -3, + "source": 0, + "target": 1, + "key": 1 + } + ] + } +} diff --git a/tests/native/fixtures/gml/provenance.json b/tests/native/fixtures/gml/provenance.json new file mode 100644 index 0000000..557722c --- /dev/null +++ b/tests/native/fixtures/gml/provenance.json @@ -0,0 +1,19 @@ +{ + "generated": "2026-10-12", + "oracle": "networkx", + "version": "3.7", + "python": "3.14.8", + "license": "Authored inputs and generated native outputs; NetworkX BSD-3-Clause.", + "documentation": "https://networkx.org/documentation/stable/reference/readwrite/generated/networkx.readwrite.gml.parse_gml.html", + "command": ".venv-native/bin/python scripts/native/networkx_oracle.py < JSON request (operation=gml)", + "files": [ + { + "file": "native.gml", + "sha256": "0d6cb1ef088fcee21e769e03e53ce801f9ca3c8217daaf2e3501521480c2f3ff" + }, + { + "file": "native.native.json", + "sha256": "2af412469d888c4cfa0c3ae1d246ad3e9b0dd4b5d8d1905b9a395dd48bad468c" + } + ] +} diff --git a/tests/native/fixtures/graphml/1.0/graphml-structure.xsd b/tests/native/fixtures/graphml/1.0/graphml-structure.xsd new file mode 100644 index 0000000..d220bb6 --- /dev/null +++ b/tests/native/fixtures/graphml/1.0/graphml-structure.xsd @@ -0,0 +1,1115 @@ + + + + + + The schema corresponding to this document defines the structural + layer of the Graph Markup Language (GraphML). + Although a DTD is provided, this schema is, together with its extensions + http://graphml.graphdrawing.org/xmlns/1.0/graphml-attributes.xsd + and + http://graphml.graphdrawing.org/xmlns/1.0/graphml-parseinfo.xsd, + the only normative reference. + + + + + + + Get access to the xlink attribute groups for the attributes + xlink:href and xlink:type of locator.type. + + + + + + + The attribute groups <element_name>.extra.attrib may be used + for adding user defined attributes to the elements + <element_name>. + The attribute group common.extra.attrib may be used for adding + user defined attributes to all elements. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Complex type definitions for the GraphML structural layer elements: + <data>, <default>, <key>, <graphml>, <graph>, + <node>, <port>, + <edge>, <hyperedge>, <endpoint> and <locator>. + The names of the complex types are constructed corresponding + to the pattern element_name.type. + (The only remaining GraphML structural layer element + <desc> is of simple type xs:string.) + + + + + + + + Extension mechanism for the content of <data> and <default>. + The complex type data-extension.type is empty per default. + Users may redefine this type in order to add content to + the complex types data.type and default.type which are + extensions of data-extension.type. + + + + + + + + + Complex type for the <data> element. + data.type is mixed, that is, <data> may contain #PCDATA. + Content type: extension of data-extension.type which is empty + per default. + + + + + + + refers to the id attribute of a <key>. + + + + + + identifies this <data>. + + + + + + + user defined extra attributes for <data> elements + + + + + + + + + + + + Complex type for the <default> element. + default.type is mixed, that is, data may contain #PCDATA. + Content type: extension of data-extension.type which is empty + per default. + + + + + + + + user defined extra attributes for <default> elements + + + + + + + + + + + + + Simple type for the for attribute of <key>. + key.for.type is a restriction of xs:NMTOKEN + Allowed values: all, graph, node, edge, hyperedge, port and endpoint. + + + + + + + + + + + + + + + + + + + Complex type for the <key> element. + + + + + + + + + identifies this <key>. + + + + + + + describes the domain of definition for + the corresponding graph attribute. + + + + + + + user defined extra attributes for <key> elements. + + + + + + + + + + Complex type for the <graphml> element. + + + + + + + + + + + + + + + + user defined extra attributes for <graphml> elements. + + + + + + + + + + Simple type for the edgedefault attribute of <graph>. + graph.edgedefault.type is a restriction of xs:NMTOKEN + Allowed values: directed, undirected. + + + + + + + + + + + + + Complex type for the <graph> element. + + + + + + + + + + + + + + + + + + + + user defined extra attributes for <graph> elements. + + + + + + + identifies this graph . + + + + + + + describes whether edges of this graph are considered + as directed or undirected per default (unless + specified by the attribute directed of <edge>). + + + + + + + + + + + Complex type for the <node> element. + + + + + + + + + + + + + + + + + + + user defined extra attributes for <node elements. + + + + + + + identifies this node. + + + + + + + + + + + Complex type for the <port> element. + + + + + + + + + + + + + user defined extra attributes for <port> elements. + + + + + + + identifies this port, within the node it is contained in. + + + + + + + + + + + Complex type for the <edge> element. + + + + + + + + + + + user defined extra attributes for <edge> elements. + + + + + + + identifies this edge . + + + + + + + overwrites the edgedefault attribute of <graph> . + + + + + + + points to the id attribute of the source <node>. + + + + + + + points to the id attribute of the target <node>. + + + + + + + points to the name attribute of the source <port>. + + + + + + + points to the name attribute of the target <port>. + + + + + + + + + + Complex type for the <hyperedge> element. + + + + + + + + + + + + + + user defined extra attributes for <hyperedge> elements. + + + + + + + identifies this <hyperedge> . + + + + + + + + + + Simple type for the type attribute of <endpoint>. + endpoint.type.type is a restriction of xs:NMTOKEN + Allowed values: in, out, undir. + + + + + + + + + + + + + Complex type for the <endpoint> element. + + + + + + + + + user defined extra attributes for <endpoint> elements. + + + + + + + identifies this <endpoint> . + + + + + + + points to the name of the port, to which this endpoint is + connected . + + + + + + + points to the id of the node, to which this endpoint is connected. + + + + + + + defines the direction on this endpoint (undirected per default). + + + + + + + + + + Complex type for the <locator> element. + Content type: (empty) + + + + + + user defined extra attributes for <locator> elements. + + + + + + + points to the resource of this locator. + + + + + + + + type of the hyperlink (fixed as simple). + + + + + + + + + + + Description: Provides human-readable descriptions for the GraphML + element containing this <desc> as its first child. + Occurence: <key>, <graphml>, <graph>, + <node>, <port>, <edge>, <hyperedge>, and + <endpoint>. + + + + + + + + + Description: Graphs and nodes are declared by the elements + <graph> and <node>, respectively. The optional + <locator>-child of these elements point to + their definition. (If there is no <locator>-child + the graphs/nodes are defined by their content). + Occurence: <graph>, and <node>. + + + + + + + + Description: In GraphML there may be data-functions attached + to graphs, nodes, ports, edges, hyperedges and + endpoint and to the whole collection of + graphs described by the content of <graphml>. + These functions are declared by <key> elements + (children of <graphml>) and defined by <data> + elements. + Occurence: <graphml>, <graph>, <node>, <port>, + <edge>, <hyperedge>, and <endpoint>. + + + + + + + Ensures: uniqueness of the key attributes of <data> children + of this <data> element. + + + + + + + + + + + + + Description: In GraphML there may be data-functions attached + to graphs, nodes, ports, edges, hyperedges and + endpoint and to the whole collection of + graphs described by the content of <graphml>. + These functions are declared by <key> elements + (children of <graphml>) and defined by <data> + elements. + Occurence: <graphml>. + + + + + + + + Description: In GraphML there may be data-functions attached + to graphs, nodes, ports, edges, hyperedges and + endpoint and to the whole collection of + graphs described by the content of <graphml>. + These functions are declared by <key> elements + (children of <graphml>) and defined by <data> + elements. + The (optional) <default> child of <key> gives + the default value for the corresponding function. + Occurence: <key>. + + + + + + + + Description: <graphml> is the root element of each GraphML + document. + Occurence: root. + + + + + + + Ensures: uniqueness of the key attributes of <data> children + of this <graphml> element. + + + + + + + + + + Ensures: existence and uniqueness of the id attributes of + each <key> element in this document. + + + + + + + + + + + Ensures: uniqueness of the id attributes of + each <graph> element in this document. + + + + + + + + + + Ensures: for the key attribute of each <data> in this document, + the existence of an id attribute of + <key> which matches the value of it. + + + + + + + + + + + + + + + Description: Describes one graph in this document. + Occurence: <graphml>, <node>, <edge>, <hyperedge>. + + + + + + + Ensures: uniqueness of the key attributes of <data> children + of this <graph> element. + + + + + + + + + + Ensures: existence and uniqueness of the id attributes of + each <node> element in this graph. + + + + + + + + + + Ensures: uniqueness of the id attributes of + each <edge> element in this graph. + + + + + + + + + + Ensures: uniqueness of the id attributes of + each <hyperedge> element in this graph. + + + + + + + + + + Ensures: uniqueness of the id attributes of + each <endpoint> element in this graph. + + + + + + + + + + Ensures: for the source attribute of each <edge> in this graph, + the existence of an id attribute of + <node> which matches the value of it. + + + + + + + + + + Ensures: for the target attribute of each <edge> in this graph, + the existence of an id attribute of + <node> which matches the value of it. + + + + + + + + + + Ensures: for the node attribute of each <endpoint> in this graph, + the existence of an id attribute of + <node> which matches the value of it. + + + + + + + + + + + + + Description: Describes one node in the <graph> + containing this <node>. + Occurence: <graph>. + + + + + + + Ensures: existence and uniqueness of the name attributes of + each <port> element within this <node>. + + + + + + + + + + Ensures: uniqueness of the key attributes of <data> children + of this <node> element. + + + + + + + + + + + + + Description: Nodes may be structured by ports; thus edges + are not only attached to a node but to a certain + port in this node. + Occurence: <node>, <port>. + + + + + + + Ensures: uniqueness of the key attributes of <data> children + of this <port> element. + + + + + + + + + + + + + Description: Describes an edge in the <graph> which contains this + <edge>. + Occurence: <graph>. + + + + + + + Ensures: uniqueness of the key attributes of <data> children + of this <edge> element. + + + + + + + + + + + + + Description: While edges describe relations between two nodes, + a hyperedge describes a relation between an arbitrary + number of nodes. + Occurence: <graph>. + + + + + + + Ensures: uniqueness of the key attributes of <data> children + of this <hyperedge> element. + + + + + + + + + + + + + Description: The list of <endpoints> within a hyperedge + points to the nodes contained in this hyperedge. + Occurence: <hyperedge>. + + + + + + + + + diff --git a/tests/native/fixtures/graphml/1.0/graphml.xsd b/tests/native/fixtures/graphml/1.0/graphml.xsd new file mode 100644 index 0000000..00e79f4 --- /dev/null +++ b/tests/native/fixtures/graphml/1.0/graphml.xsd @@ -0,0 +1,345 @@ + + + + + + + This document defines the GraphML language including GraphML attributes and GraphML parseinfo. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Simple type for the attr.name attribute of <key>. + key.name.type is final, that is, it may not be extended + or restricted. + key.name.type is a restriction of xs:NMTOKEN + Allowed values: (no restriction) + + + + + + + + + + + + + + Simple type for the attr.type attribute of <key>. + key.type.type is final, that is, it may not be extended + or restricted. + key.type.type is a restriction of xs:NMTOKEN + Allowed values: boolean, int, long, float, double, string. + + + + + + + + + + + + + + + + + + + + Definition of the attribute group key.attributes.attrib. + This group consists of the two optional attributes + - attr.name (gives the name for the data function) + - attr.type ((declares the range of values for the data function) + + + + + + + + + + + + Simple type definitions for the new graph attributes. + + + + + + + + Simple type for the parse.order attribute of <graph>. + graph.order.type is final, that is, it may not be extended + or restricted. + graph.order.type is a restriction of xs:NMTOKEN + Allowed values: free, nodesfirst, adjacencylist. + + + + + + + + + + + + + + + Simple type for the parse.nodes attribute of <graph>. + graph.nodes.type is final, that is, it may not be extended + or restricted. + graph.nodes.type is a restriction of xs:nonNegativeInteger + Allowed values: (no restriction). + + + + + + + + + + + Simple type for the parse.edges attribute of <graph>. + graph.edges.type is final, that is, it may not be extended + or restricted. + graph.edges.type is a restriction of xs:nonNegativeInteger + Allowed values: (no restriction). + + + + + + + + + + + Simple type for the parse.maxindegree attribute of <graph>. + graph.maxindegree.type is final, that is, it may not be extended + or restricted. + graph.maxindegree.type is a restriction of xs:nonNegativeInteger + Allowed values: (no restriction). + + + + + + + + + + + Simple type for the parse.maxoutdegree attribute of <graph>. + graph.maxoutdegree.type is final, that is, it may not be extended + or restricted. + graph.maxoutdegree.type is a restriction of xs:nonNegativeInteger + Allowed values: (no restriction). + + + + + + + + + + + Simple type for the parse.nodeids attribute of <graph>. + graph.nodeids.type is final, that is, it may not be extended + or restricted. + graph.nodeids.type is a restriction of xs:string + Allowed values: (no restriction). + + + + + + + + + + + + + + Simple type for the parse.edgeids attribute of <graph>. + graph.edgeids.type is final, that is, it may not be extended + or restricted. + graph.edgeids.type is a restriction of xs:string + Allowed values: (no restriction). + + + + + + + + + + + + + + Definition of the attribute group graph.parseinfo.attrib. + This group consists of the seven attributes + - parse.nodeids (fixed to 'canonical' meaning that the id attribute + of <node> follows the pattern 'n[number]), + - parse.edgeids (fixed to 'canonical' meaning that the id attribute + of <edge> follows the pattern 'e[number]), + - parse.order (required; one of the values 'nodesfirst', + 'adjacencylist' or 'free'), + - parse.nodes (required; number of nodes in this graph), + - parse.edges (required; number of edges in this graph), + - parse.maxindegree (optional; maximal indegree of a node in this graph), + - parse.maxoutdegree (optional; maximal outdegree of a node in this graph) + + + + + + + + + + + + + + + + + Simple type definitions for the new node attributes. + + + + + + + + Simple type for the parse.indegree attribute of <node>. + node.indegree.type is final, that is, it may not be extended + or restricted. + node.indegree.type is a restriction of xs:nonNegativeInteger + Allowed values: (no restriction). + + + + + + + + + + + Simple type for the parse.outdegree attribute of <node>. + node.outdegree.type is final, that is, it may not be extended + or restricted. + node.outdegree.type is a restriction of xs:nonNegativeInteger + Allowed values: (no restriction). + + + + + + + + + + + Definition of the attribute group node.parseinfo.attrib. + This group consists of two attributes + - parse.indegree (optional; indegree of this node), + - parse.outdegree (optional; outdegree of this node). + + + + + + + + + + + + diff --git a/tests/native/fixtures/graphml/1.0/provenance.json b/tests/native/fixtures/graphml/1.0/provenance.json new file mode 100644 index 0000000..57ce4d4 --- /dev/null +++ b/tests/native/fixtures/graphml/1.0/provenance.json @@ -0,0 +1,40 @@ +{ + "retrieved": "2026-10-12", + "entry": "graphml.xsd", + "license": "CC BY 3.0; ../LICENSE.txt; attribution GraphML Working Group and https://graphml.graphdrawing.org/", + "files": [ + { + "file": "graphml.xsd", + "url": "http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd", + "retrievedFrom": "http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd", + "sha256": "c5d04fb93d579712aa375f3ace67fe9243d6aaa9b039576516722af2d9c5af52", + "resolverRewrites": [ + { + "from": "http://graphml.graphdrawing.org/xmlns/1.0/graphml-structure.xsd", + "to": "graphml-structure.xsd" + } + ] + }, + { + "file": "graphml-structure.xsd", + "url": "http://graphml.graphdrawing.org/xmlns/1.0/graphml-structure.xsd", + "retrievedFrom": "http://graphml.graphdrawing.org/xmlns/1.0/graphml-structure.xsd", + "sha256": "7809123855d2ef634dea2b678055b6230607efb9addde55a06c4afd67075a04b", + "resolverRewrites": [ + { + "from": "http://graphml.graphdrawing.org/xmlns/1.0/xlink.xsd", + "to": "xlink.xsd" + } + ] + }, + { + "file": "xlink.xsd", + "url": "http://graphml.graphdrawing.org/xmlns/1.0/xlink.xsd", + "retrievedFrom": "http://graphml.graphdrawing.org/xmlns/1.0/xlink.xsd", + "sha256": "4f76a2f3c026979b733f92d3f85c815723848b72731a72ef40a1709959b698ed", + "resolverRewrites": [] + } + ], + "changes": "Schema bytes are unmodified. Test resolver replaces only schemaLocation URLs in memory with vendored filenames; semantic schema components unchanged.", + "retrievalNote": "Official GraphML host currently has a mismatched HTTPS certificate; schema was retrieved from its published HTTP endpoint. Original SHA256 pinned." +} diff --git a/tests/native/fixtures/graphml/1.0/xlink.xsd b/tests/native/fixtures/graphml/1.0/xlink.xsd new file mode 100644 index 0000000..23120ba --- /dev/null +++ b/tests/native/fixtures/graphml/1.0/xlink.xsd @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/native/fixtures/graphml/LICENSE.txt b/tests/native/fixtures/graphml/LICENSE.txt new file mode 100644 index 0000000..1a16e05 --- /dev/null +++ b/tests/native/fixtures/graphml/LICENSE.txt @@ -0,0 +1,319 @@ +Creative Commons Legal Code + +Attribution 3.0 Unported + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR + DAMAGES RESULTING FROM ITS USE. + +License + +THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE +COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY +COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS +AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. + +BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE +TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY +BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS +CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND +CONDITIONS. + +1. Definitions + + a. "Adaptation" means a work based upon the Work, or upon the Work and + other pre-existing works, such as a translation, adaptation, + derivative work, arrangement of music or other alterations of a + literary or artistic work, or phonogram or performance and includes + cinematographic adaptations or any other form in which the Work may be + recast, transformed, or adapted including in any form recognizably + derived from the original, except that a work that constitutes a + Collection will not be considered an Adaptation for the purpose of + this License. For the avoidance of doubt, where the Work is a musical + work, performance or phonogram, the synchronization of the Work in + timed-relation with a moving image ("synching") will be considered an + Adaptation for the purpose of this License. + b. "Collection" means a collection of literary or artistic works, such as + encyclopedias and anthologies, or performances, phonograms or + broadcasts, or other works or subject matter other than works listed + in Section 1(f) below, which, by reason of the selection and + arrangement of their contents, constitute intellectual creations, in + which the Work is included in its entirety in unmodified form along + with one or more other contributions, each constituting separate and + independent works in themselves, which together are assembled into a + collective whole. A work that constitutes a Collection will not be + considered an Adaptation (as defined above) for the purposes of this + License. + c. "Distribute" means to make available to the public the original and + copies of the Work or Adaptation, as appropriate, through sale or + other transfer of ownership. + d. "Licensor" means the individual, individuals, entity or entities that + offer(s) the Work under the terms of this License. + e. "Original Author" means, in the case of a literary or artistic work, + the individual, individuals, entity or entities who created the Work + or if no individual or entity can be identified, the publisher; and in + addition (i) in the case of a performance the actors, singers, + musicians, dancers, and other persons who act, sing, deliver, declaim, + play in, interpret or otherwise perform literary or artistic works or + expressions of folklore; (ii) in the case of a phonogram the producer + being the person or legal entity who first fixes the sounds of a + performance or other sounds; and, (iii) in the case of broadcasts, the + organization that transmits the broadcast. + f. "Work" means the literary and/or artistic work offered under the terms + of this License including without limitation any production in the + literary, scientific and artistic domain, whatever may be the mode or + form of its expression including digital form, such as a book, + pamphlet and other writing; a lecture, address, sermon or other work + of the same nature; a dramatic or dramatico-musical work; a + choreographic work or entertainment in dumb show; a musical + composition with or without words; a cinematographic work to which are + assimilated works expressed by a process analogous to cinematography; + a work of drawing, painting, architecture, sculpture, engraving or + lithography; a photographic work to which are assimilated works + expressed by a process analogous to photography; a work of applied + art; an illustration, map, plan, sketch or three-dimensional work + relative to geography, topography, architecture or science; a + performance; a broadcast; a phonogram; a compilation of data to the + extent it is protected as a copyrightable work; or a work performed by + a variety or circus performer to the extent it is not otherwise + considered a literary or artistic work. + g. "You" means an individual or entity exercising rights under this + License who has not previously violated the terms of this License with + respect to the Work, or who has received express permission from the + Licensor to exercise rights under this License despite a previous + violation. + h. "Publicly Perform" means to perform public recitations of the Work and + to communicate to the public those public recitations, by any means or + process, including by wire or wireless means or public digital + performances; to make available to the public Works in such a way that + members of the public may access these Works from a place and at a + place individually chosen by them; to perform the Work to the public + by any means or process and the communication to the public of the + performances of the Work, including by public digital performance; to + broadcast and rebroadcast the Work by any means including signs, + sounds or images. + i. "Reproduce" means to make copies of the Work by any means including + without limitation by sound or visual recordings and the right of + fixation and reproducing fixations of the Work, including storage of a + protected performance or phonogram in digital form or other electronic + medium. + +2. Fair Dealing Rights. Nothing in this License is intended to reduce, +limit, or restrict any uses free from copyright or rights arising from +limitations or exceptions that are provided for in connection with the +copyright protection under copyright law or other applicable laws. + +3. License Grant. Subject to the terms and conditions of this License, +Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +perpetual (for the duration of the applicable copyright) license to +exercise the rights in the Work as stated below: + + a. to Reproduce the Work, to incorporate the Work into one or more + Collections, and to Reproduce the Work as incorporated in the + Collections; + b. to create and Reproduce Adaptations provided that any such Adaptation, + including any translation in any medium, takes reasonable steps to + clearly label, demarcate or otherwise identify that changes were made + to the original Work. For example, a translation could be marked "The + original work was translated from English to Spanish," or a + modification could indicate "The original work has been modified."; + c. to Distribute and Publicly Perform the Work including as incorporated + in Collections; and, + d. to Distribute and Publicly Perform Adaptations. + e. For the avoidance of doubt: + + i. Non-waivable Compulsory License Schemes. In those jurisdictions in + which the right to collect royalties through any statutory or + compulsory licensing scheme cannot be waived, the Licensor + reserves the exclusive right to collect such royalties for any + exercise by You of the rights granted under this License; + ii. Waivable Compulsory License Schemes. In those jurisdictions in + which the right to collect royalties through any statutory or + compulsory licensing scheme can be waived, the Licensor waives the + exclusive right to collect such royalties for any exercise by You + of the rights granted under this License; and, + iii. Voluntary License Schemes. The Licensor waives the right to + collect royalties, whether individually or, in the event that the + Licensor is a member of a collecting society that administers + voluntary licensing schemes, via that society, from any exercise + by You of the rights granted under this License. + +The above rights may be exercised in all media and formats whether now +known or hereafter devised. The above rights include the right to make +such modifications as are technically necessary to exercise the rights in +other media and formats. Subject to Section 8(f), all rights not expressly +granted by Licensor are hereby reserved. + +4. Restrictions. The license granted in Section 3 above is expressly made +subject to and limited by the following restrictions: + + a. You may Distribute or Publicly Perform the Work only under the terms + of this License. You must include a copy of, or the Uniform Resource + Identifier (URI) for, this License with every copy of the Work You + Distribute or Publicly Perform. You may not offer or impose any terms + on the Work that restrict the terms of this License or the ability of + the recipient of the Work to exercise the rights granted to that + recipient under the terms of the License. You may not sublicense the + Work. You must keep intact all notices that refer to this License and + to the disclaimer of warranties with every copy of the Work You + Distribute or Publicly Perform. When You Distribute or Publicly + Perform the Work, You may not impose any effective technological + measures on the Work that restrict the ability of a recipient of the + Work from You to exercise the rights granted to that recipient under + the terms of the License. This Section 4(a) applies to the Work as + incorporated in a Collection, but this does not require the Collection + apart from the Work itself to be made subject to the terms of this + License. If You create a Collection, upon notice from any Licensor You + must, to the extent practicable, remove from the Collection any credit + as required by Section 4(b), as requested. If You create an + Adaptation, upon notice from any Licensor You must, to the extent + practicable, remove from the Adaptation any credit as required by + Section 4(b), as requested. + b. If You Distribute, or Publicly Perform the Work or any Adaptations or + Collections, You must, unless a request has been made pursuant to + Section 4(a), keep intact all copyright notices for the Work and + provide, reasonable to the medium or means You are utilizing: (i) the + name of the Original Author (or pseudonym, if applicable) if supplied, + and/or if the Original Author and/or Licensor designate another party + or parties (e.g., a sponsor institute, publishing entity, journal) for + attribution ("Attribution Parties") in Licensor's copyright notice, + terms of service or by other reasonable means, the name of such party + or parties; (ii) the title of the Work if supplied; (iii) to the + extent reasonably practicable, the URI, if any, that Licensor + specifies to be associated with the Work, unless such URI does not + refer to the copyright notice or licensing information for the Work; + and (iv) , consistent with Section 3(b), in the case of an Adaptation, + a credit identifying the use of the Work in the Adaptation (e.g., + "French translation of the Work by Original Author," or "Screenplay + based on original Work by Original Author"). The credit required by + this Section 4 (b) may be implemented in any reasonable manner; + provided, however, that in the case of a Adaptation or Collection, at + a minimum such credit will appear, if a credit for all contributing + authors of the Adaptation or Collection appears, then as part of these + credits and in a manner at least as prominent as the credits for the + other contributing authors. For the avoidance of doubt, You may only + use the credit required by this Section for the purpose of attribution + in the manner set out above and, by exercising Your rights under this + License, You may not implicitly or explicitly assert or imply any + connection with, sponsorship or endorsement by the Original Author, + Licensor and/or Attribution Parties, as appropriate, of You or Your + use of the Work, without the separate, express prior written + permission of the Original Author, Licensor and/or Attribution + Parties. + c. Except as otherwise agreed in writing by the Licensor or as may be + otherwise permitted by applicable law, if You Reproduce, Distribute or + Publicly Perform the Work either by itself or as part of any + Adaptations or Collections, You must not distort, mutilate, modify or + take other derogatory action in relation to the Work which would be + prejudicial to the Original Author's honor or reputation. Licensor + agrees that in those jurisdictions (e.g. Japan), in which any exercise + of the right granted in Section 3(b) of this License (the right to + make Adaptations) would be deemed to be a distortion, mutilation, + modification or other derogatory action prejudicial to the Original + Author's honor and reputation, the Licensor will waive or not assert, + as appropriate, this Section, to the fullest extent permitted by the + applicable national law, to enable You to reasonably exercise Your + right under Section 3(b) of this License (right to make Adaptations) + but not otherwise. + +5. Representations, Warranties and Disclaimer + +UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR +OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY +KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, +INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, +FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF +LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, +WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION +OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU. + +6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE +LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR +ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES +ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS +BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +7. Termination + + a. This License and the rights granted hereunder will terminate + automatically upon any breach by You of the terms of this License. + Individuals or entities who have received Adaptations or Collections + from You under this License, however, will not have their licenses + terminated provided such individuals or entities remain in full + compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will + survive any termination of this License. + b. Subject to the above terms and conditions, the license granted here is + perpetual (for the duration of the applicable copyright in the Work). + Notwithstanding the above, Licensor reserves the right to release the + Work under different license terms or to stop distributing the Work at + any time; provided, however that any such election will not serve to + withdraw this License (or any other license that has been, or is + required to be, granted under the terms of this License), and this + License will continue in full force and effect unless terminated as + stated above. + +8. Miscellaneous + + a. Each time You Distribute or Publicly Perform the Work or a Collection, + the Licensor offers to the recipient a license to the Work on the same + terms and conditions as the license granted to You under this License. + b. Each time You Distribute or Publicly Perform an Adaptation, Licensor + offers to the recipient a license to the original Work on the same + terms and conditions as the license granted to You under this License. + c. If any provision of this License is invalid or unenforceable under + applicable law, it shall not affect the validity or enforceability of + the remainder of the terms of this License, and without further action + by the parties to this agreement, such provision shall be reformed to + the minimum extent necessary to make such provision valid and + enforceable. + d. No term or provision of this License shall be deemed waived and no + breach consented to unless such waiver or consent shall be in writing + and signed by the party to be charged with such waiver or consent. + e. This License constitutes the entire agreement between the parties with + respect to the Work licensed here. There are no understandings, + agreements or representations with respect to the Work not specified + here. Licensor shall not be bound by any additional provisions that + may appear in any communication from You. This License may not be + modified without the mutual written agreement of the Licensor and You. + f. The rights granted under, and the subject matter referenced, in this + License were drafted utilizing the terminology of the Berne Convention + for the Protection of Literary and Artistic Works (as amended on + September 28, 1979), the Rome Convention of 1961, the WIPO Copyright + Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996 + and the Universal Copyright Convention (as revised on July 24, 1971). + These rights and subject matter take effect in the relevant + jurisdiction in which the License terms are sought to be enforced + according to the corresponding provisions of the implementation of + those treaty provisions in the applicable national law. If the + standard suite of rights granted under applicable copyright law + includes additional rights not granted under this License, such + additional rights are deemed to be included in the License; this + License is not intended to restrict the license of any rights under + applicable law. + + +Creative Commons Notice + + Creative Commons is not a party to this License, and makes no warranty + whatsoever in connection with the Work. Creative Commons will not be + liable to You or any party on any legal theory for any damages + whatsoever, including without limitation any general, special, + incidental or consequential damages arising in connection to this + license. Notwithstanding the foregoing two (2) sentences, if Creative + Commons has expressly identified itself as the Licensor hereunder, it + shall have all rights and obligations of Licensor. + + Except for the limited purpose of indicating to the public that the + Work is licensed under the CCPL, Creative Commons does not authorize + the use by either party of the trademark "Creative Commons" or any + related trademark or logo of Creative Commons without the prior + written consent of Creative Commons. Any permitted use will be in + compliance with Creative Commons' then-current trademark usage + guidelines, as may be published on its website or otherwise made + available upon request from time to time. For the avoidance of doubt, + this trademark restriction does not form part of this License. + + Creative Commons may be contacted at https://creativecommons.org/. diff --git a/tests/native/fixtures/graphml/cases/nested-keyrefs.graphml b/tests/native/fixtures/graphml/cases/nested-keyrefs.graphml new file mode 100644 index 0000000..f1e8332 --- /dev/null +++ b/tests/native/fixtures/graphml/cases/nested-keyrefs.graphml @@ -0,0 +1,10 @@ + + + Default + 7 + + A αPort + 9 + Edge + + diff --git a/tests/native/fixtures/graphml/cases/provenance.json b/tests/native/fixtures/graphml/cases/provenance.json new file mode 100644 index 0000000..10c58c0 --- /dev/null +++ b/tests/native/fixtures/graphml/cases/provenance.json @@ -0,0 +1,17 @@ +{ + "created": "2026-10-12", + "author": "@statelyai/graph native regression fixtures", + "license": "Repository MIT license; authored examples, not upstream schema copies.", + "specification": "https://graphml.graphdrawing.org/specification.html", + "files": [ + { + "file": "scopes-ports.graphml", + "sha256": "7c917dca46b78b4cd65e5d32707281565e6c228c14a26587c3fbaed6591bdcc6" + }, + { + "file": "nested-keyrefs.graphml", + "sha256": "b139a2a8f7d66f024f673f3470aeb522b6512f76f8193f5152916173f1f2c486" + } + ], + "notes": "nested-keyrefs.graphml intentionally demonstrates the official GraphML descendant identity-constraint ambiguity in libxml2; all other authored fixtures validate against their pinned profile." +} diff --git a/tests/native/fixtures/graphml/cases/scopes-ports.graphml b/tests/native/fixtures/graphml/cases/scopes-ports.graphml new file mode 100644 index 0000000..b543d84 --- /dev/null +++ b/tests/native/fixtures/graphml/cases/scopes-ports.graphml @@ -0,0 +1,10 @@ + + + Default + 7 + + A αPort + 9 + Edge + + diff --git a/tests/native/fixtures/graphml/provenance.json b/tests/native/fixtures/graphml/provenance.json new file mode 100644 index 0000000..8af6261 --- /dev/null +++ b/tests/native/fixtures/graphml/provenance.json @@ -0,0 +1,12 @@ +{ + "retrieved": "2026-10-12", + "license": "CC BY 3.0", + "licenseDeclaration": "https://graphml.graphdrawing.org/", + "files": [ + { + "file": "LICENSE.txt", + "url": "https://creativecommons.org/licenses/by/3.0/legalcode.txt", + "sha256": "e6bc9e9c474700b708f568bac9e5a8a9bcb2b1dad53442f5ba449fcb848b8e76" + } + ] +} diff --git a/tests/native/fixtures/graphviz/clusters-ranks.dot b/tests/native/fixtures/graphviz/clusters-ranks.dot new file mode 100644 index 0000000..0c2b15f --- /dev/null +++ b/tests/native/fixtures/graphviz/clusters-ranks.dot @@ -0,0 +1 @@ +digraph G { subgraph cluster_group { a [label="A"]; b; } {rank=same; a; c;} a:n -> c:s [label="E"]; isolated; } diff --git a/tests/native/fixtures/graphviz/clusters-ranks.json b/tests/native/fixtures/graphviz/clusters-ranks.json new file mode 100644 index 0000000..3dba67c --- /dev/null +++ b/tests/native/fixtures/graphviz/clusters-ranks.json @@ -0,0 +1,280 @@ +{ + "name": "G", + "directed": true, + "strict": false, + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#fffffe00" + }, + { + "op": "C", + "grad": "none", + "color": "#ffffff" + }, + { + "op": "P", + "points": [[0.000,0.000],[0.000,84.710],[316.070,84.710],[316.070,0.000]] + } + ], + "bb": "0,0,316.07,84.706", + "xdotversion": "1.7", + "_subgraph_cnt": 2, + "objects": [ + { + "name": "cluster_group", + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "p", + "points": [[154.000,24.710],[154.000,76.710],[224.000,76.710],[224.000,24.710]] + } + ], + "bb": "154,24.706,224,76.706", + "_gvid": 0, + "nodes": [ + 3 + ] + }, + { + "name": "%1", + "rank": "same", + "_gvid": 1, + "nodes": [ + 2,4 + ] + }, + { + "_gvid": 2, + "name": "a", + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "e", + "rect": [27.000,50.710,27.000,18.000] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [27.000,46.510], + "align": "c", + "width": 10.110, + "text": "A" + } + ], + "height": "0.5", + "label": "A", + "pos": "27,50.706", + "width": "0.75" + }, + { + "_gvid": 3, + "name": "b", + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "e", + "rect": [189.000,50.710,27.000,18.000] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [189.000,46.510], + "align": "c", + "width": 7.000, + "text": "b" + } + ], + "height": "0.5", + "label": "\\N", + "pos": "189,50.706", + "width": "0.75" + }, + { + "_gvid": 4, + "name": "c", + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "e", + "rect": [117.000,50.710,27.000,18.000] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [117.000,46.510], + "align": "c", + "width": 6.210, + "text": "c" + } + ], + "height": "0.5", + "label": "\\N", + "pos": "117,50.706", + "width": "0.75" + }, + { + "_gvid": 5, + "name": "isolated", + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "e", + "rect": [275.000,50.710,41.070,18.000] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [275.000,46.510], + "align": "c", + "width": 43.540, + "text": "isolated" + } + ], + "height": "0.5", + "label": "\\N", + "pos": "275,50.706", + "width": "1.1409" + } + ], + "edges": [ + { + "_gvid": 0, + "tail": 2, + "head": 4, + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "b", + "points": [[27.000,70.030],[27.000,82.040],[44.570,76.460],[54.000,69.030],[71.800,54.990],[49.670,33.610],[67.720,19.910],[84.700,7.020],[112.030,-17.280],[116.400,19.890]] + } + ], + "_hdraw_": + [ + { + "op": "S", + "style": "solid" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "C", + "grad": "none", + "color": "#000000" + }, + { + "op": "P", + "points": [[112.910,20.070],[116.920,29.870],[119.900,19.710]] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [72.000,24.110], + "align": "c", + "width": 8.550, + "text": "E" + } + ], + "headport": "s", + "label": "E", + "lp": "72,28.306", + "pos": "e,117,31.386 27,70.027 27,82.035 44.57,76.462 54,69.027 71.8,54.992 49.668,33.61 67.724,19.906 84.7,7.0232 112.03,-17.281 116.4,19.895", + "tailport": "n" + } + ] +} diff --git a/tests/native/fixtures/graphviz/parallel-undirected.dot b/tests/native/fixtures/graphviz/parallel-undirected.dot new file mode 100644 index 0000000..1baaaec --- /dev/null +++ b/tests/native/fixtures/graphviz/parallel-undirected.dot @@ -0,0 +1 @@ +graph G { a -- b[label="first"]; a -- b[label="second"]; isolated; } diff --git a/tests/native/fixtures/graphviz/parallel-undirected.json b/tests/native/fixtures/graphviz/parallel-undirected.json new file mode 100644 index 0000000..7d4183b --- /dev/null +++ b/tests/native/fixtures/graphviz/parallel-undirected.json @@ -0,0 +1,229 @@ +{ + "name": "G", + "directed": false, + "strict": false, + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#fffffe00" + }, + { + "op": "C", + "grad": "none", + "color": "#ffffff" + }, + { + "op": "P", + "points": [[0.000,0.000],[0.000,124.800],[154.070,124.800],[154.070,0.000]] + } + ], + "bb": "0,0,154.07,124.8", + "xdotversion": "1.7", + "_subgraph_cnt": 0, + "objects": [ + { + "_gvid": 0, + "name": "a", + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "e", + "rect": [27.000,106.800,27.000,18.000] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [27.000,102.600], + "align": "c", + "width": 6.210, + "text": "a" + } + ], + "height": "0.5", + "label": "\\N", + "pos": "27,106.8", + "width": "0.75" + }, + { + "_gvid": 1, + "name": "b", + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "e", + "rect": [27.000,18.000,27.000,18.000] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [27.000,13.800], + "align": "c", + "width": 7.000, + "text": "b" + } + ], + "height": "0.5", + "label": "\\N", + "pos": "27,18", + "width": "0.75" + }, + { + "_gvid": 2, + "name": "isolated", + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "e", + "rect": [113.000,106.800,41.070,18.000] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [113.000,102.600], + "align": "c", + "width": 43.540, + "text": "isolated" + } + ], + "height": "0.5", + "label": "\\N", + "pos": "113,106.8", + "width": "1.1409" + } + ], + "edges": [ + { + "_gvid": 0, + "tail": 0, + "head": 1, + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "b", + "points": [[14.910,90.300],[11.110,84.510],[7.390,77.670],[5.450,70.800],[3.410,63.620],[3.410,61.180],[5.450,54.000],[7.390,47.130],[11.110,40.290],[14.910,34.500]] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [16.720,58.200], + "align": "c", + "width": 22.550, + "text": "first" + } + ], + "label": "first", + "lp": "16.724,62.4", + "pos": "14.911,90.298 11.106,84.508 7.3926,77.666 5.4482,70.8 3.4138,63.616 3.4138,61.184 5.4482,54 7.3926,47.134 11.106,40.292 14.911,34.502" + }, + { + "_gvid": 1, + "tail": 0, + "head": 1, + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "b", + "points": [[27.600,88.360],[27.760,82.750],[27.920,76.510],[28.000,70.800],[28.110,63.330],[28.110,61.470],[28.000,54.000],[27.920,48.290],[27.760,42.050],[27.600,36.440]] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [47.520,58.200], + "align": "c", + "width": 38.880, + "text": "second" + } + ], + "label": "second", + "lp": "47.518,62.4", + "pos": "27.596,88.355 27.762,82.75 27.918,76.514 28,70.8 28.107,63.334 28.107,61.466 28,54 27.918,48.286 27.762,42.05 27.596,36.445" + } + ] +} diff --git a/tests/native/fixtures/graphviz/provenance.json b/tests/native/fixtures/graphviz/provenance.json new file mode 100644 index 0000000..4f7601f --- /dev/null +++ b/tests/native/fixtures/graphviz/provenance.json @@ -0,0 +1,36 @@ +{ + "generated": "2026-10-12", + "enginePackage": "@hpcc-js/wasm-graphviz", + "packageVersion": "1.22.0", + "engineVersion": "15.0.0", + "command": "engine.layout(source, 'json', 'dot')", + "license": "Own DOT fixtures; Graphviz output derived from those inputs. Graphviz 15 EPL-2.0; WASM wrapper Apache-2.0.", + "documentation": "https://graphviz.org/docs/outputs/json/", + "files": [ + { + "file": "strict-ports.dot", + "sha256": "d39cbc028e60dc86bffc1eb6bd9c878907106757f845b8d093c0d374647b268f" + }, + { + "file": "strict-ports.json", + "sha256": "3a4985bebfd3cd80488868bf69033012f3d160a280d8ac4c944104063a134ad6" + }, + { + "file": "clusters-ranks.dot", + "sha256": "fe0d0b0e96fd99ba3cddae62af7936291a0206dbd7723fb8cfd5b08c05b7cf20" + }, + { + "file": "clusters-ranks.json", + "sha256": "b8b35f14f42ac5d845398f86643af6539a9d2d60e72ae0599e2018bd810b66b4" + }, + { + "file": "parallel-undirected.dot", + "sha256": "87d95ac5e09d3d31df533e79353c33a196a23f8d03927783b025ef5b99f4752f" + }, + { + "file": "parallel-undirected.json", + "sha256": "21dbca163dbe9793bb98921fbc0f2b7d067b6caa7d5ab755e812844721c0cc58" + } + ], + "serialization": "Generated JSON capture formatting normalizes trailing horizontal whitespace only; parsed native JSON values remain unchanged." +} diff --git a/tests/native/fixtures/graphviz/schema/LICENSE.txt b/tests/native/fixtures/graphviz/schema/LICENSE.txt new file mode 100644 index 0000000..e23ece2 --- /dev/null +++ b/tests/native/fixtures/graphviz/schema/LICENSE.txt @@ -0,0 +1,277 @@ +Eclipse Public License - v 2.0 + + THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + + a) in the case of the initial Contributor, the initial content + Distributed under this Agreement, and + + b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + where such changes and/or additions to the Program originate from + and are Distributed by that particular Contributor. A Contribution + "originates" from a Contributor if it was added to the Program by + such Contributor itself or anyone acting on such Contributor's behalf. + Contributions do not include changes or additions to the Program that + are not Modified Works. + +"Contributor" means any person or entity that Distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which +are necessarily infringed by the use or sale of its Contribution alone +or when combined with the Program. + +"Program" means the Contributions Distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement +or any Secondary License (as applicable), including Contributors. + +"Derivative Works" shall mean any work, whether in Source Code or other +form, that is based on (or derived from) the Program and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. + +"Modified Works" shall mean any work in Source Code or other form that +results from an addition to, deletion from, or modification of the +contents of the Program, including, for purposes of clarity any new file +in Source Code form that contains any contents of the Program. Modified +Works shall not include works that contain only declarations, +interfaces, types, classes, structures, or files of the Program solely +in each case in order to link to, bind by name, or subclass the Program +or Modified Works thereof. + +"Distribute" means the acts of a) distributing or b) making available +in any manner that enables the transfer of a copy. + +"Source Code" means the form of a Program preferred for making +modifications, including but not limited to software source code, +documentation source, and configuration files. + +"Secondary License" means either the GNU General Public License, +Version 2.0, or any later versions of that license, including any +exceptions or additional permissions as identified by the initial +Contributor. + +2. GRANT OF RIGHTS + + a) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free copyright + license to reproduce, prepare Derivative Works of, publicly display, + publicly perform, Distribute and sublicense the Contribution of such + Contributor, if any, and such Derivative Works. + + b) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free patent + license under Licensed Patents to make, use, sell, offer to sell, + import and otherwise transfer the Contribution of such Contributor, + if any, in Source Code or other form. This patent license shall + apply to the combination of the Contribution and the Program if, at + the time the Contribution is added by the Contributor, such addition + of the Contribution causes such combination to be covered by the + Licensed Patents. The patent license shall not apply to any other + combinations which include the Contribution. No hardware per se is + licensed hereunder. + + c) Recipient understands that although each Contributor grants the + licenses to its Contributions set forth herein, no assurances are + provided by any Contributor that the Program does not infringe the + patent or other intellectual property rights of any other entity. + Each Contributor disclaims any liability to Recipient for claims + brought by any other entity based on infringement of intellectual + property rights or otherwise. As a condition to exercising the + rights and licenses granted hereunder, each Recipient hereby + assumes sole responsibility to secure any other intellectual + property rights needed, if any. For example, if a third party + patent license is required to allow Recipient to Distribute the + Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + + d) Each Contributor represents that to its knowledge it has + sufficient copyright rights in its Contribution, if any, to grant + the copyright license set forth in this Agreement. + + e) Notwithstanding the terms of any Secondary License, no + Contributor makes additional grants to any Recipient (other than + those set forth in this Agreement) as a result of such Recipient's + receipt of the Program under the terms of a Secondary License + (if permitted under the terms of Section 3). + +3. REQUIREMENTS + +3.1 If a Contributor Distributes the Program in any form, then: + + a) the Program must also be made available as Source Code, in + accordance with section 3.2, and the Contributor must accompany + the Program with a statement that the Source Code for the Program + is available under this Agreement, and informs Recipients how to + obtain it in a reasonable manner on or through a medium customarily + used for software exchange; and + + b) the Contributor may Distribute the Program under a license + different than this Agreement, provided that such license: + i) effectively disclaims on behalf of all other Contributors all + warranties and conditions, express and implied, including + warranties or conditions of title and non-infringement, and + implied warranties or conditions of merchantability and fitness + for a particular purpose; + + ii) effectively excludes on behalf of all other Contributors all + liability for damages, including direct, indirect, special, + incidental and consequential damages, such as lost profits; + + iii) does not attempt to limit or alter the recipients' rights + in the Source Code under section 3.2; and + + iv) requires any subsequent distribution of the Program by any + party to be under a license that satisfies the requirements + of this section 3. + +3.2 When the Program is Distributed as Source Code: + + a) it must be made available under this Agreement, or if the + Program (i) is combined with other material in a separate file or + files made available under a Secondary License, and (ii) the initial + Contributor attached to the Source Code the notice described in + Exhibit A of this Agreement, then the Program may be made available + under the terms of such Secondary Licenses, and + + b) a copy of this Agreement must be included with each copy of + the Program. + +3.3 Contributors may not remove or alter any copyright, patent, +trademark, attribution notices, disclaimers of warranty, or limitations +of liability ("notices") contained within the Program from any copy of +the Program which they Distribute, provided that Contributors may add +their own appropriate notices. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities +with respect to end users, business partners and the like. While this +license is intended to facilitate the commercial use of the Program, +the Contributor who includes the Program in a commercial product +offering should do so in a manner which does not create potential +liability for other Contributors. Therefore, if a Contributor includes +the Program in a commercial product offering, such Contributor +("Commercial Contributor") hereby agrees to defend and indemnify every +other Contributor ("Indemnified Contributor") against any losses, +damages and costs (collectively "Losses") arising from claims, lawsuits +and other legal actions brought by a third party against the Indemnified +Contributor to the extent caused by the acts or omissions of such +Commercial Contributor in connection with its distribution of the Program +in a commercial product offering. The obligations in this section do not +apply to any claims or Losses relating to any actual or alleged +intellectual property infringement. In order to qualify, an Indemnified +Contributor must: a) promptly notify the Commercial Contributor in +writing of such claim, and b) allow the Commercial Contributor to control, +and cooperate with the Commercial Contributor in, the defense and any +related settlement negotiations. The Indemnified Contributor may +participate in any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial +product offering, Product X. That Contributor is then a Commercial +Contributor. If that Commercial Contributor then makes performance +claims, or offers warranties related to Product X, those performance +claims and warranties are such Commercial Contributor's responsibility +alone. Under this section, the Commercial Contributor would have to +defend claims against the other Contributors related to those performance +claims and warranties, and if a court requires any other Contributor to +pay any damages as a result, the Commercial Contributor must pay +those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" +BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR +IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF +TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR +PURPOSE. Each Recipient is solely responsible for determining the +appropriateness of using and distributing the Program and assumes all +risks associated with its exercise of rights under this Agreement, +including but not limited to the risks and costs of program errors, +compliance with applicable laws, damage to or loss of data, programs +or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS +SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST +PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of +the remainder of the terms of this Agreement, and without further +action by the parties hereto, such provision shall be reformed to the +minimum extent necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the +Program itself (excluding combinations of the Program with other software +or hardware) infringes such Recipient's patent(s), then such Recipient's +rights granted under Section 2(b) shall terminate as of the date such +litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it +fails to comply with any of the material terms or conditions of this +Agreement and does not cure such failure in a reasonable period of +time after becoming aware of such noncompliance. If all Recipient's +rights under this Agreement terminate, Recipient agrees to cease use +and distribution of the Program as soon as reasonably practicable. +However, Recipient's obligations under this Agreement and any licenses +granted by Recipient relating to the Program shall continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, +but in order to avoid inconsistency the Agreement is copyrighted and +may only be modified in the following manner. The Agreement Steward +reserves the right to publish new versions (including revisions) of +this Agreement from time to time. No one other than the Agreement +Steward has the right to modify this Agreement. The Eclipse Foundation +is the initial Agreement Steward. The Eclipse Foundation may assign the +responsibility to serve as the Agreement Steward to a suitable separate +entity. Each new version of the Agreement will be given a distinguishing +version number. The Program (including Contributions) may always be +Distributed subject to the version of the Agreement under which it was +received. In addition, after a new version of the Agreement is published, +Contributor may elect to Distribute the Program (including its +Contributions) under the new version. + +Except as expressly stated in Sections 2(a) and 2(b) above, Recipient +receives no rights or licenses to the intellectual property of any +Contributor under this Agreement, whether expressly, by implication, +estoppel or otherwise. All rights in the Program not expressly granted +under this Agreement are reserved. Nothing in this Agreement is intended +to be enforceable by any entity that is not a Contributor or Recipient. +No third-party beneficiary rights are created under this Agreement. + +Exhibit A - Form of Secondary Licenses Notice + +"This Source Code may also be made available under the following +Secondary Licenses when the conditions for such availability set forth +in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), +version(s), and exceptions or additional permissions here}." + + Simply including a copy of this Agreement, including this Exhibit A + is not sufficient to license the Source Code under Secondary Licenses. + + If it is not possible or desirable to put the notice in a particular + file, then You may include the notice in a location (such as a LICENSE + file in a relevant directory) where a recipient would be likely to + look for such a notice. + + You may add additional accurate notices of copyright ownership. \ No newline at end of file diff --git a/tests/native/fixtures/graphviz/schema/graphviz_json_schema.json b/tests/native/fixtures/graphviz/schema/graphviz_json_schema.json new file mode 100644 index 0000000..a28b72e --- /dev/null +++ b/tests/native/fixtures/graphviz/schema/graphviz_json_schema.json @@ -0,0 +1,422 @@ +{ + "title": "Graphviz JSON", + "description": "JSON representation of a graph encoding xdot attributes", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The graph name" + }, + "directed": { + "type": "boolean", + "description": "True if the graph is directed" + }, + "strict": { + "type": "boolean", + "description": "True if the graph is strict" + }, + "_subgraph_cnt": { + "type": "integer", + "description": "Number of subgraphs in the graph" + }, + "objects": { + "type": "array", + "items": { "$ref": "#/definitions/metanode" }, + "description": "The graph's subgraphs followed by the graph's nodes" + }, + "edges": { + "type": "array", + "items": { + "$ref": "#/definitions/edge" + } + }, + "_draw_": { + "$ref": "#/definitions/drawops" + }, + "_ldraw_": { + "$ref": "#/definitions/drawops" + }, + "additionalProperties": { + "type": "string" + } + }, + "required": [ + "name", + "directed", + "strict", + "_subgraph_cnt" + ], + "definitions": { + "edge": { + "title": "edge", + "type": "object", + "properties": { + "_gvid": { + "type": "integer" + }, + "tail": { + "type": "integer", + "description": "_gvid of tail node" + }, + "head": { + "type": "integer", + "description": "_gvid of tail head" + }, + "_draw_": { + "$ref": "#/definitions/drawops" + }, + "_ldraw_": { + "$ref": "#/definitions/drawops" + }, + "_hdraw_": { + "$ref": "#/definitions/drawops" + }, + "_tdraw_": { + "$ref": "#/definitions/drawops" + }, + "_hldraw_": { + "$ref": "#/definitions/drawops" + }, + "_tldraw_": { + "$ref": "#/definitions/drawops" + }, + "additionalProperties": { + "type": "string" + } + }, + "required": [ + "_gvid", + "tail", + "head" + ] + }, + "metanode": { + "title": "node or subgraph", + "type": "object", + "properties": { + "_gvid": { + "type": "integer" + }, + "name": { + "type": "string", + "description": "The node or subgraph name" + }, + "subgraphs": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "index of a child subgraph" + }, + "nodes": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "index of a node in this subgraph" + }, + "edges": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "index of an edge in this subgraph" + }, + "_draw_": { + "$ref": "#/definitions/drawops" + }, + "_ldraw_": { + "$ref": "#/definitions/drawops" + }, + "additionalProperties": { + "type": "string" + } + }, + "required": [ + "_gvid", + "name" + ] + }, + "drawops": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/ellipse" + }, + { + "$ref": "#/definitions/polygon" + }, + { + "$ref": "#/definitions/polyline" + }, + { + "$ref": "#/definitions/bspline" + }, + { + "$ref": "#/definitions/text" + }, + { + "$ref": "#/definitions/font_style" + }, + { + "$ref": "#/definitions/drawcolor" + }, + { + "$ref": "#/definitions/font" + }, + { + "$ref": "#/definitions/style" + } + ] + } + }, + "ellipse": { + "type": "object", + "properties": { + "op": { + "type": "string", + "pattern": "[eE]" + }, + "rect": { + "$ref": "#/definitions/rectangle" + } + }, + "required": [ + "op", + "rect" + ] + }, + "polygon": { + "type": "object", + "properties": { + "op": { + "type": "string", + "pattern": "[pP]" + }, + "points": { + "$ref": "#/definitions/pointlist" + } + }, + "required": [ + "op", + "points" + ] + }, + "polyline": { + "type": "object", + "properties": { + "op": { + "type": "string", + "pattern": "L" + }, + "points": { + "$ref": "#/definitions/pointlist" + } + }, + "required": [ + "op", + "points" + ] + }, + "bspline": { + "type": "object", + "properties": { + "op": { + "type": "string", + "pattern": "[bB]" + }, + "points": { + "$ref": "#/definitions/pointlist" + } + }, + "required": [ + "op", + "points" + ] + }, + "text": { + "type": "object", + "properties": { + "op": { + "type": "string", + "pattern": "T" + }, + "pt": { + "$ref": "#/definitions/point" + }, + "align": { + "type": "string", + "pattern": "[lcr]" + }, + "text": { + "type": "string" + }, + "width": { + "type": "number" + } + }, + "required": [ + "op", + "pt", + "align", + "text", + "width" + ] + }, + "font_style": { + "type": "object", + "properties": { + "op": { + "type": "string", + "pattern": "t" + }, + "fontchar": { + "type": "integer", + "minimum": 0, + "maximum": 127 + } + }, + "required": [ + "op", + "fontchar" + ] + }, + "drawcolor": { + "type": "object", + "properties": { + "op": { + "type": "string", + "pattern": "[cC]" + }, + "p0": { + "oneOf": [ + { + "$ref": "#/definitions/point" + }, + { + "$ref": "#/definitions/point3" + } + ] + }, + "p1": { + "oneOf": [ + { + "$ref": "#/definitions/point" + }, + { + "$ref": "#/definitions/point3" + } + ] + }, + "grad": { + "type": "string", + "enum": [ + "none", + "linear", + "radial" + ] + }, + "color": { + "$ref": "#/definitions/color" + }, + "stops": { + "type": "array", + "items": { + "$ref": "#/definitions/stop" + } + } + }, + "required": [ + "op", + "grad" + ] + }, + "font": { + "type": "object", + "properties": { + "op": { + "type": "string", + "pattern": "F" + }, + "size": { + "type": "number", + "minimum": 0 + }, + "face": { + "type": "string" + } + }, + "required": [ + "op", + "size", + "face" + ] + }, + "style": { + "type": "object", + "properties": { + "op": { + "type": "string", + "pattern": "S" + }, + "style": { + "type": "string" + } + }, + "required": [ + "op", + "style" + ] + }, + "pointlist": { + "type": "array", + "items": { + "$ref": "#/definitions/point" + } + }, + "point": { + "type": "array", + "items": { + "type": "number" + }, + "maxItems": 2, + "minItems": 2 + }, + "point3": { + "type": "array", + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3 + }, + "rectangle": { + "type": "array", + "items": { + "type": "number" + }, + "maxItems": 4, + "minItems": 4 + }, + "color": { + "type": "string", + "pattern": "(#[0-9a-f]*)|(#[0-9a-f]{8})" + }, + "stop": { + "type": "object", + "properties": { + "frac": { + "type": "number" + }, + "color": { + "$ref": "#/definitions/color" + } + }, + "required": [ + "frac", + "color" + ] + } + } +} diff --git a/tests/native/fixtures/graphviz/schema/provenance.json b/tests/native/fixtures/graphviz/schema/provenance.json new file mode 100644 index 0000000..0743ee4 --- /dev/null +++ b/tests/native/fixtures/graphviz/schema/provenance.json @@ -0,0 +1,27 @@ +{ + "retrieved": "2026-10-12", + "repository": "https://gitlab.com/graphviz/graphviz", + "source": "doc/infosrc/graphviz_json_schema.json", + "documentation": "https://graphviz.org/docs/outputs/json/", + "license": "EPL-2.0", + "entry": "graphviz_json_schema.json", + "files": [ + { + "file": "graphviz_json_schema.json", + "sha256": "0a9656975cea8c14f2b6d7ae172c89f981dc98283abebab84b8ebd8fe48b316d", + "url": "https://gitlab.com/api/v4/projects/graphviz%2Fgraphviz/repository/blobs/a28b72e11bf5ab44781452063148e3323a23d602/raw", + "blob": "a28b72e11bf5ab44781452063148e3323a23d602", + "modifications": [] + }, + { + "file": "LICENSE.txt", + "sha256": "0becf16567beb77fa252b7664631dd177c8f9a1889e48995b45379c7130e5303", + "url": "https://gitlab.com/api/v4/projects/graphviz%2Fgraphviz/repository/blobs/e23ece2c852415800ac224b74ede6c5849f6f88d/raw", + "blob": "e23ece2c852415800ac224b74ede6c5849f6f88d", + "modifications": [] + } + ], + "limits": [ + "The upstream schema places additionalProperties inside properties, so unknown attributes are not constrained by a top-level additionalProperties rule. Runtime adapter separately validates known label/weight/geometry types and indexed references." + ] +} diff --git a/tests/native/fixtures/graphviz/strict-ports.dot b/tests/native/fixtures/graphviz/strict-ports.dot new file mode 100644 index 0000000..a0ac6d2 --- /dev/null +++ b/tests/native/fixtures/graphviz/strict-ports.dot @@ -0,0 +1 @@ +strict digraph G { a [shape=record,label="

P|R"]; b [shape=record,label="Q"]; a:p:n -> b:q:s [weight=2,label="Edge"]; a:r -> b [color=red]; isolated; } diff --git a/tests/native/fixtures/graphviz/strict-ports.json b/tests/native/fixtures/graphviz/strict-ports.json new file mode 100644 index 0000000..15ed85b --- /dev/null +++ b/tests/native/fixtures/graphviz/strict-ports.json @@ -0,0 +1,244 @@ +{ + "name": "G", + "directed": true, + "strict": true, + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#fffffe00" + }, + { + "op": "C", + "grad": "none", + "color": "#ffffff" + }, + { + "op": "P", + "points": [[0.000,0.000],[0.000,134.530],[154.070,134.530],[154.070,0.000]] + } + ], + "bb": "0,0,154.07,134.53", + "xdotversion": "1.7", + "_subgraph_cnt": 0, + "objects": [ + { + "_gvid": 0, + "name": "a", + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "p", + "points": [[0.000,98.030],[0.000,134.030],[54.000,134.030],[54.000,98.030]] + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "L", + "points": [[25.790,98.030],[25.790,134.030]] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [12.890,111.830], + "align": "c", + "width": 7.790, + "text": "P" + }, + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [39.460,111.830], + "align": "c", + "width": 9.340, + "text": "R" + } + ], + "height": "0.51389", + "label": "

P|R", + "pos": "27,116.03", + "rects": "0,98.027,25.786,134.03 25.786,98.027,53.124,134.03", + "shape": "record", + "width": "0.75" + }, + { + "_gvid": 1, + "name": "b", + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "p", + "points": [[12.000,8.230],[12.000,44.230],[66.000,44.230],[66.000,8.230]] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [38.560,22.030], + "align": "c", + "width": 10.110, + "text": "Q" + } + ], + "height": "0.51389", + "label": "Q", + "pos": "39,26.227", + "rects": "12,8.2273,65.11,44.227", + "shape": "record", + "width": "0.75" + }, + { + "_gvid": 2, + "name": "isolated", + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "e", + "rect": [113.000,116.030,41.070,18.000] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [113.000,111.830], + "align": "c", + "width": 43.540, + "text": "isolated" + } + ], + "height": "0.5", + "label": "\\N", + "pos": "113,116.03", + "width": "1.1409" + } + ], + "edges": [ + { + "_gvid": 0, + "tail": 0, + "head": 1, + "_draw_": + [ + { + "op": "c", + "grad": "none", + "color": "#ff0000" + }, + { + "op": "b", + "points": [[39.460,97.530],[39.460,71.080],[18.280,70.420],[12.000,44.730],[8.150,28.970],[0.750,19.910],[12.000,8.230],[16.420,3.640],[23.790,0.280],[29.650,0.000]] + } + ], + "_hdraw_": + [ + { + "op": "S", + "style": "solid" + }, + { + "op": "c", + "grad": "none", + "color": "#ff0000" + }, + { + "op": "C", + "grad": "none", + "color": "#ff0000" + }, + { + "op": "P", + "points": [[27.410,2.690],[37.380,6.270],[31.820,-2.740]] + } + ], + "_ldraw_": + [ + { + "op": "F", + "size": 14.000, + "face": "Times-Roman" + }, + { + "op": "c", + "grad": "none", + "color": "#000000" + }, + { + "op": "T", + "pt": [49.090,66.930], + "align": "c", + "width": 28.770, + "text": "Edge" + } + ], + "color": "red", + "headport": "q:s", + "label": "Edge", + "lp": "49.095,71.127", + "pos": "e,38.555,7.2273 39.455,97.527 39.455,71.078 18.281,70.42 12,44.727 8.1478,28.969 0.74705,19.912 12,8.2273 16.416,3.6415 23.788,0.27711 29.645,0", + "tailport": "r", + "weight": "2" + } + ] +} diff --git a/tests/native/fixtures/jgf/LICENSE b/tests/native/fixtures/jgf/LICENSE new file mode 100644 index 0000000..55a6be2 --- /dev/null +++ b/tests/native/fixtures/jgf/LICENSE @@ -0,0 +1,21 @@ +Copyright 2014 Anthony Bargnesi, Anselmo DiFabio, William Hayes +https://github.com/jsongraph/jsongraph.rb + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tests/native/fixtures/jgf/json-graph-schema_v1.json b/tests/native/fixtures/jgf/json-graph-schema_v1.json new file mode 100644 index 0000000..f2c1d58 --- /dev/null +++ b/tests/native/fixtures/jgf/json-graph-schema_v1.json @@ -0,0 +1,108 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema", + "oneOf": [ + { + "type": "object", + "properties": { + "graph": { + "$ref": "#/definitions/graph" + } + }, + "additionalProperties": false, + "required": ["graph"] + }, + { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "metadata": { + "type": ["object", "null"] + }, + "graphs": { + "type": "array", + "items": { + "$ref": "#/definitions/graph" + } + } + }, + "additionalProperties": false + } + ], + "definitions": { + "graph": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "directed": { + "type": ["boolean", "null"], + "default": true + }, + "type": { + "type": "string" + }, + "metadata": { + "type": ["object", "null"] + }, + "nodes": { + "type": ["array", "null"], + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "metadata": { + "type": ["object", "null"] + } + }, + "required": ["id"] + } + }, + "edges": { + "type": ["array", "null"], + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "relation": { + "type": "string" + }, + "directed": { + "type": ["boolean", "null"], + "default": true + }, + "label": { + "type": "string" + }, + "metadata": { + "type": ["object", "null"] + } + }, + "required": ["source", "target"] + } + } + } + } + } +} diff --git a/tests/native/fixtures/jgf/json-graph-schema_v2.json b/tests/native/fixtures/jgf/json-graph-schema_v2.json new file mode 100644 index 0000000..0eb85d9 --- /dev/null +++ b/tests/native/fixtures/jgf/json-graph-schema_v2.json @@ -0,0 +1,138 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://jsongraphformat.info/v2.1/json-graph-schema.json", + "title": "JSON Graph Schema", + "oneOf": [ + { + "type": "object", + "properties": { + "graph": { "$ref": "#/definitions/graph" } + }, + "additionalProperties": false, + "required": [ + "graph" + ] + }, + { + "type": "object", + "properties": { + "graphs": { + "type": "array", + "items": { "$ref": "#/definitions/graph" } + } + }, + "additionalProperties": false + } + ], + "definitions": { + "graph": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "label": { "type": "string" }, + "directed": { "type": [ "boolean" ], "default": true }, + "type": { "type": "string" }, + "metadata": { "type": [ "object" ] }, + "nodes": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/node" } + }, + "edges": { + "type": [ "array" ], + "items": { "$ref": "#/definitions/edge" } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "label": { "type": "string" }, + "directed": { "type": [ "boolean" ], "default": true }, + "type": { "type": "string" }, + "metadata": { "type": [ "object" ] }, + "nodes": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/node" } + }, + "hyperedges": { + "type": [ "array" ], + "items": { "$ref": "#/definitions/directedhyperedge" } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "label": { "type": "string" }, + "directed": { "type": [ "boolean" ], "enum": [false] }, + "type": { "type": "string" }, + "metadata": { "type": [ "object" ] }, + "nodes": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/node" } + }, + "hyperedges": { + "type": [ "array" ], + "items": { "$ref": "#/definitions/undirectedhyperedge" } + } + }, + "required": [ "directed" ] + } + ] + }, + "node": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { "type": "string" }, + "metadata": { "type": "object" } + } + }, + "edge": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "source": { "type": "string" }, + "target": { "type": "string" }, + "relation": { "type": "string" }, + "directed": { "type": [ "boolean" ], "default": true }, + "label": { "type": "string" }, + "metadata": { "type": [ "object" ] } + }, + "required": [ "source", "target" ] + }, + "directedhyperedge": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "source": { "type": "array", "items": { "type": "string" } }, + "target": { "type": "array", "items": { "type": "string" } }, + "relation": { "type": "string" }, + "label": { "type": "string" }, + "metadata": { "type": [ "object" ] } + }, + "required": [ "source", "target" ] + }, + "undirectedhyperedge": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "nodes": { "type": "array", "items": { "type": "string" } }, + "relation": { "type": "string" }, + "label": { "type": "string" }, + "metadata": { "type": [ "object" ] } + }, + "required": [ "nodes" ] + } + } +} diff --git a/tests/native/fixtures/jgf/provenance.json b/tests/native/fixtures/jgf/provenance.json new file mode 100644 index 0000000..c01bb1d --- /dev/null +++ b/tests/native/fixtures/jgf/provenance.json @@ -0,0 +1,24 @@ +{ + "retrieved": "2026-10-12", + "repository": "https://github.com/jsongraph/json-graph-specification", + "commit": "758ab3d0275dc2fa96ddfec9a0d0ca2c5f440b99", + "files": [ + { + "file": "json-graph-schema_v1.json", + "url": "https://raw.githubusercontent.com/jsongraph/json-graph-specification/758ab3d0275dc2fa96ddfec9a0d0ca2c5f440b99/json-graph-schema_v1.json", + "sha256": "28dda9987e5c4335dc96020906a68d14e88c27a0df8b18509dad51b708abcb7d" + }, + { + "file": "json-graph-schema_v2.json", + "url": "https://raw.githubusercontent.com/jsongraph/json-graph-specification/758ab3d0275dc2fa96ddfec9a0d0ca2c5f440b99/json-graph-schema_v2.json", + "sha256": "9d3cd9dd6e78e3186cdb886a0f30f2f40407cface074ab2e877bfd2b2a59f258" + }, + { + "file": "LICENSE", + "url": "https://raw.githubusercontent.com/jsongraph/json-graph-specification/758ab3d0275dc2fa96ddfec9a0d0ca2c5f440b99/LICENSE", + "sha256": "d0a726d891d49cda0458f53e05c95ebbb46a12afbb18a0b975dad7989e1decca" + } + ], + "changes": "Unmodified upstream bytes.", + "licenseFile": "LICENSE" +} diff --git a/tests/native/fixtures/mermaid-expanded.json b/tests/native/fixtures/mermaid-expanded.json new file mode 100644 index 0000000..3e40d11 --- /dev/null +++ b/tests/native/fixtures/mermaid-expanded.json @@ -0,0 +1,43 @@ +{ + "oracle": "mermaid@12.1.0", + "retrieved": "2026-10-12", + "provenance": "Task-authored fixtures against primary Mermaid syntax docs; independent native parser and database assertions. Scoped profiles, no unrestricted grammar claim.", + "fixtures": [ + { + "format": "architecture", + "documentation": "https://mermaid.js.org/syntax/architecture.html", + "source": "architecture-beta\ngroup api(cloud)[API]\nservice db(database)[Database] in api\nservice web(server)[Web] in api\njunction hub in api\ndb:R --> L:web\nweb:B -- T:hub", + "sha256": "f1b6dc9a6ec9f511a13610d13549a9ecbc362d4402b4b53b7f3c08a0b08a3bb5" + }, + { + "format": "requirement", + "documentation": "https://mermaid.js.org/syntax/requirementDiagram.html", + "source": "requirementDiagram\nrequirement secure {\nid: R1\ntext: \"Secure 東京\"\nrisk: high\nverifymethod: test\n}\nelement app {\ntype: software\ndocref: \"spec v1\"\n}\napp - satisfies -> secure", + "sha256": "adac5e3a7ce411afdfd81b35c6af04498c5e992ca79c14cea093c13f06d030ea" + }, + { + "format": "sankey", + "documentation": "https://mermaid.js.org/syntax/sankey.html", + "source": "sankey-beta\nSource,Store,2.5\nStore,Target,2", + "sha256": "132e63f9d6eca42ae3d0ba9776e84b804a2ef29d7f3a4c06747e6a2b0bd07a2c" + }, + { + "format": "git", + "documentation": "https://mermaid.js.org/syntax/gitgraph.html", + "source": "gitGraph\ncommit id: \"a\"\nbranch develop order: 2\ncommit id: \"b\" tag: \"β\"\ncheckout main\ncommit id: \"c\" type: HIGHLIGHT\nmerge develop id: \"m\"", + "sha256": "364bfc7f3056d20ab619503a5b82fce7cb1659a87153e594483ee57137d1536c" + }, + { + "format": "c4", + "documentation": "https://mermaid.js.org/syntax/c4.html", + "source": "C4Container\nPerson(user, \"User\", \"Customer\")\nContainer_Boundary(api, \"API\") {\nContainer(web, \"Web\", \"JS\", \"Frontend\")\nContainerDb(db, \"Store\", \"SQL\", \"Database\")\n}\nRel(user, web, \"Uses\", \"HTTPS\")\nRel(web, db, \"Reads\", \"SQL\")", + "sha256": "8c05486e60b04befc18977b8d6f45010e8bc296801a810e244eb6e550464a4ba" + }, + { + "format": "usecase", + "documentation": "https://mermaid.js.org/syntax/usecase.html", + "source": "usecase-beta\nactor User(\"User\")\nsystemBoundary app[\"App\"]\nLogin(\"Login\")\nManage[\"Manage\"]\nend\nUser --> Login\nManage ..> : include Login", + "sha256": "388119ee419677c59b5e700ec60c6b44fba20b4125b365b9be507c69ac23ed39" + } + ] +} diff --git a/tests/native/fixtures/mermaid-profiles.json b/tests/native/fixtures/mermaid-profiles.json new file mode 100644 index 0000000..2fb3e4d --- /dev/null +++ b/tests/native/fixtures/mermaid-profiles.json @@ -0,0 +1,46 @@ +{ + "oracle": "mermaid@12.1.0", + "retrieved": "2026-10-12", + "fixtures": [ + { + "format": "flowchart", + "documentation": "https://mermaid.js.org/syntax/flowchart.html", + "source": "flowchart LR\n A[\"Start 東京\"] -->|Go| B{Decision}\n B -.-> C@{ shape: cloud, label: \"Future\" }\n Isolated[\"No edges\"]\n classDef blue fill:#def,stroke:#123\n class A blue\n style B fill:#fed\n" + }, + { + "format": "sequence", + "documentation": "https://mermaid.js.org/syntax/sequenceDiagram.html", + "source": "sequenceDiagram\n autonumber 10 5\n participant A as Client\n participant B@{ \"type\": \"database\" } as Store\n A->>B: Save 東京\n note over A,B: Keep this note\n B-->>A: Saved\n" + }, + { + "format": "state", + "documentation": "https://mermaid.js.org/syntax/stateDiagram.html", + "source": "stateDiagram-v2\n state \"Idle 東京\" as Idle\n [*] --> Idle\n Idle --> Running: start\n Running --> [*]\n note right of Idle: Waiting\n state Group {\n Inner1 --> Inner2: nested\n }\n Idle --> Group: enter\n" + }, + { + "format": "class", + "documentation": "https://mermaid.js.org/syntax/classDiagram.html", + "source": "classDiagram\n namespace Domain {\n class Customer {\n +String name\n +save()\n }\n class Order\n }\n Customer \"1\" --> \"many\" Order : places\n note for Customer \"Keeps records\"\n" + }, + { + "format": "er", + "documentation": "https://mermaid.js.org/syntax/entityRelationshipDiagram.html", + "source": "erDiagram\n CUSTOMER {\n string id PK, UK \"Identity\"\n string name\n }\n CUSTOMER ||--o{ ORDER : places\n ISOLATED\n" + }, + { + "format": "block", + "documentation": "https://mermaid.js.org/syntax/block.html", + "source": "block-beta\n columns 3\n A[\"First block\"] B((\"Second block\")) C{\"Third block\"}\n A --> B\n style A fill:#def\n block:group\n columns 2\n D[\"Nested one\"] E[\"Nested two\"]\n end\n" + }, + { + "format": "mindmap", + "documentation": "https://mermaid.js.org/syntax/mindmap.html", + "source": "mindmap\n root((Root 東京))\n a[First child]\n Deep child\n b(Second child)\n" + }, + { + "format": "ishikawa", + "documentation": "https://mermaid.js.org/syntax/ishikawa.html", + "source": "ishikawa-beta\n Effect 東京\n People\n Training\n Process\n Review\n" + } + ] +} diff --git a/tests/native/fixtures/networkx/multidigraph.json b/tests/native/fixtures/networkx/multidigraph.json new file mode 100644 index 0000000..f2fb31e --- /dev/null +++ b/tests/native/fixtures/networkx/multidigraph.json @@ -0,0 +1,17 @@ +{ + "directed": true, + "multigraph": true, + "graph": { "name": "Native graph", "id": "ordinary attribute", "metadata": { "vendor": [1, null, "α"] } }, + "nodes": [ + { "id": 1, "label": "numeric one", "color": "#ff00ff", "vendor": { "keep": true } }, + { "id": "1", "label": "string one" }, + { "id": false, "label": "" }, + { "id": "isolated", "metadata": [null, 42] } + ], + "edges": [ + { "source": 1, "target": "1", "key": "shared", "weight": 2.5, "label": "numeric→string", "vendor": { "x": [1, 2] } }, + { "source": 1, "target": "1", "key": 2, "weight": 3 }, + { "source": "1", "target": false, "key": "shared", "label": "repeated key, different pair" }, + { "source": false, "target": false, "key": "loop", "weight": -1 } + ] +} diff --git a/tests/native/fixtures/networkx/multidigraph.native.json b/tests/native/fixtures/networkx/multidigraph.native.json new file mode 100644 index 0000000..9e71b36 --- /dev/null +++ b/tests/native/fixtures/networkx/multidigraph.native.json @@ -0,0 +1,167 @@ +{ + "oracle": "networkx", + "version": "3.7", + "python": "3.14.8", + "summary": { + "directed": true, + "multigraph": true, + "graph": { + "name": "Native graph", + "id": "ordinary attribute", + "metadata": { + "vendor": [ + 1, + null, + "α" + ] + } + }, + "nodes": [ + { + "id": 1, + "attributes": { + "label": "numeric one", + "color": "#ff00ff", + "vendor": { + "keep": true + } + } + }, + { + "id": "1", + "attributes": { + "label": "string one" + } + }, + { + "id": false, + "attributes": { + "label": "" + } + }, + { + "id": "isolated", + "attributes": { + "metadata": [ + null, + 42 + ] + } + } + ], + "edges": [ + { + "source": 1, + "target": "1", + "key": "shared", + "attributes": { + "weight": 2.5, + "label": "numeric→string", + "vendor": { + "x": [ + 1, + 2 + ] + } + } + }, + { + "source": 1, + "target": "1", + "key": 2, + "attributes": { + "weight": 3 + } + }, + { + "source": "1", + "target": false, + "key": "shared", + "attributes": { + "label": "repeated key, different pair" + } + }, + { + "source": false, + "target": false, + "key": "loop", + "attributes": { + "weight": -1 + } + } + ] + }, + "roundTrip": { + "directed": true, + "multigraph": true, + "graph": { + "name": "Native graph", + "id": "ordinary attribute", + "metadata": { + "vendor": [ + 1, + null, + "α" + ] + } + }, + "nodes": [ + { + "label": "numeric one", + "color": "#ff00ff", + "vendor": { + "keep": true + }, + "id": 1 + }, + { + "label": "string one", + "id": "1" + }, + { + "label": "", + "id": false + }, + { + "metadata": [ + null, + 42 + ], + "id": "isolated" + } + ], + "edges": [ + { + "weight": 2.5, + "label": "numeric→string", + "vendor": { + "x": [ + 1, + 2 + ] + }, + "source": 1, + "target": "1", + "key": "shared" + }, + { + "weight": 3, + "source": 1, + "target": "1", + "key": 2 + }, + { + "label": "repeated key, different pair", + "source": "1", + "target": false, + "key": "shared" + }, + { + "weight": -1, + "source": false, + "target": false, + "key": "loop" + } + ] + } +} diff --git a/tests/native/fixtures/networkx/provenance.json b/tests/native/fixtures/networkx/provenance.json new file mode 100644 index 0000000..62f5648 --- /dev/null +++ b/tests/native/fixtures/networkx/provenance.json @@ -0,0 +1,27 @@ +{ + "generated": "2026-10-12", + "oracle": "networkx", + "version": "3.7", + "python": "3.14.8", + "license": "Authored inputs and generated native outputs; NetworkX BSD-3-Clause.", + "documentation": "https://networkx.org/documentation/stable/reference/readwrite/generated/networkx.readwrite.json_graph.node_link_graph.html", + "command": ".venv-native/bin/python scripts/native/networkx_oracle.py < JSON request (operation=node-link)", + "files": [ + { + "file": "multidigraph.json", + "sha256": "0a61b9a7016722a4c8d1d68c0bc0e818956063e038aeac4863c5175b551259ba" + }, + { + "file": "multidigraph.native.json", + "sha256": "df101e3b33acc1bc977b400a2797b258d7aa99968621c994c1b9c8cbd6454176" + }, + { + "file": "simple-links.json", + "sha256": "2d7db213b3a349ce5be3076c665f90463444883b45541db29cf8bde767bf5550" + }, + { + "file": "simple-links.native.json", + "sha256": "40a7099ae030694cc4514e0c1305f8db767d96fc8dbed8aa95899c8752337d9b" + } + ] +} diff --git a/tests/native/fixtures/networkx/simple-links.json b/tests/native/fixtures/networkx/simple-links.json new file mode 100644 index 0000000..aba850f --- /dev/null +++ b/tests/native/fixtures/networkx/simple-links.json @@ -0,0 +1,7 @@ +{ + "directed": false, + "multigraph": false, + "graph": { "description": "legacy links spelling; key is an ordinary simple-edge attribute" }, + "nodes": [{ "id": "left", "label": null }, { "id": "right", "label": "" }, { "id": "alone" }], + "links": [{ "source": "left", "target": "right", "key": "ordinary", "weight": 0, "custom": [false, null] }] +} diff --git a/tests/native/fixtures/networkx/simple-links.native.json b/tests/native/fixtures/networkx/simple-links.native.json new file mode 100644 index 0000000..864f187 --- /dev/null +++ b/tests/native/fixtures/networkx/simple-links.native.json @@ -0,0 +1,76 @@ +{ + "oracle": "networkx", + "version": "3.7", + "python": "3.14.8", + "summary": { + "directed": false, + "multigraph": false, + "graph": { + "description": "legacy links spelling; key is an ordinary simple-edge attribute" + }, + "nodes": [ + { + "id": "left", + "attributes": { + "label": null + } + }, + { + "id": "right", + "attributes": { + "label": "" + } + }, + { + "id": "alone", + "attributes": {} + } + ], + "edges": [ + { + "source": "left", + "target": "right", + "attributes": { + "key": "ordinary", + "weight": 0, + "custom": [ + false, + null + ] + } + } + ] + }, + "roundTrip": { + "directed": false, + "multigraph": false, + "graph": { + "description": "legacy links spelling; key is an ordinary simple-edge attribute" + }, + "nodes": [ + { + "label": null, + "id": "left" + }, + { + "label": "", + "id": "right" + }, + { + "id": "alone" + } + ], + "links": [ + { + "key": "ordinary", + "weight": 0, + "custom": [ + false, + null + ], + "source": "left", + "target": "right" + } + ] + } +} diff --git a/tests/native/fixtures/pajek/comments.igraph.json b/tests/native/fixtures/pajek/comments.igraph.json new file mode 100644 index 0000000..aa01be9 --- /dev/null +++ b/tests/native/fixtures/pajek/comments.igraph.json @@ -0,0 +1,33 @@ +{ + "oracle": "igraph", + "version": "1.0.0", + "python": "3.14.8", + "summary": { + "directed": true, + "graph": {}, + "nodes": [ + { + "id": 0, + "attributes": { + "name": "one" + } + }, + { + "id": 1, + "attributes": { + "name": "two" + } + } + ], + "edges": [ + { + "source": 0, + "target": 1, + "attributes": { + "weight": 1.0 + } + } + ] + }, + "warnings": [] +} diff --git a/tests/native/fixtures/pajek/comments.net b/tests/native/fixtures/pajek/comments.net new file mode 100644 index 0000000..d36c1e8 --- /dev/null +++ b/tests/native/fixtures/pajek/comments.net @@ -0,0 +1,8 @@ +% before vertices +*Vertices 2 +1 "one" +2 "two" +% before arcs +*Arcs +1 2 1 +% after arc diff --git a/tests/native/fixtures/pajek/duplicate-labels.igraph.json b/tests/native/fixtures/pajek/duplicate-labels.igraph.json new file mode 100644 index 0000000..43d4d3c --- /dev/null +++ b/tests/native/fixtures/pajek/duplicate-labels.igraph.json @@ -0,0 +1,33 @@ +{ + "oracle": "igraph", + "version": "1.0.0", + "python": "3.14.8", + "summary": { + "directed": false, + "graph": {}, + "nodes": [ + { + "id": 0, + "attributes": { + "name": "same" + } + }, + { + "id": 1, + "attributes": { + "name": "same" + } + } + ], + "edges": [ + { + "source": 0, + "target": 1, + "attributes": { + "weight": 1.0 + } + } + ] + }, + "warnings": [] +} diff --git a/tests/native/fixtures/pajek/duplicate-labels.net b/tests/native/fixtures/pajek/duplicate-labels.net new file mode 100644 index 0000000..08fce41 --- /dev/null +++ b/tests/native/fixtures/pajek/duplicate-labels.net @@ -0,0 +1,5 @@ +*Vertices 2 +1 "same" +2 "same" +*Edges +1 2 1 diff --git a/tests/native/fixtures/pajek/igraph-provenance.json b/tests/native/fixtures/pajek/igraph-provenance.json new file mode 100644 index 0000000..c2831d2 --- /dev/null +++ b/tests/native/fixtures/pajek/igraph-provenance.json @@ -0,0 +1,44 @@ +{ + "generated": "2026-10-12", + "oracle": "igraph", + "version": "1.0.0", + "python": "3.14.8", + "license": "Authored inputs and generated native outputs; igraph is GPL-2.0-or-later (dev-only reader).", + "documentation": "https://igraph.org/c/html/develop/igraph-Foreign.html#igraph_read_graph_pajek", + "command": ".venv-native/bin/python scripts/native/igraph_oracle.py < JSON request (operation=pajek)", + "profile": "C-backed uniform .net reader, identity independent of labels, comments, native attributes and parallel weighted edges. Mixed sections collapse to a global mode; temporal/project formats unsupported.", + "files": [ + { + "file": "uniform.net", + "sha256": "419aa778ed953c24be560cd556eb05b11ca8e5f3c93cf2c146e1fdd5d9abed2e" + }, + { + "file": "uniform.igraph.json", + "sha256": "9930c06764a5cd5f993ea5cd606735b3b645acf4e9e02380943137a59c4cf81f" + }, + { + "file": "duplicate-labels.net", + "sha256": "3a9fa4d31df30857cbac47ffe1b2856d88f0c184bb93ba1dd6e12bdf2d55c9d9" + }, + { + "file": "duplicate-labels.igraph.json", + "sha256": "3b4db5bd927035b213c3563f21df12d17fd2be0c6ad69882fff9e41719d3a225" + }, + { + "file": "comments.net", + "sha256": "2eb34dbc448fc73ca7bc040b69e7f2ba6e8445109127a2d6106367ab01545d2a" + }, + { + "file": "comments.igraph.json", + "sha256": "077341e8935f446590b9078ef32c6b315eef4eb46ffbd9f4aaa328cefb7b62f2" + }, + { + "file": "mixed.net", + "sha256": "80dc3651837a90d2121faad7c01d0a3daabd114c46f0dca18d9ff74413c31f4d" + }, + { + "file": "mixed.igraph.json", + "sha256": "9ba8141c509bf35b29f6a139d55d2f53628731436da5f1a982162a113cd4fef1" + } + ] +} diff --git a/tests/native/fixtures/pajek/mixed.igraph.json b/tests/native/fixtures/pajek/mixed.igraph.json new file mode 100644 index 0000000..edb7328 --- /dev/null +++ b/tests/native/fixtures/pajek/mixed.igraph.json @@ -0,0 +1,40 @@ +{ + "oracle": "igraph", + "version": "1.0.0", + "python": "3.14.8", + "summary": { + "directed": false, + "graph": {}, + "nodes": [ + { + "id": 0, + "attributes": { + "name": "one" + } + }, + { + "id": 1, + "attributes": { + "name": "two" + } + } + ], + "edges": [ + { + "source": 0, + "target": 1, + "attributes": { + "weight": 1.0 + } + }, + { + "source": 0, + "target": 1, + "attributes": { + "weight": 2.0 + } + } + ] + }, + "warnings": [] +} diff --git a/tests/native/fixtures/pajek/mixed.net b/tests/native/fixtures/pajek/mixed.net new file mode 100644 index 0000000..778b005 --- /dev/null +++ b/tests/native/fixtures/pajek/mixed.net @@ -0,0 +1,7 @@ +*Vertices 2 +1 "one" +2 "two" +*Arcs +1 2 1 +*Edges +2 1 2 diff --git a/tests/native/fixtures/pajek/provenance.json b/tests/native/fixtures/pajek/provenance.json new file mode 100644 index 0000000..bb243cd --- /dev/null +++ b/tests/native/fixtures/pajek/provenance.json @@ -0,0 +1,20 @@ +{ + "generated": "2026-10-12", + "oracle": "networkx", + "version": "3.7", + "python": "3.14.8", + "license": "Authored input and generated native output; NetworkX BSD-3-Clause.", + "documentation": "https://networkx.org/documentation/stable/_modules/networkx/readwrite/pajek.html", + "profile": "Uniform directed, unique labels, UTF-8, coordinates, parallel weighted arcs. This reader does not prove complete Pajek conformance or duplicate-label identity preservation.", + "command": ".venv-native/bin/python scripts/native/networkx_oracle.py < JSON request (operation=pajek)", + "files": [ + { + "file": "uniform.net", + "sha256": "419aa778ed953c24be560cd556eb05b11ca8e5f3c93cf2c146e1fdd5d9abed2e" + }, + { + "file": "uniform.native.json", + "sha256": "ad6376cbf77da0c2bd2d750a205371596ced01bbba54cd587e55081e3212af2c" + } + ] +} diff --git a/tests/native/fixtures/pajek/uniform.igraph.json b/tests/native/fixtures/pajek/uniform.igraph.json new file mode 100644 index 0000000..13427bd --- /dev/null +++ b/tests/native/fixtures/pajek/uniform.igraph.json @@ -0,0 +1,68 @@ +{ + "oracle": "igraph", + "version": "1.0.0", + "python": "3.14.8", + "summary": { + "directed": true, + "graph": {}, + "nodes": [ + { + "id": 0, + "attributes": { + "name": "Alpha & α", + "x": 0.1, + "y": 0.2, + "shape": "ellipse", + "color": "Red" + } + }, + { + "id": 1, + "attributes": { + "name": "", + "x": 0.5, + "y": 0.6, + "shape": "box", + "color": "LightOrange" + } + }, + { + "id": 2, + "attributes": { + "name": "isolated", + "x": 0.0, + "y": 0.0, + "shape": "ellipse", + "color": "LightOrange" + } + } + ], + "edges": [ + { + "source": 0, + "target": 1, + "attributes": { + "weight": 2.5, + "label": "edge" + } + }, + { + "source": 0, + "target": 1, + "attributes": { + "weight": -3.0, + "label": "" + } + }, + { + "source": 0, + "target": 0, + "attributes": { + "weight": 0.0, + "label": "" + } + } + ] + }, + "warnings": [] +} diff --git a/tests/native/fixtures/pajek/uniform.native.json b/tests/native/fixtures/pajek/uniform.native.json new file mode 100644 index 0000000..0fddeb2 --- /dev/null +++ b/tests/native/fixtures/pajek/uniform.native.json @@ -0,0 +1,118 @@ +{ + "oracle": "networkx", + "version": "3.7", + "python": "3.14.8", + "summary": { + "directed": true, + "multigraph": true, + "graph": { + "name": "Native fixture" + }, + "nodes": [ + { + "id": "Alpha & α", + "attributes": { + "id": "1", + "x": 0.1, + "y": 0.2, + "shape": "ellipse", + "ic": "Red" + } + }, + { + "id": "", + "attributes": { + "id": "2", + "x": 0.5, + "y": 0.6, + "shape": "box" + } + }, + { + "id": "isolated", + "attributes": { + "id": "3", + "x": 0.0, + "y": 0.0, + "shape": "ellipse" + } + } + ], + "edges": [ + { + "source": "Alpha & α", + "target": "", + "key": 0, + "attributes": { + "weight": 2.5, + "l": "edge" + } + }, + { + "source": "Alpha & α", + "target": "", + "key": 1, + "attributes": { + "weight": -3.0 + } + }, + { + "source": "Alpha & α", + "target": "Alpha & α", + "key": 0, + "attributes": { + "weight": 0.0 + } + } + ] + }, + "roundTrip": { + "directed": true, + "multigraph": true, + "graph": { + "name": "Native fixture" + }, + "nodes": [ + { + "id": "Alpha & α", + "x": 0.1, + "y": 0.2, + "shape": "ellipse", + "ic": "Red" + }, + { + "id": "", + "x": 0.5, + "y": 0.6, + "shape": "box" + }, + { + "id": "isolated", + "x": 0.0, + "y": 0.0, + "shape": "ellipse" + } + ], + "edges": [ + { + "weight": 2.5, + "l": "edge", + "source": "Alpha & α", + "target": "", + "key": 0 + }, + { + "weight": -3.0, + "source": "Alpha & α", + "target": "", + "key": 1 + }, + { + "weight": 0.0, + "source": "Alpha & α", + "target": "Alpha & α", + "key": 0 + } + ] + } +} diff --git a/tests/native/fixtures/pajek/uniform.net b/tests/native/fixtures/pajek/uniform.net new file mode 100644 index 0000000..e12c8f8 --- /dev/null +++ b/tests/native/fixtures/pajek/uniform.net @@ -0,0 +1,10 @@ +% Authored unique-label interoperability fixture. +*Network Native fixture +*Vertices 3 +1 "Alpha & α" 0.1 0.2 ellipse ic Red +2 "" 0.5 0.6 box +3 "isolated" 0 0 ellipse +*Arcs +1 2 2.5 l "edge" +1 2 -3 +1 1 0 diff --git a/tests/native/fixtures/structured/README.md b/tests/native/fixtures/structured/README.md new file mode 100644 index 0000000..397b3e2 --- /dev/null +++ b/tests/native/fixtures/structured/README.md @@ -0,0 +1,28 @@ +# Structured native controls + + + +Authored fixtures and independent reader checks; no converter creates these controls. +Pinned installed versions: Graphology 0.26.0, Graphlib 4.0.1, +Cytoscape.js 3.34.0, d3-force 3.0.0, elkjs 0.11.1. + +Official references: + +- [Graphology serialization](https://graphology.github.io/serialization.html): native mixed directed/undirected edges, multigraph options, attribute dictionaries. +- [Graphlib concepts and serialization](https://github.com/dagrejs/graphlib/wiki/API-Reference): global directedness, named parallel edges, compound nodes, arbitrary labels. +- [Cytoscape element JSON](https://js.cytoscape.org/#notation/elements-json): global element IDs, data, parents, positions. +- [D3 link force](https://d3js.org/d3-force/link): numeric indices use the default accessor; string IDs use an ID accessor. Live node objects are runtime references. +- [ELK JSON](https://eclipse.dev/elk/documentation/tooldevelopers/graphdatastructure/jsonformat.html): unique IDs, ports, extended/primitive edges and sections. + +Graphlib controls intentionally use separate directed/undirected fixtures: an edge +label cannot establish native mixed direction. D3/ELK/Cytoscape preserve application +mode metadata but do not independently interpret every Graph direction kind. +D3 document values preserve JSON topology; runtime alias identity is outside that +contract. Native readers may normalize fields absent from the source. These tests +prove their named profiles, not universal semantic conformance. + +Observed upstream boundaries at these pinned versions: Graphology adjacency queries +for a `constructor` neighbor throw, and Graphlib compound `__proto__` children throw +during JSON import. Explicit negative controls retain those reproductions; supported +control fixtures use ordinary IDs. Future adapters must reject or explicitly encode +such native keys, rather than treating parser acceptance as semantic proof. diff --git a/tests/native/fixtures/structured/fixtures.ts b/tests/native/fixtures/structured/fixtures.ts new file mode 100644 index 0000000..283bfd1 --- /dev/null +++ b/tests/native/fixtures/structured/fixtures.ts @@ -0,0 +1,40 @@ +import type { SerializedGraph } from 'graphology-types'; + +/** Authored native fixture; deliberately independent of every converter. */ +export function createGraphologyNativeFixture(): SerializedGraph { + return { + options: { type: 'mixed', multi: true, allowSelfLoops: true }, + attributes: { title: '混合 graph', data: ['root', null], __statelyai: { foreign: true } }, + nodes: [ + { key: 'a', attributes: { label: '', data: null } }, + { key: 'b', attributes: { label: '雪 🙂', data: ['array', 2] } }, + { key: '', attributes: { label: 'empty ID', data: 42 } }, + { key: 'isolated', attributes: { label: null, x: 3 } }, + ], + edges: [ + { key: 'parallel 1', source: 'a', target: 'b', attributes: { weight: 1, label: '' } }, + { key: 'parallel 2', source: 'a', target: 'b', attributes: { weight: 2, data: null } }, + { key: 'undirected', source: 'b', target: '', undirected: true, attributes: { data: [1, 2] } }, + { key: 'loop', source: '', target: '', attributes: { label: 'loop' } }, + ], + }; +} + +/** Graphlib has a global orientation; mixedness is not inferred from labels. */ +export function createGraphlibNativeFixture(directed = true) { + return { + options: { directed, multigraph: true, compound: true }, + value: { label: 'Graphlib', data: null, __statelyai: { foreign: true } }, + nodes: [ + { v: 'parent', value: { label: '' } }, + { v: 'a', parent: 'parent', value: { data: ['array', null] } }, + { v: '', value: 42 }, + { v: 'isolated', value: null }, + ], + edges: [ + { v: 'a', w: '', name: 'first', value: { weight: 1, label: '' } }, + { v: 'a', w: '', name: 'second', value: ['edge', null] }, + { v: '', w: '', name: 'loop', value: null }, + ], + }; +} diff --git a/tests/native/followon-adapters.test.ts b/tests/native/followon-adapters.test.ts new file mode 100644 index 0000000..ee57529 --- /dev/null +++ b/tests/native/followon-adapters.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import Ajv from 'ajv'; +import { describe, expect, it } from 'vitest'; +import { Graphviz } from '@hpcc-js/wasm-graphviz'; +import { createGraph } from '../../src/graph'; +import { toGraphvizJSON, fromGraphvizJSON, type GraphvizJSON } from '../../src/formats/graphviz-json'; +import { toDrawio, fromDrawio, createDrawioDocumentConverter } from '../../src/formats/drawio'; +import { toPajek, fromPajek, pajekDocumentConverter } from '../../src/formats/pajek'; +import { getXMLValidation } from './xml-oracle'; +import { getIGraphOracle } from './igraph-oracle'; +import { getNetworkXOracle } from './networkx-oracle'; +const schemaDirectory = new URL('./fixtures/graphviz/schema/', import.meta.url); +const schema = JSON.parse(readFileSync(new URL('graphviz_json_schema.json', schemaDirectory), 'utf8')); +const validate = new Ajv({ strict: false }).compile(schema); +describe('Graphviz output JSON independent schema/engine', () => { + it('pins unmodified official schema/license bytes', () => { + const manifest = JSON.parse(readFileSync(new URL('provenance.json', schemaDirectory), 'utf8')); + for (const file of manifest.files) expect(createHash('sha256').update(readFileSync(new URL(file.file, schemaDirectory))).digest('hex')).toBe(file.sha256); + }); + it('validates fresh minimal output without treating JSON as a native Graphviz input', () => { + const graph = createGraph({ nodes: [{ id: 'group' }, { id: 'a', parentId: 'group', label: '' }, { id: 'isolated', label: null }], edges: [{ id: 'e', sourceId: 'a', targetId: 'isolated', weight: 2.5 }] }); + expect(validate(toGraphvizJSON(graph))).toBe(true); + }); + it('imports actual current dot_json and rendered json engine output with native topology/attributes intact', async () => { + const engine = await Graphviz.load(); + for (const profile of ['dot_json', 'json'] as const) { + const source: GraphvizJSON = JSON.parse(engine.layout('digraph G { a [label="A"]; b; isolated; a -> b [label="E",weight=2.5]; a -> b [weight=3]; }', profile, 'dot')); + expect(validate(source)).toBe(true); + const graph = fromGraphvizJSON(source); + expect(graph.nodes).toHaveLength(3); expect(graph.edges).toHaveLength(2); expect(graph.edges.map((edge) => edge.weight)).toEqual([2.5, 3]); + expect(toGraphvizJSON(graph)).toEqual(source); + } + }); + it('uses actual fdp cluster endpoints while distinguishing dot producer behavior', async () => { + const engine = await Graphviz.load(), input = 'graph G { subgraph cluster_a {a;b;a--b;} subgraph cluster_b {c;d;c--d;} cluster_a -- cluster_b; }'; + const fdp: GraphvizJSON = JSON.parse(engine.layout(input, 'json', 'fdp')); + expect(validate(fdp)).toBe(true); expect(fdp.edges!.at(-1)).toMatchObject({ tail: 0, head: 1 }); + const graph = fromGraphvizJSON(fdp); expect(graph.edges.at(-1)).toMatchObject({ sourceId: 'graphviz:cluster:0', targetId: 'graphviz:cluster:1' }); + expect(toGraphvizJSON(graph)).toEqual(fdp); + const minimal = toGraphvizJSON(graph, { regenerate: true }); expect(validate(minimal)).toBe(true); + expect(minimal.edges!.at(-1)).toMatchObject({ tail: 0, head: 1 }); + const dot: GraphvizJSON = JSON.parse(engine.layout(input, 'json', 'dot')); + expect(dot.edges!.at(-1)!.tail).toBeGreaterThanOrEqual(dot._subgraph_cnt); + }); + it('documents weak upstream unknown-property typing while rejecting malformed known fields', () => { + const source: GraphvizJSON = { name: 'G', directed: true, strict: false, _subgraph_cnt: 0, objects: [{ _gvid: 0, name: 'a', label: 42 }], edges: [] }; + expect(validate(source)).toBe(true); // additionalProperties is nested in properties in the published schema. + expect(() => fromGraphvizJSON(source)).toThrow(/label must be a string/); + }); +}); +describe('fresh draw.io generated-file profile through actual libxml2', () => { + it('validates fresh UserObject carriers, Unicode, empty labels, reserved IDs, hierarchy and waypoints', async () => { + const graph = createGraph({ nodes: [{ id: '0', label: 'A\rB\nC\tα', x: 1, y: 2, width: 120, height: 60, data: 42 }, { id: '1', label: '', parentId: '0' }], edges: [{ id: '0', sourceId: '0', targetId: '1', label: null, points: [{ x: 10, y: 20 }] }] }); + const source = toDrawio(graph); + const result = await getXMLValidation(source, 'drawio'); expect(result.errors).toEqual([]); expect(result.valid).toBe(true); + expect(fromDrawio(source).nodes[0].label).toBe('A\rB\nC\tα'); + }); + it('keeps selected-page edits schema-valid without changing unselected pages', async () => { + const source = readFileSync(new URL('./fixtures/drawio/pages-uncompressed.drawio', import.meta.url), 'utf8'), converter = createDrawioDocumentConverter({ pageId: 'first' }), document = converter.from(source); + document.graph!.nodes.find((node) => node.id === 'b')!.label = 'NEW & '; + const output = converter.to(document); + expect(output.slice(output.indexOf(' { + it.each(['directed', 'undirected'] as const)('preserves actual %s topology, duplicate labels, isolates, parallel edges and numeric weights in igraph', async (mode) => { + const graph = createGraph({ mode, nodes: [{ id: '0', label: 'same' }, { id: '1', label: 'same' }, { id: 'isolated', label: null }], edges: [{ id: 'e', sourceId: '0', targetId: '1', weight: 2.5 }, { id: 'parallel', sourceId: '0', targetId: '1', label: '', weight: 0 }] }); + const source = toPajek(graph), native = await getIGraphOracle({ operation: 'pajek', source }); + expect(native.summary!.directed).toBe(mode === 'directed'); expect(native.summary!.nodes).toHaveLength(3); expect(native.summary!.nodes.slice(0, 2).map((node) => node.attributes.name)).toEqual(['same', 'same']); + expect(native.summary!.edges.map((edge) => [edge.source, edge.target, edge.attributes.weight])).toEqual([[0, 1, 2.5], [0, 1, 0]]); + expect(fromPajek(source).nodes.map((node) => node.label)).toEqual(['same', 'same', null]); + }); + it('passes the NetworkX unique-label subset without asking it to certify duplicate identity', async () => { + const source = toPajek(createGraph({ nodes: [{ id: 'a', label: 'A α' }, { id: 'b', label: 'B' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 2.5, label: 'EDGE' }] })); + const native = await getNetworkXOracle({ operation: 'pajek', source }); + expect(native.summary!.nodes.map((node) => node.id)).toEqual(['A α', 'B']); expect(native.summary!.edges[0].attributes).toMatchObject({ weight: 2.5, l: 'EDGE' }); + }); + it('patches persisted literal labels without changing actual native topology', async () => { + const source = toPajek(createGraph({ nodes: [{ id: 'a', label: null }, { id: 'b', label: '' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', weight: 1 }] })); + const document = JSON.parse(JSON.stringify(pajekDocumentConverter.from(source))); document.graph.nodes[0].label = 'NEW α'; document.graph.edges[0].label = 'EDGE'; + const native = await getIGraphOracle({ operation: 'pajek', source: pajekDocumentConverter.to(document) }); + expect(native.summary!.nodes[0].attributes.name).toBe('NEW α'); expect(native.summary!.edges[0]).toMatchObject({ source: 0, target: 1, attributes: { label: 'EDGE', weight: 1 } }); + }); +}); diff --git a/tests/native/gml.test.ts b/tests/native/gml.test.ts new file mode 100644 index 0000000..fd7c1bf --- /dev/null +++ b/tests/native/gml.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { fromGML, toGML, gmlDocumentConverter } from '../../src/formats/gml'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { getNetworkXOracle } from './networkx-oracle'; + +describe('GML standard output through actual NetworkX3.7', () => { + it('pins authored input and the actual independent reader output', () => { + const provenance = JSON.parse(readFileSync(new URL('./fixtures/gml/provenance.json', import.meta.url), 'utf8')); + for (const file of provenance.files) expect(createHash('sha256').update(readFileSync(new URL(`./fixtures/gml/${file.file}`, import.meta.url))).digest('hex')).toBe(file.sha256); + }); + it('uses the exact configured independent reader version', async () => { + expect((await getNetworkXOracle({ operation: 'version' })).version).toBe('3.7'); + }); + it('parses fresh numeric identity, labels/entities, isolated nodes, weights and Graph data', async () => { + const graph = createGraph({ id: 'network', data: { custom: 'KEEP' }, + nodes: [{ id: 'a', label: 'A & α "quoted"', data: 42 }, { id: 'b', label: '' }, { id: 'isolated', label: null }], + edges: [{ id: 'e', sourceId: 'a', targetId: 'b', label: '', weight: 2.5 }] }); + const native = await getNetworkXOracle({ operation: 'gml', source: toGML(graph, { standard: true }) }); + expect(native.summary).toMatchObject({ directed: true, multigraph: false, graph: { data: '{"custom":"KEEP"}' }, + nodes: [{ id: 0, attributes: { label: 'A & α "quoted"', data: '42' } }, { id: 1 }, { id: 2 }], + edges: [{ source: 0, target: 1, attributes: { label: '', weight: 2.5 } }] }); + expect(native.summary!.nodes[1].attributes.label).toBe(''); + expect(native.summary!.nodes[2].attributes.label).toBeUndefined(); + }); + it('preserves native missing label presence separately from explicit empty values', async () => { + const source = readFileSync(new URL('./fixtures/gml/native.gml', import.meta.url), 'utf8'); + const before = await getNetworkXOracle({ operation: 'gml', source }); + expect(before.summary).toEqual(JSON.parse(readFileSync(new URL('./fixtures/gml/native.native.json', import.meta.url), 'utf8')).summary); + const graph = fromGML(source, { standard: true }); + const after = await getNetworkXOracle({ operation: 'gml', source: toGML(graph, { standard: true }) }); + expect(after.summary!.nodes.map((node) => node.attributes.label)).toEqual(before.summary!.nodes.map((node) => node.attributes.label)); + expect(after.summary!.edges.map((edge) => edge.attributes.label)).toEqual(before.summary!.edges.map((edge) => edge.attributes.label)); + graph.nodes[2].label = 'ADDED'; + expect((await getNetworkXOracle({ operation: 'gml', source: toGML(graph, { standard: true }) })).summary!.nodes[2].attributes.label).toBe('ADDED'); + }); + it('parses parallel edges in the explicit multigraph profile', async () => { + const graph = createGraph({ mode: 'undirected', nodes: [{ id: 'a' }, { id: 'b' }, { id: 'isolated' }], + edges: [{ id: 'first', sourceId: 'a', targetId: 'b', weight: 1 }, { id: 'second', sourceId: 'b', targetId: 'a', weight: 2 }] }); + const native = await getNetworkXOracle({ operation: 'gml', source: toGML(graph, { standard: true }) }); + expect(native.summary).toMatchObject({ directed: false, multigraph: true }); + expect(native.summary!.nodes).toHaveLength(3); + expect(native.summary!.edges.map((edge) => edge.attributes.weight)).toEqual([1, 2]); + }); + it('accepts the legacy string identity extension but rejects malformed blocks independently', async () => { + const graph = createGraph({ nodes: [{ id: 'a' }] }); + expect((await getNetworkXOracle({ operation: 'gml', source: toGML(graph) })).summary!.nodes[0].id).toBe('a'); + await expect(getNetworkXOracle({ operation: 'gml', source: 'graph [ node [ id 0 ]' })).rejects.toThrow(); + }); +}); + +it('GML preserves exact CR LF TAB node and edge labels in the independent line reader', async () => { + const label = 'A\rB\nC\tD'; + const graph = createGraph({ nodes: [{ id: 'a', label }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', label }] }); + const native = await getNetworkXOracle({ operation: 'gml', source: toGML(graph, { standard: true }) }); + expect(native.summary!.nodes[0].attributes.label).toBe(label); + expect(native.summary!.edges[0].attributes.label).toBe(label); +}); + +it('GML persisted document label edits remain exact in the independent native reader', async () => { + const source = '# KEEP\ngraph [ directed 1 node [ id 0 label "A B" ] node [ id 1 ] edge [ id 2 source 0 target 1 label "OLD" ] ]'; + const document = JSON.parse(JSON.stringify(gmlDocumentConverter.from(source))); + expect(gmlDocumentConverter.to(document)).toBe(source); + document.graph.nodes[0].label = 'N\rO\nP\tQ'; + document.graph.edges[0].label = 'E\rF\nG\tH'; + const output = gmlDocumentConverter.to(document); + const native = await getNetworkXOracle({ operation: 'gml', source: output }); + expect(native.summary!.nodes[0].attributes.label).toBe('N\rO\nP\tQ'); + expect(native.summary!.edges[0].attributes.label).toBe('E\rF\nG\tH'); + expect(output).toContain('# KEEP\n'); +}); diff --git a/tests/native/graphviz.test.ts b/tests/native/graphviz.test.ts new file mode 100644 index 0000000..fd04073 --- /dev/null +++ b/tests/native/graphviz.test.ts @@ -0,0 +1,98 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { Graphviz } from '@hpcc-js/wasm-graphviz'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { fromDOT, toDOT } from '../../src/formats/dot'; + +interface NativeGraph { name: string; directed: boolean; strict: boolean; rankdir?: string; objects: Array & { _gvid: number; name: string }>; edges?: Array & { tail: number; head: number }> } +let engine: Awaited>; +beforeAll(async () => { engine = await Graphviz.load(); }); +function getNative(source: string): NativeGraph { return JSON.parse(engine.layout(source, 'json', 'dot')); } +function getTopology(native: NativeGraph) { + const names = new Map(native.objects.map((object) => [object._gvid, object.name])); + return { directed: native.directed, strict: native.strict, nodes: native.objects.filter((object) => !object.nodes).map((object) => object.name).sort(), + edges: (native.edges ?? []).map((edge) => [names.get(edge.tail), names.get(edge.head)]) }; +} + +describe('Graphviz 15.0.0 native parser/layout oracle', () => { + it('pins the independent engine version', () => { expect(engine.version()).toBe('15.0.0'); }); + it('parses emitted DOT topology, isolated nodes, Unicode, parallel edges and modes', () => { + const graph = createGraph({ id: 'network 🕸️', direction: 'right', mode: 'bidirectional', + nodes: [{ id: 'a', label: 'A', shape: 'rectangle', color: '#abcdef' }, { id: 'b', label: 'B' }, { id: 'isolated 🕸️', label: 'Isolated' }], + edges: [{ id: 'e', sourceId: 'a', targetId: 'b', label: 'Edge', color: '#123456' }, { id: 'parallel', sourceId: 'a', targetId: 'b', mode: 'undirected', label: 'Second' }] }); + const native = getNative(toDOT(graph)); + expect(getTopology(native)).toEqual({ directed: true, strict: false, nodes: ['a', 'b', 'isolated 🕸️'], edges: [['a', 'b'], ['a', 'b']] }); + expect(native.name).toBe(graph.id); + expect(native.rankdir).toBe('LR'); + expect(native.objects.find((object) => object.name === 'a')).toMatchObject({ label: 'A', shape: 'box', fillcolor: '#abcdef', style: 'filled' }); + expect(native.edges![0]).toMatchObject({ label: 'Edge', color: '#123456', dir: 'both' }); + expect(native.edges![1]).toMatchObject({ label: 'Second', dir: 'none' }); + }); + it('renders explicit empty node labels as empty rather than Graphviz default node IDs', () => { + const native = getNative(toDOT(createGraph({ nodes: [{ id: 'empty', label: '' }, { id: 'missing', label: null }] }))); + expect(native.objects.find((object) => object.name === 'empty')!.label).toBe(''); + expect(native.objects.find((object) => object.name === 'missing')!.label).toBe('\\N'); + }); + it('retains native strict merge attributes and latest explicit endpoint ports', () => { + const source = 'strict digraph G { a [shape=record,label="

P|R"]; b [shape=record,label="Q"]; a:p:n -> b:q:s [weight=2,label="Edge"]; a:r -> b [color=red]; isolated; }'; + const expected = getNative(source), emitted = getNative(toDOT(fromDOT(source))); + expect(getTopology(emitted)).toEqual(getTopology(expected)); + expect(expected.edges![0]).toMatchObject({ tailport: 'r', headport: 'q:s', label: 'Edge', color: 'red', weight: '2' }); + expect(emitted.edges![0]).toMatchObject({ tailport: 'r', headport: 'q:s', label: 'Edge', color: 'red', weight: '2' }); + }); + it('retains native compass-only endpoints and cluster/rank topology', () => { + const source = 'digraph G { subgraph cluster_group { a [label="A"]; b; } {rank=same; a; c;} a:n -> c:s [label="E"]; isolated; }'; + const expected = getNative(source), emitted = getNative(toDOT(fromDOT(source))); + expect(getTopology(emitted)).toEqual(getTopology(expected)); + expect(emitted.edges![0]).toMatchObject({ tailport: 'n', headport: 's', label: 'E' }); + const getMembers = (native: NativeGraph) => { + const names = new Map(native.objects.map((object) => [object._gvid, object.name])); + return native.objects.find((object) => object.name === 'cluster_group')!.nodes.map((id: number) => names.get(id)).sort(); + }; + expect(getMembers(emitted)).toEqual(getMembers(expected)); + }); + it('keeps unknown native graph/node/edge attributes authoritative through export', () => { + const source = 'graph G { graph[bgcolor="pink"]; node[fontname="Courier"]; a[label="A",tooltip="KEEP",color=red,fillcolor=blue,style=filled]; b; a -- b[penwidth=3,label="E"]; }'; + const native = getNative(toDOT(fromDOT(source))); + expect(native).toMatchObject({ directed: false, bgcolor: 'pink' }); + expect(native.objects.find((object) => object.name === 'a')).toMatchObject({ label: 'A', tooltip: 'KEEP', color: 'red', fillcolor: 'blue', style: 'filled', fontname: 'Courier' }); + expect(native.edges![0]).toMatchObject({ penwidth: '3', label: 'E' }); + }); + it('rejects malformed syntax using Graphviz independently', () => { + expect(() => engine.layout('digraph { a -> ; }', 'json', 'dot')).toThrow(); + }); + it('verifies vendored native JSON fixtures and their endpoint reference domain', () => { + const directory = new URL('./fixtures/graphviz/', import.meta.url); + const manifest = JSON.parse(readFileSync(new URL('provenance.json', directory), 'utf8')) as { engineVersion: string; files: Array<{ file: string; sha256: string }> }; + expect(manifest.engineVersion).toBe(engine.version()); + for (const item of manifest.files) { + const bytes = readFileSync(new URL(item.file, directory)); + expect(createHash('sha256').update(bytes).digest('hex')).toBe(item.sha256); + if (item.file.endsWith('.json')) { + const fixture = JSON.parse(bytes.toString('utf8')) as NativeGraph; + const indices = new Set(fixture.objects.map((object) => object._gvid)); + for (const edge of fixture.edges ?? []) { expect(indices.has(edge.tail)).toBe(true); expect(indices.has(edge.head)).toBe(true); } + const source = readFileSync(new URL(item.file.replace('.json', '.dot'), directory), 'utf8'); + expect(getTopology(getNative(source))).toEqual(getTopology(fixture)); + } + } + }); +}); + +it('preserves missing native labels as Graphviz default ID labels during conversion', () => { + const source = 'digraph { a; b [label=""]; }'; + const native = getNative(toDOT(fromDOT(source))); + expect(native.objects.find((object) => object.name === 'a')!.label).toBe('\\N'); + expect(native.objects.find((object) => object.name === 'b')!.label).toBe(''); +}); + +it('rejects actual native NUL scanning and guards emitted strings without rejecting escaped private JSON', () => { + expect(() => getNative('digraph { a [label="A\u0000B"]; }')).toThrow(); + for (const value of ['A\u0000B', '\ud800']) { + expect(() => toDOT(createGraph({ nodes: [{ id: 'a', label: value }] }))).toThrow(/Unicode scalar/); + expect(() => toDOT(createGraph({ id: value, nodes: [{ id: 'a' }] }))).toThrow(/Unicode scalar/); + } + const source = toDOT(createGraph({ nodes: [{ id: 'a', label: 'A', data: { nul: '\u0000', surrogate: '\ud800' } }] })); + expect(getNative(source).objects[0].label).toBe('A'); expect(fromDOT(source).nodes[0].data).toEqual({ nul: '\u0000', surrogate: '\ud800' }); +}); diff --git a/tests/native/igraph-oracle.ts b/tests/native/igraph-oracle.ts new file mode 100644 index 0000000..40fc34b --- /dev/null +++ b/tests/native/igraph-oracle.ts @@ -0,0 +1,25 @@ +import { execFile } from 'node:child_process'; +import { resolve } from 'node:path'; + +export interface IGraphOracleRequest { operation: 'version' | 'pajek'; source?: string } +export interface IGraphOracleResult { + oracle: 'igraph'; version: string; python: string; warnings?: string[]; + summary?: { directed: boolean; graph: Record; + nodes: Array<{ id: number; attributes: Record }>; + edges: Array<{ source: number; target: number; attributes: Record }> }; +} + +/** Execute the pinned C-backed reader; no native identity is replaced by its label. */ +export async function getIGraphOracle(request: IGraphOracleRequest): Promise { + const python = process.env.GRAPH_NATIVE_PYTHON ?? resolve('.venv-native/bin/python'); + const result = await new Promise((resolveResult, reject) => { + const child = execFile(python, [resolve('scripts/native/igraph_oracle.py')], { timeout: 10_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error) reject(new Error(`igraph native oracle failed: ${stderr.trim() || error.message}`)); + else resolveResult(stdout); + }); + child.stdin!.end(JSON.stringify(request)); + }); + const parsed = JSON.parse(result) as IGraphOracleResult; + if (parsed.oracle !== 'igraph' || parsed.version !== '1.0.0') throw new Error('igraph native oracle version mismatch'); + return parsed; +} diff --git a/tests/native/igraph-pajek.test.ts b/tests/native/igraph-pajek.test.ts new file mode 100644 index 0000000..ccabec7 --- /dev/null +++ b/tests/native/igraph-pajek.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { getIGraphOracle } from './igraph-oracle'; + +function getFixture(name: string): string { + return readFileSync(new URL(`./fixtures/pajek/${name}.net`, import.meta.url), 'utf8'); +} + +describe('actual igraph1.0.0 C-backed Pajek reader', () => { + it('pins reader version, authored fixtures and actual native output bytes', async () => { + expect((await getIGraphOracle({ operation: 'version' })).version).toBe('1.0.0'); + const directory = new URL('./fixtures/pajek/', import.meta.url); + const provenance = JSON.parse(readFileSync(new URL('igraph-provenance.json', directory), 'utf8')); + for (const file of provenance.files) expect(createHash('sha256').update(readFileSync(new URL(file.file, directory))).digest('hex')).toBe(file.sha256); + }); + it.each(['uniform', 'duplicate-labels', 'comments', 'mixed'])('reproduces captured %s native semantics', async (name) => { + const native = await getIGraphOracle({ operation: 'pajek', source: getFixture(name) }); + const captured = JSON.parse(readFileSync(new URL(`./fixtures/pajek/${name}.igraph.json`, import.meta.url), 'utf8')); + expect(native.summary).toEqual(captured.summary); + expect(native.warnings).toEqual(captured.warnings); + }); + it('retains independent vertex identity when labels are equal', async () => { + const native = await getIGraphOracle({ operation: 'pajek', source: getFixture('duplicate-labels') }); + expect(native.summary).toEqual({ directed: false, graph: {}, nodes: [{ id: 0, attributes: { name: 'same' } }, { id: 1, attributes: { name: 'same' } }], edges: [{ source: 0, target: 1, attributes: { weight: 1 } }] }); + }); + it('reads comments without inventing edges and exposes its mixed-mode limitation', async () => { + const comments = await getIGraphOracle({ operation: 'pajek', source: getFixture('comments') }); + expect(comments.summary!.edges).toEqual([{ source: 0, target: 1, attributes: { weight: 1 } }]); + const mixed = await getIGraphOracle({ operation: 'pajek', source: getFixture('mixed') }); + expect(mixed.summary!.directed).toBe(false); + expect(mixed.summary!.edges.map((edge) => [edge.source, edge.target, edge.attributes.weight])).toEqual([[0, 1, 1], [0, 1, 2]]); + }); + it('rejects out-of-range vertex references in the actual C parser', async () => { + await expect(getIGraphOracle({ operation: 'pajek', source: '*Vertices 1\n1 "one"\n*Edges\n1 2 1\n' })).rejects.toThrow(/igraph native oracle failed/); + }); +}); diff --git a/tests/native/jgf.test.ts b/tests/native/jgf.test.ts new file mode 100644 index 0000000..2c7460a --- /dev/null +++ b/tests/native/jgf.test.ts @@ -0,0 +1,94 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import Ajv from 'ajv'; +import AjvDraft04 from 'ajv-draft-04'; +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { fromJGF, toJGF, jgfDocumentConverter, type JGFInput } from '../../src/formats/jgf'; + +const directory = new URL('./fixtures/jgf/', import.meta.url); +const provenance = JSON.parse(readFileSync(new URL('provenance.json', directory), 'utf8')) as { files: Array<{ file: string; sha256: string }> }; +function getSchema(version: 1 | 2) { + const name = `json-graph-schema_v${version}.json`, bytes = readFileSync(new URL(name, directory)); + const expected = provenance.files.find((file) => file.file === name)!; + if (createHash('sha256').update(bytes).digest('hex') !== expected.sha256) throw new Error(`JGF schema provenance changed: ${name}`); + return JSON.parse(bytes.toString('utf8')); +} +const validators = { 1: new AjvDraft04({ allErrors: true, strict: false }).compile(getSchema(1)), 2: new Ajv({ allErrors: true, strict: false }).compile(getSchema(2)) }; + +describe('official JGF schemas with pinned Ajv draft support', () => { + it.each([1, 2] as const)('validates explicit standard export version %s with rich private metadata', (version) => { + const graph = createGraph({ id: 'graph 🕸️', mode: 'bidirectional', data: 42, style: { color: 'red' }, + nodes: [{ id: 'a', label: null, data: { __statelyai: { user: 'KEEP' }, x: 1 }, ports: [{ name: 'out', data: false }] }, { id: 'isolated', label: '' }, { id: '__proto__', label: 'Prototype' }], + edges: [{ id: 'e', sourceId: 'a', targetId: '__proto__', label: null, mode: 'bidirectional', weight: 2, sourcePort: 'out', data: ['primitive', 1] }] }); + const native = toJGF(graph, { version }); + const validate = validators[version]; + expect(validate(native), JSON.stringify(validate.errors)).toBe(true); + expect(fromJGF(native)).toEqual(graph); + }); + it('makes the backward-compatible default hybrid explicitly invalid under both official profiles', () => { + const native = toJGF(createGraph({ nodes: [{ id: 'a' }] })); + expect(validators[1](native)).toBe(false); + expect(validators[2](native)).toBe(false); + }); + it('validates v1 native nullable metadata and directed defaults independently', () => { + const native: JGFInput = { graph: { directed: null, metadata: null, + nodes: [{ id: 'a', metadata: null }, { id: 'b', label: '', metadata: { vendor: 'KEEP' } }], + edges: [{ source: 'a', target: 'b', directed: null, metadata: null }, { source: 'b', target: 'a', directed: false, relation: 'linked' }] } }; + expect(validators[1](native), JSON.stringify(validators[1].errors)).toBe(true); + const graph = fromJGF(native, { metadata: 'native' }); + expect(graph.mode).toBe('directed'); + expect(graph.edges[0].mode).toBeUndefined(); + expect(graph.edges[1].mode).toBe('undirected'); + const emitted = toJGF(graph, { version: 1 }); + expect(validators[1](emitted), JSON.stringify(validators[1].errors)).toBe(true); + expect(emitted).toEqual(native); + }); + it('validates v2 keyed nodes, native metadata and undirected overrides', () => { + const native: JGFInput = { graph: { id: 'native', directed: false, metadata: { vendor: { keep: true } }, + nodes: { a: { label: 'A', metadata: { __statelyai: { user: true } } }, b: {} }, + edges: [{ id: 'native-edge', source: 'a', target: 'b', directed: true, metadata: { vendor: 'KEEP' } }] } }; + expect(validators[2](native), JSON.stringify(validators[2].errors)).toBe(true); + const graph = fromJGF(native, { metadata: 'native' }); + expect(graph.mode).toBe('undirected'); + expect(graph.edges[0].mode).toBe('directed'); + const emitted = toJGF(graph, { version: 2 }); + expect(validators[2](emitted), JSON.stringify(validators[2].errors)).toBe(true); + expect(emitted).toEqual(native); + }); + it('records official v2 nodes-only schema ambiguity rather than altering the pinned schema', () => { + const example = { graph: { nodes: { a: {} } } }; + expect(validators[2](example)).toBe(false); + expect(validators[2].errors?.some((error) => error.keyword === 'oneOf')).toBe(true); + expect(validators[2]({ graph: { ...example.graph, edges: [] } })).toBe(true); + }); + it('rejects v2 nullable metadata and directed values permitted only in v1', () => { + expect(validators[2]({ graph: { directed: null, metadata: null, nodes: {}, edges: [] } })).toBe(false); + }); + it('retains schema-valid hyperedges exactly with an unavailable binary projection', () => { + const native = { graph: { directed: false, nodes: { a: {}, b: {} }, hyperedges: [{ nodes: ['a', 'b'], metadata: { vendor: true } }] } }; + expect(validators[2](native), JSON.stringify(validators[2].errors)).toBe(true); + const document = jgfDocumentConverter.from(native as unknown as JGFInput); + expect(document.graph).toBeNull(); + expect(jgfDocumentConverter.to(JSON.parse(JSON.stringify(document)))).toEqual(native); + }); +}); + +it('converts schema-valid v1 nullable metadata/defaults into schema-valid explicit v2', () => { + const source: JGFInput = { graph: { directed: null, metadata: null, nodes: [{ id: 'a', metadata: null }, { id: 'b' }], edges: [{ source: 'a', target: 'b', directed: null, metadata: null }] } }; + expect(validators[1](source), JSON.stringify(validators[1].errors)).toBe(true); + const output = toJGF(fromJGF(source, { metadata: 'native' }), { version: 2 }); + expect(validators[2](output), JSON.stringify(validators[2].errors)).toBe(true); + expect(output.graph.directed).toBe(true); + expect(Array.isArray(output.graph.nodes)).toBe(false); +}); +it('makes explicit v2 export schema-valid for sparse legacy-compatible v2 imports', () => { + // The official nodes-only v2 schema has overlapping oneOf alternatives; + // an explicit standard export supplies edges plus resolved direction. + const source: JGFInput = { graph: { nodes: { a: {} } } }; + expect(validators[2](source)).toBe(false); + const output = toJGF(fromJGF(source, { metadata: 'native' }), { version: 2 }); + expect(validators[2](output), JSON.stringify(validators[2].errors)).toBe(true); + expect(output.graph.edges).toEqual([]); + expect(output.graph.directed).toBe(true); +}); diff --git a/tests/native/matrices.test.ts b/tests/native/matrices.test.ts new file mode 100644 index 0000000..ba00b88 --- /dev/null +++ b/tests/native/matrices.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { toMatrixMarket, fromMatrixMarket, matrixMarketDocumentConverter } from '../../src/formats/matrix-market'; +import { getSciPyOracle } from './scipy-oracle'; +describe('actual SciPy 1.18.1 / NumPy 2.5.4 Matrix Market semantics', () => { + it.each(['coordinate', 'array'] as const)('native %s directed coefficients, isolates and negative values', async (storage) => { + const graph = createGraph({ nodes: [{ id: 'a' }, { id: 'b' }, { id: 'isolated' }], edges: [{ id: 'zero', sourceId: 'a', targetId: 'b', weight: 0 }, { id: 'negative', sourceId: 'b', targetId: 'b', weight: -2.5 }, { id: 'forward', sourceId: 'a', targetId: 'a', weight: 3 }] }); + const source = toMatrixMarket(graph, { storage }); const oracle = await getSciPyOracle(source, { symmetry: 'general' }); + expect(oracle.shape).toEqual([3, 3]); expect(oracle.dense).toEqual([[3, 0, 0], [0, -2.5, 0], [0, 0, 0]]); expect(oracle.sparse).toBe(storage === 'coordinate'); + if (storage === 'coordinate') { expect(oracle.storedZeros).toBe(1); expect(oracle.entries.some((entry) => entry.row === 0 && entry.col === 1 && entry.value === 0)).toBe(true); } + const doc = matrixMarketDocumentConverter.from(source); doc.graph!.edges[1].weight = -4; + expect((await getSciPyOracle(matrixMarketDocumentConverter.to(doc), { symmetry: 'general' })).dense![1][1]).toBe(-4); + }); + it.each(['coordinate', 'array'] as const)('native %s symmetric lower triangle expands exactly once and keeps zero diagonal', async (storage) => { + const graph = createGraph({ mode: 'undirected', nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'u', sourceId: 'a', targetId: 'b', weight: -2 }, { id: 'diag', sourceId: 'a', targetId: 'a', weight: 0 }] }); + const source = toMatrixMarket(graph, { storage, field: 'integer' }); const oracle = await getSciPyOracle(source, { symmetry: 'symmetric' }); + expect(oracle.dense).toEqual([[0, -2], [-2, 0]]); if (storage === 'coordinate') expect(oracle.storedZeros).toBe(1); + expect(fromMatrixMarket(source).edges.map((edge) => edge.id)).toEqual(['u', 'diag']); + }); + it('native authored array is column-major; duplicate coordinate native reader sums but adapter requires explicit policy', async () => { + const array = '%%MatrixMarket matrix array real general\n2 2\n1\n2\n3\n4\n'; expect((await getSciPyOracle(array, { symmetry: 'general' })).dense).toEqual([[1, 3], [2, 4]]); + const duplicate = '%%MatrixMarket matrix coordinate real general\n2 2 2\n1 2 2\n1 2 -1\n'; const oracle = await getSciPyOracle(duplicate, { symmetry: 'general' }); + expect(oracle.entries).toHaveLength(2); expect(oracle.dense).toEqual([[0, 1], [0, 0]]); expect(() => fromMatrixMarket(duplicate)).toThrow('duplicate'); expect(fromMatrixMarket(duplicate, { aggregation: 'sum' }).edges[0].weight).toBe(1); + }); + it('native pattern represents unit coefficients, malformed dimensions are independently rejected', async () => { + const source = '%%MatrixMarket matrix coordinate pattern general\n2 2 2\n1 2\n2 2\n'; expect((await getSciPyOracle(source, { symmetry: 'general', field: 'pattern' })).dense).toEqual([[0, 1], [0, 1]]); + await expect(getSciPyOracle('%%MatrixMarket matrix coordinate real general\n2 2 2\n1 2 1\n')).rejects.toThrow(); + }); + it('sparse large graph has no dense adapter/oracle intermediate', async () => { + const graph = createGraph({ nodes: Array.from({ length: 20000 }, (_, index) => ({ id: `n${index}` })), edges: [{ id: 'e', sourceId: 'n0', targetId: 'n19999', weight: 0 }] }); + const source = toMatrixMarket(graph); const oracle = await getSciPyOracle(source, { symmetry: 'general' }); expect(oracle.shape).toEqual([20000, 20000]); expect(oracle.dense).toBeUndefined(); expect(oracle.entries).toEqual([{ row: 0, col: 19999, value: 0 }]); + }); +}); diff --git a/tests/native/mermaid-decoder.ts b/tests/native/mermaid-decoder.ts new file mode 100644 index 0000000..3fec366 --- /dev/null +++ b/tests/native/mermaid-decoder.ts @@ -0,0 +1,13 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +/** Pinned Mermaid native decoding, followed by its browser text interpretation. */ +export async function getNativeMermaidText(text: string): Promise { + const require = createRequire(import.meta.url); + const directory = join(dirname(require.resolve('mermaid')), 'chunks/mermaid.core'); + const matches = readdirSync(directory).filter((file) => file.endsWith('.mjs') && readFileSync(join(directory, file), 'utf8').includes('var decodeEntities =')); + if (matches.length !== 1) throw new Error('Pinned Mermaid entity decoder changed; review oracle'); + const native = await import(pathToFileURL(join(directory, matches[0])).href); + const element = document.createElement('textarea'); element.innerHTML = native.decodeEntities(text); return element.value; +} diff --git a/tests/native/mermaid-expanded.test.ts b/tests/native/mermaid-expanded.test.ts new file mode 100644 index 0000000..985e105 --- /dev/null +++ b/tests/native/mermaid-expanded.test.ts @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +import mermaid from 'mermaid'; +import { describe, expect, it } from 'vitest'; +import { fromMermaidArchitecture, toMermaidArchitecture } from '../../src/formats/mermaid/architecture'; +import { fromMermaidRequirement, toMermaidRequirement } from '../../src/formats/mermaid/requirement'; +import { fromMermaidSankey, toMermaidSankey } from '../../src/formats/mermaid/sankey'; +import { fromMermaidGitGraph, toMermaidGitGraph } from '../../src/formats/mermaid/git-graph'; +import { fromMermaidC4, toMermaidC4 } from '../../src/formats/mermaid/c4'; +import { fromMermaidUseCase, toMermaidUseCase } from '../../src/formats/mermaid/use-case'; +mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' }); +import corpus from './fixtures/mermaid-expanded.json'; +import packageJSON from '../../package.json'; +const fixtures = Object.fromEntries(corpus.fixtures.map(({ format, source }) => [format, source])); +const codecs: Record any; to: (graph: any) => string }> = { + architecture: { from: fromMermaidArchitecture, to: toMermaidArchitecture }, requirement: { from: fromMermaidRequirement, to: toMermaidRequirement }, sankey: { from: fromMermaidSankey, to: toMermaidSankey }, git: { from: fromMermaidGitGraph, to: toMermaidGitGraph }, c4: { from: fromMermaidC4, to: toMermaidC4 }, usecase: { from: fromMermaidUseCase, to: toMermaidUseCase }, +}; +function getNativeSummary(kind: string, db: any): any { + switch (kind) { + case 'architecture': return { groups: db.getGroups(), services: db.getServices(), junctions: db.getJunctions(), edges: db.getEdges() }; + case 'requirement': return { requirements: [...db.getRequirements()], elements: [...db.getElements()], relations: db.getRelationships() }; + case 'sankey': return db.getGraph(); + case 'git': return { commits: db.getCommitsArray().map((commit: any) => [commit.id, commit.branch, commit.parents, commit.type, commit.tags]), branches: db.getBranchesAsObjArray() }; + case 'c4': return { type: db.getC4Type(), root: db.getC4ShapeArray(), api: db.getC4ShapeArray('api'), boundaries: db.getBoundaries(), relations: db.getRels() }; + case 'usecase': return { actors: [...db.getActors()], usecases: [...db.getUseCases()], boundaries: [...db.getSystemBoundaries()], relations: db.getRelationships() }; + } +} +describe('Mermaid12.1 expanded native grammar and databases', () => { + it('pins official-profile provenance to the installed independent oracle', () => { + expect(corpus.oracle).toBe(`mermaid@${packageJSON.devDependencies.mermaid}`); + expect(corpus.fixtures).toHaveLength(6); + }); + it.each(Object.entries(fixtures))('%s accepts authored/emitted source and preserves native semantics', async (kind, source) => { + const native = (await mermaid.mermaidAPI.getDiagramFromText(source)).db as any; + const baseline = JSON.parse(JSON.stringify(getNativeSummary(kind, native))); + const generated = codecs[kind].to(codecs[kind].from(source)); + const db = (await mermaid.mermaidAPI.getDiagramFromText(generated)).db as any; + expect(JSON.parse(JSON.stringify(getNativeSummary(kind, db)))).toEqual(baseline); + switch (kind) { + case 'architecture': expect(db.getGroups()).toMatchObject([{ id: 'api', title: 'API' }]); expect(db.getServices()).toMatchObject([{ id: 'db', in: 'api', icon: 'database' }, { id: 'web', in: 'api' }]); expect(db.getJunctions()).toMatchObject([{ id: 'hub', in: 'api' }]); break; + case 'requirement': expect(db.getRequirements().get('secure')).toMatchObject({ text: 'Secure 東京', risk: 'High', verifyMethod: 'Test' }); expect(db.getRelationships()[0]).toMatchObject({ type: 'satisfies', src: 'app', dst: 'secure' }); break; + case 'sankey': expect(db.getGraph().links).toEqual([{ source: 'Source', target: 'Store', value: 2.5 }, { source: 'Store', target: 'Target', value: 2 }]); break; + case 'git': expect(db.getCommits().get('m')).toMatchObject({ branch: 'main', parents: ['c', 'b'], type: 3 }); break; + case 'c4': expect(db.getC4Shape('db')).toMatchObject({ alias: 'db', label: { text: 'Store' }, techn: { text: 'SQL' } }); break; + case 'usecase': expect(db.getActors().get('User')).toMatchObject({ label: 'User' }); expect(db.getUseCases().get('Login')).toMatchObject({ parentId: 'app' }); expect(db.getRelationships()[1]).toMatchObject({ type: 'include', source: 'Manage', target: 'Login' }); break; + } + }); +}); + +import { getNativeMermaidText } from './mermaid-decoder'; +import { escapeMermaidId } from '../../src/formats/mermaid/shared'; +import { mermaidArchitectureDocumentConverter, mermaidRequirementDocumentConverter, mermaidC4DocumentConverter, mermaidUseCaseDocumentConverter } from '../../src/formats/mermaid/document'; +const unicodeText = '東京 "quoted" \\ slash [] () {} #quot;\nnext'; +it.each(['architecture', 'requirement', 'git', 'c4', 'usecase'])('%s native display decodes exact Unicode/quotes/backslashes and line breaks', async (kind) => { + const graph = codecs[kind].from(fixtures[kind as keyof typeof fixtures]); + if (kind === 'architecture') graph.nodes.find((node: any) => node.id === 'db').label = unicodeText; + if (kind === 'requirement') graph.nodes[0].label = unicodeText; + if (kind === 'git') graph.nodes[1].data.tags = [unicodeText]; + if (kind === 'c4') graph.nodes.find((node: any) => node.id === 'web').label = unicodeText; + if (kind === 'usecase') graph.nodes.find((node: any) => node.id === 'Login').label = unicodeText; + const db = (await mermaid.mermaidAPI.getDiagramFromText(codecs[kind].to(graph))).db as any; + const actual = kind === 'architecture' ? db.getServices().find((node: any) => node.id === 'db').title + : kind === 'requirement' ? db.getRequirements().get('secure').text + : kind === 'git' ? db.getCommits().get('b').tags[0] + : kind === 'c4' ? db.getC4Shape('web').label.text : db.getUseCases().get('Login').label; + expect(await getNativeMermaidText(actual)).toBe(unicodeText); +}); +it('Architecture native sides/arrowheads/group modifiers and align directives agree', async () => { + const source = 'architecture-beta\ngroup g(cloud)[G]\nservice a(server)[A] in g\nservice b(database)[B]\na{group}:R <--> L:b\nalign row a b'; + const baseline = (await mermaid.mermaidAPI.getDiagramFromText(source)).db as any; + const expected = JSON.parse(JSON.stringify({ edges: baseline.getEdges(), align: baseline.getLayoutHints() })); + const db = (await mermaid.mermaidAPI.getDiagramFromText(toMermaidArchitecture(fromMermaidArchitecture(source)))).db as any; + expect({ edges: db.getEdges(), align: db.getLayoutHints() }).toEqual(expected); +}); +it('Requirement native classes/styles, types, directions and semantic relations survive', async () => { + const source = 'requirementDiagram\ndirection LR\nfunctionalRequirement a:::blue {\nid: "R-東京"\nrisk: low\nverifymethod: inspection\n}\nphysicalRequirement b {\ntext: "Body"\nrisk: medium\nverifymethod: demonstration\n}\na - traces -> b\nclassDef blue fill:#eef\nstyle a stroke:#abc'; + const db0 = (await mermaid.mermaidAPI.getDiagramFromText(source)).db as any; + const expected = JSON.parse(JSON.stringify(getNativeSummary('requirement', db0))); + const graph = fromMermaidRequirement(source); + const db = (await mermaid.mermaidAPI.getDiagramFromText(toMermaidRequirement(graph))).db as any; + expect(JSON.parse(JSON.stringify(getNativeSummary('requirement', db)))).toEqual(expected); + expect(db.getDirection()).toBe('LR'); +}); +it('UseCase variants, stereotypes, explicit IDs/styles/animation, notes and JSON cells retain native semantics', async () => { + const source = 'usecase-beta\nactor A("Actor")@{ type: hollow, business: true } <>:::blue\nactor B\nCase["Case"]\nOther("Other")\nA use@-- "Starts" ---> Case\nCase ..> : extend Other\nA --|> B\nnote for A "Keep this"\njson Payload@{ "active": true, "none": null, "value": [1, false] }\nCase --> Payload\nuse@{ animation: fast }\nstyle use stroke:#06c\nclassDef blue fill:#eef\nclass Case blue'; + const db0 = (await mermaid.mermaidAPI.getDiagramFromText(source)).db as any; + const expected = JSON.parse(JSON.stringify({ ...getNativeSummary('usecase', db0), notes: [...db0.getNotes()], json: [...db0.getJsonNodes()], classes: [...db0.getClassDefs()] })); + const db = (await mermaid.mermaidAPI.getDiagramFromText(toMermaidUseCase(fromMermaidUseCase(source)))).db as any; + expect(JSON.parse(JSON.stringify({ ...getNativeSummary('usecase', db), notes: [...db.getNotes()], json: [...db.getJsonNodes()], classes: [...db.getClassDefs()] }))).toEqual(expected); + expect(db.getRelationships()[0]).toMatchObject({ explicitId: true, minlen: 2, animate: true, animation: 'fast', styles: ['stroke:#06c'] }); +}); +it('GitGraph cherry-pick records both copy and current-parent dependencies', async () => { + const source = 'gitGraph\ncommit id: "a"\nbranch feature\ncommit id: "b"\ncheckout main\ncommit id: "c"\ncherry-pick id: "b"'; + const graph = fromMermaidGitGraph(source); const last = graph.nodes.at(-1)!; + expect(graph.edges.filter((edge) => edge.sourceId === last.id).map((edge) => edge.targetId)).toEqual(['c', 'b']); + const db = (await mermaid.mermaidAPI.getDiagramFromText(toMermaidGitGraph(graph))).db as any; + expect(db.getCommitsArray().at(-1)).toMatchObject({ parents: ['c', 'b'], type: 4, tags: ['cherry-pick:b'] }); +}); +it('Sankey CSV grammar retains exact quoted Unicode names, zero and parallel weights', async () => { + const source = 'sankey-beta\n"A,ASCII","B ""quoted"" \\ slash",0\n"A,ASCII","B ""quoted"" \\ slash",3e-2'; + const db = (await mermaid.mermaidAPI.getDiagramFromText(toMermaidSankey(fromMermaidSankey(source)))).db as any; + expect(db.getGraph()).toEqual({ nodes: [{ id: 'A,ASCII' }, { id: 'B "quoted" \\ slash' }], links: [{ source: 'A,ASCII', target: 'B "quoted" \\ slash', value: 0 }, { source: 'A,ASCII', target: 'B "quoted" \\ slash', value: 0.03 }] }); +}); +it.each([ + ['architecture', mermaidArchitectureDocumentConverter, 'db'], ['requirement', mermaidRequirementDocumentConverter, 'secure'], ['c4', mermaidC4DocumentConverter, 'web'], ['usecase', mermaidUseCaseDocumentConverter, 'Login'], +] as const)('%s edited document output remains natively valid with untouched comments', async (kind, codec, id) => { + const document = codec.from(`%% retained\n${fixtures[kind]}\n%% trailing\n`); + document.graph!.nodes.find((node) => node.id === id)!.label = unicodeText; + const result = codec.to(JSON.parse(JSON.stringify(document)) as any); + expect(result.startsWith('%% retained\n')).toBe(true); expect(result.endsWith('%% trailing\n')).toBe(true); + expect(await mermaid.parse(result)).toHaveProperty('diagramType'); +}); +it('Architecture IDs and UseCase numeric IDs remain native-safe without collisions', async () => { + const raw = ['a b', '__stately_id_0061', '1', '東京', 'constructor', '__proto__']; + const source = `architecture-beta\n${raw.map((id) => `service ${escapeMermaidId(id)}[Service]`).join('\n')}`; + const db = (await mermaid.mermaidAPI.getDiagramFromText(toMermaidArchitecture(fromMermaidArchitecture(source)))).db as any; + expect(db.getServices().map((node: any) => node.id)).toEqual(raw.map(escapeMermaidId)); + const usecase = (await mermaid.mermaidAPI.getDiagramFromText('usecase-beta\nactor 1("One")\n3rd("Third")\n1 --> 3rd')).db as any; + expect([...usecase.getActors()].map(([id]: any) => id)).toEqual(['1']); +}); +it('Sankey native whitespace is normalized and Unicode rejection is an explicit upstream control', async () => { + const native = (await mermaid.mermaidAPI.getDiagramFromText('sankey-beta\n" A "," B ", 2 ')).db as any; + expect(native.getGraph().nodes).toEqual([{ id: 'A' }, { id: 'B' }]); + expect(fromMermaidSankey('sankey-beta\n" A "," B ", 2 ').nodes.map((node) => node.id)).toEqual(['A', 'B']); + await expect(mermaid.parse('sankey-beta\n東京,B,1')).rejects.toThrow(); +}); +it('Requirement omitted empty text is native-valid, unlike the upstream empty string token', async () => { + await expect(mermaid.parse('requirementDiagram\nrequirement a {\ntext: ""\n}')).rejects.toThrow(); + const graph = fromMermaidRequirement('requirementDiagram\nrequirement a {\n}'); + const db = (await mermaid.mermaidAPI.getDiagramFromText(toMermaidRequirement(graph))).db as any; + expect(db.getRequirements().get('a').text).toBe(''); +}); +it('GitGraph native fresh and modified mainBranchName follow the live graph field', async () => { + const graph = fromMermaidGitGraph('%%{init: {"gitGraph":{"mainBranchName":"trunk"}}}%%\ngitGraph\ncommit id: "a"'); + graph.data.mainBranch = 'live'; graph.nodes[0].data.branch = 'live'; + const generated = toMermaidGitGraph(graph); + const config = (await mermaid.parse(generated)) as { config: any }; + expect(config.config.gitGraph.mainBranchName).toBe('live'); + // getDiagramFromText strips directives without applying configuration; initialize + // from the native parser's extracted config before inspecting its database. + mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', ...config.config }); + let db = (await mermaid.mermaidAPI.getDiagramFromText(generated)).db as any; + expect(db.getCommits().get('a').branch).toBe('live'); + graph.data = { diagramType: 'gitGraph', mainBranch: 'fresh', actions: [{ kind: 'commit', nodeId: 'a' }] }; graph.nodes[0].data.branch = 'fresh'; + const fresh = toMermaidGitGraph(graph); + const freshConfig = (await mermaid.parse(fresh)) as { config: any }; + expect(freshConfig.config.gitGraph.mainBranchName).toBe('fresh'); + mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', ...freshConfig.config }); + db = (await mermaid.mermaidAPI.getDiagramFromText(fresh)).db as any; + expect(db.getBranchesAsObjArray()).toEqual([{ name: 'fresh' }]); +}); +it('native architecture and UseCase empty-ID aliases preserve group membership', async () => { + const id = escapeMermaidId(''); + const architecture = fromMermaidArchitecture(`architecture-beta\ngroup ${id}[G]\nservice c[C] in ${id}`); + const db = (await mermaid.mermaidAPI.getDiagramFromText(toMermaidArchitecture(architecture))).db as any; + expect(db.getGroups()[0].id).toBe(id); expect(db.getServices()[0].in).toBe(id); + const usecase = fromMermaidUseCase(`usecase-beta\nsystemBoundary ${id}[G]\nC[C]\nend`); + const useDb = (await mermaid.mermaidAPI.getDiagramFromText(toMermaidUseCase(usecase))).db as any; + expect(useDb.getUseCases().get('C').parentId).toBe(id); +}); +it('native bulk/deep profiles preserve service counts and deployment hierarchy', async () => { + const architecture = `architecture-beta\n${Array.from({ length: 2000 }, (_, index) => `service n${index}[Node]`).join('\n')}`; + const db = (await mermaid.mermaidAPI.getDiagramFromText(toMermaidArchitecture(fromMermaidArchitecture(architecture)))).db as any; + expect(db.getServices()).toHaveLength(2000); + const c4 = `C4Deployment\n${Array.from({ length: 100 }, (_, index) => `Deployment_Node(g${index}, "Group") {`).join('\n')}\nContainer(leaf, "Leaf", "JS")\n${Array.from({ length: 100 }, () => '}').join('\n')}`; + const c4Db = (await mermaid.mermaidAPI.getDiagramFromText(toMermaidC4(fromMermaidC4(c4)))).db as any; + expect(c4Db.getC4Shape('leaf')).toMatchObject({ parentBoundary: 'g99' }); +}); +it.each(['architecture', 'c4', 'usecase'])('%s native empty/null labels have an explicit valid output or rejection', async (kind) => { + const graph = codecs[kind].from(fixtures[kind as keyof typeof fixtures]); + graph.nodes[0].label = ''; + if (kind === 'architecture') expect(() => codecs[kind].to(graph)).toThrow('empty labels'); + else expect(await mermaid.parse(codecs[kind].to(graph))).toHaveProperty('diagramType'); + graph.nodes[0].label = null; + expect(await mermaid.parse(codecs[kind].to(graph))).toHaveProperty('diagramType'); +}); + +it('Architecture preserves native bracket whitespace and literal quotes', async () => { + const source = 'architecture-beta\nservice a[ A ]\nservice b["B"]'; + const db = (await mermaid.mermaidAPI.getDiagramFromText(source)).db as any; + expect(fromMermaidArchitecture(source).nodes.map((node) => node.label)).toEqual(db.getServices().map((node: any) => node.title)); +}); +it('native numeric display entities replace NUL and lone surrogates instead of preserving them', async () => { + const nul = (await mermaid.mermaidAPI.getDiagramFromText('flowchart TD\nA["#0;"]')).db as any; + expect(await getNativeMermaidText(nul.getVertices().get('A').text)).toBe('\ufffd'); + const surrogate = (await mermaid.mermaidAPI.getDiagramFromText('flowchart TD\nA["#55296;"]')).db as any; + expect(await getNativeMermaidText(surrogate.getVertices().get('A').text)).toBe('\ufffd'); + expect(await getNativeMermaidText('Astral 😀')).toBe('Astral 😀'); + const graph = fromMermaidArchitecture(fixtures.architecture); + for (const invalid of ['NUL\0value', 'A\ud800B']) { graph.nodes[0].label = invalid; expect(() => toMermaidArchitecture(graph)).toThrow('display'); } +}); + +it('C4 native reverse relation uses the start marker and canonical reverse Graph endpoints', async () => { + const source = 'C4Context\nPerson(a,"A")\nPerson(b,"B")\nPerson(c,"C")\nRel_Back(a,b,"Back")\nRel_U(a,c,"Up")'; + const graph = fromMermaidC4(source); + expect(graph.edges.map(({ sourceId, targetId }) => [sourceId, targetId])).toEqual([['b', 'a'], ['a', 'c']]); + const db = (await mermaid.mermaidAPI.getDiagramFromText(toMermaidC4(graph))).db as any; + expect(db.getRels()).toMatchObject([{ from: 'a', to: 'b', type: 'rel_b' }, { from: 'a', to: 'c', type: 'rel_u' }]); + // Characterize the pinned renderer itself: its reverse relation lacks the end marker. + const { createRequire } = await import('node:module'); + const { dirname, join } = await import('node:path'); + const { readdirSync, readFileSync } = await import('node:fs'); + const directory = join(dirname(createRequire(import.meta.url).resolve('mermaid')), 'chunks/mermaid.core'); + const renderer = readFileSync(join(directory, readdirSync(directory).find((file) => /^c4Diagram-.*\.mjs$/.test(file))!), 'utf8'); + expect(renderer).toMatch(/if \(rel\.type !== "rel_b"\) \{\s*line\.attr\("marker-end"/); + expect(renderer).toMatch(/if \(rel\.type === "birel" \|\| rel\.type === "rel_b"\) \{\s*line\.attr\("marker-start"/); +}); diff --git a/tests/native/mermaid.test.ts b/tests/native/mermaid.test.ts new file mode 100644 index 0000000..f805ef2 --- /dev/null +++ b/tests/native/mermaid.test.ts @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +import { readFileSync, readdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import mermaid from 'mermaid'; +import corpus from './fixtures/mermaid-profiles.json'; +import packageJSON from '../../package.json'; +import { mermaidFlowchartConverter, mermaidSequenceConverter, mermaidStateConverter, mermaidClassConverter, mermaidERConverter, mermaidBlockConverter, mermaidMindmapConverter, mermaidIshikawaConverter, mermaidFlowchartDocumentConverter } from '../../src/formats/mermaid'; +import type { GraphFormatConverter } from '../../src/types'; + +const converters: Record> = { + flowchart: mermaidFlowchartConverter, sequence: mermaidSequenceConverter, state: mermaidStateConverter, + class: mermaidClassConverter, er: mermaidERConverter, block: mermaidBlockConverter, + mindmap: mermaidMindmapConverter, ishikawa: mermaidIshikawaConverter, +}; +mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' }); + +async function getNativeMermaidDecodedText(text: string): Promise { + // Use the pinned native bundle's own entity decoder, then browser HTML decoding. + const require = createRequire(import.meta.url); + const directory = join(dirname(require.resolve('mermaid')), 'chunks/mermaid.core'); + const matches = readdirSync(directory).filter((file) => file.endsWith('.mjs') && readFileSync(join(directory, file), 'utf8').includes('var decodeEntities =')); + if (matches.length !== 1) throw new Error('Pinned Mermaid entity decoder changed; review the oracle integration'); + const native = await import(pathToFileURL(join(directory, matches[0])).href); + const element = document.createElement('textarea'); + element.innerHTML = native.decodeEntities(text); + return element.value; +} + + +describe(`native Mermaid ${corpus.oracle}`, () => { + it('uses the exact oracle version recorded by the fixture corpus', () => { + expect(corpus.oracle).toBe(`mermaid@${packageJSON.devDependencies['mermaid']}`); + }); + + it.each(corpus.fixtures)('$format validates independently authored and emitted profile syntax', async (fixture) => { + expect(await mermaid.parse(fixture.source)).toHaveProperty('diagramType'); + const graph = converters[fixture.format].from(fixture.source); + expect(graph.nodes.length).toBeGreaterThan(0); + const emitted = converters[fixture.format].to(graph); + expect(await mermaid.parse(emitted)).toHaveProperty('diagramType'); + }); + + + it.each(corpus.fixtures)('$format preserves selected native database semantics after emit', async (fixture) => { + const emitted = converters[fixture.format].to(converters[fixture.format].from(fixture.source)); + // Pinned native parser databases are the independent semantic oracle. + const db = (await mermaid.mermaidAPI.getDiagramFromText(emitted)).db as any; + switch (fixture.format) { + case 'flowchart': + expect(db.getVertices().get('A').text).toBe('Start 東京'); + expect(db.getVertices().get('C').type).toBe('cloud'); + expect(db.getVertices().get('Isolated').text).toBe('No edges'); + expect(db.getEdges().map((edge: any) => [edge.start, edge.end, edge.stroke])).toEqual([['A', 'B', 'normal'], ['B', 'C', 'dotted']]); + break; + case 'sequence': + expect([...db.getActors()].map(([id, actor]: any) => [id, actor.description, actor.type])).toEqual([['A', 'Client', 'participant'], ['B', 'Store', 'database']]); + expect(db.getMessages().map((message: any) => [message.type, message.from, message.to, message.message])).toEqual([ + [26, undefined, undefined, { start: 10, step: 5, visible: true }], + [0, 'A', 'B', 'Save 東京'], [2, 'A', 'B', 'Keep this note'], [1, 'B', 'A', 'Saved'], + ]); + break; + case 'class': + expect(db.getClasses().get('Customer').parent).toBe('Domain'); + expect(db.getClasses().get('Customer').members.map((member: any) => [member.id, member.visibility])).toEqual([['String name', '+']]); + expect(db.getClasses().get('Customer').methods.map((method: any) => [method.id, method.visibility])).toEqual([['save', '+']]); + expect(db.getRelations()).toMatchObject([{ id1: 'Customer', id2: 'Order', relationTitle1: '1', relationTitle2: 'many', title: 'places' }]); + break; + case 'er': + expect([...db.getEntities().keys()]).toEqual(['CUSTOMER', 'ORDER', 'ISOLATED']); + expect(db.getEntities().get('CUSTOMER').attributes).toEqual([{ type: 'string', name: 'id', keys: ['PK', 'UK'], comment: 'Identity' }, { type: 'string', name: 'name', keys: [], comment: '' }]); + expect(db.getRelationships()).toMatchObject([{ roleA: 'places', relSpec: { cardA: 'ZERO_OR_MORE', cardB: 'ONLY_ONE', relType: 'IDENTIFYING' } }]); + break; + case 'state': + expect(db.getStates().get('Idle').descriptions).toEqual(['Idle 東京']); + expect(db.getRelations()).toContainEqual(expect.objectContaining({ id1: 'Idle', id2: 'Running', relationTitle: 'start' })); + expect(db.getRootDocV2().doc.find((entry: any) => entry.id === 'Group').doc).toContainEqual(expect.objectContaining({ stmt: 'relation', description: 'nested', state1: expect.objectContaining({ id: 'Inner1' }), state2: expect.objectContaining({ id: 'Inner2' }) })); + break; + case 'block': + expect(db.getColumns('root')).toBe(3); + expect(db.getColumns('group')).toBe(2); + expect(db.getBlock('B')).toMatchObject({ label: 'Second block', type: 'circle' }); + expect(db.getBlock('D')).toMatchObject({ label: 'Nested one' }); + expect(db.getEdges()).toMatchObject([{ start: 'A', end: 'B' }]); + break; + case 'mindmap': + expect(db.getMindmap()).toMatchObject({ nodeId: 'root', descr: 'Root 東京', type: 3, children: [{ nodeId: 'a', descr: 'First child', type: 2, children: [{ descr: 'Deep child' }] }, { nodeId: 'b', descr: 'Second child', type: 1 }] }); + break; + case 'ishikawa': + expect(db.getRoot()).toEqual({ text: 'Effect 東京', children: [{ text: 'People', children: [{ text: 'Training', children: [] }] }, { text: 'Process', children: [{ text: 'Review', children: [] }] }] }); + break; + } + }); + it('native flow database retains labels, parallel edges and isolated nodes', async () => { + const source = 'flowchart LR\nA["東京 #quot;quoted#quot;"]\nB[Target]\nIsolated[Alone]\nA --> B\nA -.-> B\n'; + const emitted = mermaidFlowchartConverter.to(mermaidFlowchartConverter.from(source)); + const diagram = await mermaid.mermaidAPI.getDiagramFromText(emitted); + const db = diagram.db as { getVertices(): Map; getEdges(): Array<{ start: string; end: string; stroke: string }> }; + expect(db.getVertices().size).toBe(3); + expect(db.getVertices().get('A')?.text).toContain('東京'); + expect(db.getEdges().map((edge) => [edge.start, edge.end])).toEqual([['A', 'B'], ['A', 'B']]); + expect(db.getEdges()[1].stroke).toBe('dotted'); + }); + + it('native parser validates edited document output while no-op preserves unsupported annotations exactly', async () => { + const source = '---\ntitle: Native\n---\n%% retained\nflowchart TD\nA[Before]\n%% trailing\n'; + expect(await mermaid.parse(source)).toHaveProperty('diagramType'); + const document = mermaidFlowchartDocumentConverter.from(source); + expect(mermaidFlowchartDocumentConverter.to(JSON.parse(JSON.stringify(document)))).toBe(source); + document.graph!.nodes[0].label = 'After 東京 "quoted" \\'; + const patched = mermaidFlowchartDocumentConverter.to(document); + expect(patched).toContain('%% trailing'); + expect(await mermaid.parse(patched)).toHaveProperty('diagramType'); + }); + + + it('native entity decoding preserves exact Unicode, quotes and literal backslash counts', async () => { + const label = '東京 "quoted" \\ \\\\ # ; | < >'; + const source = 'flowchart TD\nA[Before]'; + const graph = mermaidFlowchartConverter.from(source); + graph.nodes[0].label = label; + const diagram = await mermaid.mermaidAPI.getDiagramFromText(mermaidFlowchartConverter.to(graph)); + const db = diagram.db as { getVertices(): Map }; + expect(await getNativeMermaidDecodedText(db.getVertices().get('A')!.text)).toBe(label); + }); + + + it('preserves native literal backslashes without interpreting them as a private escape convention', async () => { + const source = 'flowchart TD\nA["Path\\\\"]'; + const native = await mermaid.mermaidAPI.getDiagramFromText(source); + const before = await getNativeMermaidDecodedText((native.db as any).getVertices().get('A').text); + const graph = mermaidFlowchartConverter.from(source); + expect(graph.nodes[0].label).toBe(before); + const emitted = await mermaid.mermaidAPI.getDiagramFromText(mermaidFlowchartConverter.to(graph)); + expect(await getNativeMermaidDecodedText((emitted.db as any).getVertices().get('A').text)).toBe(before); + }); + + it('keeps adjacent native block labels ending in literal backslashes distinct', async () => { + const source = 'block-beta\nA["Path\\"] B["Second"]'; + const before = (await mermaid.mermaidAPI.getDiagramFromText(source)).db as any; + expect(before.getBlocks()).toHaveLength(2); + const graph = mermaidBlockConverter.from(source); + expect(graph.nodes.map((node) => node.label)).toEqual(before.getBlocks().map((block: any) => block.label)); + const after = (await mermaid.mermaidAPI.getDiagramFromText(mermaidBlockConverter.to(graph))).db as any; + expect(await getNativeMermaidDecodedText(after.getBlock('A').label)).toBe('Path\\'); + expect(after.getBlock('B').label).toBe('Second'); + }); + it('native parser handles a larger chain and bounded deeply nested tree profile', async () => { + const chain = `flowchart LR\n${Array.from({ length: 250 }, (_, index) => `n${index} --> n${index + 1}`).join('\n')}`; + const flow = await mermaid.mermaidAPI.getDiagramFromText(mermaidFlowchartConverter.to(mermaidFlowchartConverter.from(chain))); + expect((flow.db as any).getVertices().size).toBe(251); + expect((flow.db as any).getEdges()).toHaveLength(250); + const tree = `mindmap\n${Array.from({ length: 50 }, (_, index) => `${' '.repeat(index + 1)}Level ${index}`).join('\n')}`; + const native = await mermaid.mermaidAPI.getDiagramFromText(mermaidMindmapConverter.to(mermaidMindmapConverter.from(tree))); + let node = (native.db as any).getMindmap(); + let count = 0; + while (node) { count++; node = node.children[0]; } + expect(count).toBe(50); + }); + it('the oracle rejects malformed syntax rather than accepting the converter output blindly', async () => { + await expect(mermaid.parse('flowchart TD\nA[unterminated')).rejects.toThrow(); + await expect(mermaid.parse('sequenceDiagram\nA->>')).rejects.toThrow(); + }); +}); diff --git a/tests/native/networkx-oracle.ts b/tests/native/networkx-oracle.ts new file mode 100644 index 0000000..c75d56a --- /dev/null +++ b/tests/native/networkx-oracle.ts @@ -0,0 +1,31 @@ +import { execFile } from 'node:child_process'; +import { resolve } from 'node:path'; + +export interface NetworkXOracleRequest { + operation: 'version' | 'node-link' | 'gml' | 'pajek'; + data?: unknown; + source?: string; + options?: { source?: string; target?: string; name?: string; key?: string; edges?: string; nodes?: string; label?: string | null }; +} +export interface NetworkXOracleResult { + oracle: 'networkx'; version: string; python: string; + summary?: { directed: boolean; multigraph: boolean; graph: Record; + nodes: Array<{ id: string | number | boolean; attributes: Record }>; + edges: Array<{ source: string | number | boolean; target: string | number | boolean; key?: string | number | boolean; attributes: Record }> }; + roundTrip?: Record; +} + +/** Actual pinned Python NetworkX, with bounded process lifetime and output size. */ +export async function getNetworkXOracle(request: NetworkXOracleRequest): Promise { + const python = process.env.GRAPH_NATIVE_PYTHON ?? resolve('.venv-native/bin/python'); + const result = await new Promise((resolveResult, reject) => { + const child = execFile(python, [resolve('scripts/native/networkx_oracle.py')], { timeout: 10_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error) reject(new Error(`NetworkX native oracle failed: ${stderr.trim() || error.message}`)); + else resolveResult(stdout); + }); + child.stdin!.end(JSON.stringify(request)); + }); + const parsed = JSON.parse(result) as NetworkXOracleResult; + if (parsed.oracle !== 'networkx' || parsed.version !== '3.7') throw new Error('NetworkX native oracle version mismatch'); + return parsed; +} diff --git a/tests/native/networkx.test.ts b/tests/native/networkx.test.ts new file mode 100644 index 0000000..2acef8d --- /dev/null +++ b/tests/native/networkx.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { fromNetworkXNodeLink, toNetworkXNodeLink, networkxDocumentConverter, type NetworkXNodeLinkJSON } from '../../src/formats/networkx'; +import { getNetworkXOracle } from './networkx-oracle'; +import { getCarrierData } from '../../src/formats/preservation'; + +function getFixture(name: string): NetworkXNodeLinkJSON { + return JSON.parse(readFileSync(new URL(`./fixtures/networkx/${name}.json`, import.meta.url), 'utf8')); +} + +describe('NetworkX3.7 actual Python node-link oracle', () => { + it('pins the authored fixture and actual Python output bytes', () => { + const provenance = JSON.parse(readFileSync(new URL('./fixtures/networkx/provenance.json', import.meta.url), 'utf8')); + for (const file of provenance.files) expect(createHash('sha256').update(readFileSync(new URL(`./fixtures/networkx/${file.file}`, import.meta.url))).digest('hex')).toBe(file.sha256); + }); + it('reads freshly emitted rich directed multigraph data', async () => { + const graph = createGraph({ id: 'G', data: { root: [null, true] }, nodes: [ + { id: 'a', label: 'A & α', data: { custom: { keep: 1 } } }, { id: 'b', label: '' }, { id: 'alone', label: null }], + edges: [{ id: 'first', sourceId: 'a', targetId: 'b', weight: 2.5 }, { id: 'parallel', sourceId: 'a', targetId: 'b', label: 'second' }, { id: 'loop', sourceId: 'a', targetId: 'a', weight: 0 }] }); + const emitted = toNetworkXNodeLink(graph); + const native = await getNetworkXOracle({ operation: 'node-link', data: emitted }); + expect(native.summary).toMatchObject({ directed: true, multigraph: true, nodes: [ + { id: 'a', attributes: { label: 'A & α' } }, { id: 'b', attributes: { label: '' } }, { id: 'alone' }], + edges: [{ source: 'a', target: 'b', key: 'first', attributes: { weight: 2.5 } }, { source: 'a', target: 'b', key: 'parallel' }, { source: 'a', target: 'a', key: 'loop', attributes: { weight: 0 } }] }); + const restored = fromNetworkXNodeLink(native.roundTrip as unknown as NetworkXNodeLinkJSON); + expect({ ...restored, data: getCarrierData(restored.data, 'networkx') }).toEqual(graph); + }); + it.each(['multidigraph', 'simple-links'])('preserves authored %s native IDs, attributes and endpoint-scoped keys', async (name) => { + const fixture = getFixture(name); + const edgeKey = fixture.links ? 'links' : 'edges'; + const before = await getNetworkXOracle({ operation: 'node-link', data: fixture, options: { edges: edgeKey } }); + const captured = JSON.parse(readFileSync(new URL(`./fixtures/networkx/${name}.native.json`, import.meta.url), 'utf8')); + expect(before.summary).toEqual(captured.summary); + expect(before.roundTrip).toEqual(captured.roundTrip); + const projected = fromNetworkXNodeLink(fixture); + expect(projected.nodes).toHaveLength(fixture.nodes.length); + expect(projected.edges).toHaveLength((fixture[edgeKey])!.length); + const after = await getNetworkXOracle({ operation: 'node-link', data: toNetworkXNodeLink(projected, { edgeKey }), options: { edges: edgeKey } }); + expect(after.summary).toEqual(before.summary); + const document = JSON.parse(JSON.stringify(networkxDocumentConverter.from(fixture))); + expect(networkxDocumentConverter.to(document)).toEqual(fixture); + }); + it('keeps reverse endpoint pairs distinct only in directed graphs and retains loops', async () => { + const fixture: NetworkXNodeLinkJSON = { directed: true, multigraph: true, graph: {}, nodes: [{ id: 'a' }, { id: 'b' }], edges: [ + { source: 'a', target: 'b', key: 0 }, { source: 'b', target: 'a', key: 0 }, { source: 'a', target: 'a', key: 0 }] }; + expect((await getNetworkXOracle({ operation: 'node-link', data: fixture })).summary!.edges).toHaveLength(3); + expect(fromNetworkXNodeLink(fixture).edges).toHaveLength(3); + const undirected = { ...fixture, directed: false }; + expect((await getNetworkXOracle({ operation: 'node-link', data: undirected })).summary!.edges).toHaveLength(2); + expect(() => fromNetworkXNodeLink(undirected)).toThrow(/duplicate/); + }); + it('rejects Python-equivalent bool/numeric duplicate nodes rather than silently merging', async () => { + const fixture: NetworkXNodeLinkJSON = { directed: false, multigraph: true, graph: {}, nodes: [{ id: true, first: 1 }, { id: 1, second: 2 }], edges: [] }; + const native = await getNetworkXOracle({ operation: 'node-link', data: fixture }); + expect(native.summary!.nodes).toEqual([{ id: true, attributes: { first: 1, second: 2 } }]); + expect(() => fromNetworkXNodeLink(fixture)).toThrow(/Python-equivalent/); + }); + it('rejects equivalent multigraph keys which native Python merges', async () => { + const fixture: NetworkXNodeLinkJSON = { directed: true, multigraph: true, graph: {}, nodes: [{ id: 'a' }, { id: 'b' }], edges: [ + { source: 'a', target: 'b', key: false, weight: 1 }, { source: 'a', target: 'b', key: 0, weight: 2 }] }; + expect((await getNetworkXOracle({ operation: 'node-link', data: fixture })).summary!.edges).toEqual([{ source: 'a', target: 'b', key: false, attributes: { weight: 2 } }]); + expect(() => fromNetworkXNodeLink(fixture)).toThrow(/duplicate/); + }); + it('uses native missing-key allocation including collisions and per-pair dictionaries', async () => { + const fixture: NetworkXNodeLinkJSON = { directed: false, multigraph: true, graph: {}, nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], edges: [ + { source: 'a', target: 'b', key: 1 }, { source: 'b', target: 'a' }, { source: 'a', target: 'c' }] }; + const native = await getNetworkXOracle({ operation: 'node-link', data: fixture }); + expect(native.summary!.edges.map((edge) => edge.key)).toEqual([1, 2, 0]); + const emitted = toNetworkXNodeLink(fromNetworkXNodeLink(fixture)); + expect((await getNetworkXOracle({ operation: 'node-link', data: emitted })).summary).toEqual(native.summary); + }); +}); diff --git a/tests/native/pajek-oracle.test.ts b/tests/native/pajek-oracle.test.ts new file mode 100644 index 0000000..31e25d6 --- /dev/null +++ b/tests/native/pajek-oracle.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { getNetworkXOracle } from './networkx-oracle'; + +describe('Pajek future-adapter independent reader boundary', () => { + it('pins the uniform fixture and native output; reads labels, coordinates, parallel arcs and weights', async () => { + const directory = new URL('./fixtures/pajek/', import.meta.url); + const provenance = JSON.parse(readFileSync(new URL('provenance.json', directory), 'utf8')); + for (const file of provenance.files) expect(createHash('sha256').update(readFileSync(new URL(file.file, directory))).digest('hex')).toBe(file.sha256); + const source = readFileSync(new URL('uniform.net', directory), 'utf8'); + const native = await getNetworkXOracle({ operation: 'pajek', source }); + const captured = JSON.parse(readFileSync(new URL('uniform.native.json', directory), 'utf8')); + expect(native.summary).toEqual(captured.summary); + expect(native.summary).toMatchObject({ directed: true, multigraph: true, graph: { name: 'Native fixture' }, + nodes: [{ id: 'Alpha & α', attributes: { id: '1', x: 0.1, y: 0.2, shape: 'ellipse', ic: 'Red' } }, { id: '', attributes: { id: '2' } }, { id: 'isolated' }], + edges: [{ source: 'Alpha & α', target: '', attributes: { weight: 2.5, l: 'edge' } }, { attributes: { weight: -3 } }, { source: 'Alpha & α', target: 'Alpha & α', attributes: { weight: 0 } }] }); + }); + it('shows why NetworkX alone cannot certify distinct native vertices sharing a label', async () => { + const native = await getNetworkXOracle({ operation: 'pajek', source: '*Vertices 2\n1 "same"\n2 "same"\n*Edges\n1 2 1\n' }); + expect(native.summary!.nodes).toEqual([{ id: 'same', attributes: { id: '2' } }]); + expect(native.summary!.edges).toEqual([{ source: 'same', target: 'same', key: 0, attributes: { weight: 1 } }]); + }); +}); diff --git a/tests/native/plantuml-oracle.ts b/tests/native/plantuml-oracle.ts new file mode 100644 index 0000000..760c7a6 --- /dev/null +++ b/tests/native/plantuml-oracle.ts @@ -0,0 +1,27 @@ +import { execFile } from 'node:child_process'; +import { resolve, join } from 'node:path'; +export async function getPlantUMLOracle(source: string, format: 'svg' | 'xmi' | 'xmi:star' = 'svg'): Promise { + const java = process.env.GRAPH_NATIVE_JAVA ?? (process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin/java') : resolve('.tools-native/java/bin/java')); + const jar = process.env.GRAPH_NATIVE_PLANTUML ?? resolve('.tools-native/plantuml.jar'); + return new Promise((resolveResult, reject) => { + const child = execFile(java, ['-Djava.awt.headless=true', '-jar', jar, format === 'xmi:star' ? '--xmi:star' : `-t${format}`, '-pipe'], { timeout: 20_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error || /(?:^|\n)ERROR(?:\n|$)/.test(stderr) || stdout.includes('Syntax Error?')) reject(new Error(`PlantUML native oracle failed: ${stderr.trim() || (error ? `${error.message}; killed=${error.killed}; signal=${error.signal}` : 'native syntax error')}`)); + else resolveResult(stdout); + }); child.stdin!.end(source); + }); +} + +export async function getPlantUMLOracleVersion(): Promise<{ compiler: string; compilerExitCode: number; java: string }> { + const java = process.env.GRAPH_NATIVE_JAVA ?? (process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin/java') : resolve('.tools-native/java/bin/java')); + const jar = process.env.GRAPH_NATIVE_PLANTUML ?? resolve('.tools-native/plantuml.jar'); + const compiler = await new Promise<{ text: string; exitCode: number }>((resolveResult, reject) => { + const child = execFile(java, ['-Djava.awt.headless=true', '-jar', jar, '--version'], { timeout: 10_000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { + if (error && (error.killed || typeof error.code !== 'number')) reject(new Error(`${stderr || error.message}; killed=${error.killed}; signal=${error.signal}`)); + else resolveResult({ text: stdout + stderr, exitCode: error?.code as number ?? 0 }); + }); child.stdin!.end(); + }); + const javaVersion = await new Promise((resolveResult, reject) => { + const child = execFile(java, ['-version'], { timeout: 10_000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => error ? reject(new Error(stderr || error.message)) : resolveResult(stdout + stderr)); child.stdin!.end(); + }); + return { compiler: compiler.text, compilerExitCode: compiler.exitCode, java: javaVersion }; +} diff --git a/tests/native/plantuml.test.ts b/tests/native/plantuml.test.ts new file mode 100644 index 0000000..a088c9a --- /dev/null +++ b/tests/native/plantuml.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { createGraph } from '../../src/graph'; +import { toPlantUML, fromPlantUML, createPlantUMLDocumentConverter } from '../../src/formats/plantuml'; +import { getPlantUMLOracle, getPlantUMLOracleVersion } from './plantuml-oracle'; +function getRectDistance(x: number, y: number, rectangle: Element): number { + const left = Number(rectangle.getAttribute('x')), top = Number(rectangle.getAttribute('y')), right = left + Number(rectangle.getAttribute('width')), bottom = top + Number(rectangle.getAttribute('height')); + return Math.hypot(Math.max(left - x, 0, x - right), Math.max(top - y, 0, y - bottom)); +} +function getSVG(source: string): Document { return new JSDOM(source, { contentType: 'image/svg+xml' }).window.document; } +describe('official PlantUML 1.2026.8 isolated Java21 native semantics', () => { + it('runtime overrides must retain the pinned compiler and Java major', async () => { + const version = await getPlantUMLOracleVersion(); expect(version.compiler).toContain('PlantUML version 1.2026.8'); expect(version.java).toMatch(/version "21[.\"]/); + expect([0, 16]).toContain(version.compilerExitCode); if (version.compilerExitCode === 16) expect(version.compiler).toContain('Error: Dot executable does not exist'); + }); + it.each(['component', 'class'] as const)('native %s reader observes qualified aliases, hierarchy, links, labels and stereo', async (dialect) => { + const graph = createGraph({ nodes: [{ id: 'group', label: 'Folder' }, { id: 'a', parentId: 'group', label: 'Alpha', data: dialect === 'class' ? { members: ['+x: int', '+f(): void'], stereotypes: 'entity' } : { stereotypes: 'service' }, color: 'red' }, { id: 'b', label: 'Beta' }, { id: 'isolated', label: 'Isolated' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', label: 'Calls' }] }); + const source = toPlantUML(graph, { dialect }), svg = getSVG(await getPlantUMLOracle(source)); + expect(svg.documentElement.getAttribute('data-diagram-type')).toBe(dialect === 'class' ? 'CLASS' : 'DESCRIPTION'); + const text = [...svg.querySelectorAll('text')].map((node) => node.textContent); expect(text).toContain('Folder'); expect(text).toContain('Alpha'); expect(text).toContain('Beta'); expect(text).toContain('Isolated'); expect(text).toContain('Calls'); if (dialect === 'class') expect(text.some((value) => value?.includes('x: int'))).toBe(true); + const entities = [...svg.querySelectorAll('g.entity')]; expect(entities).toHaveLength(3); const byName = new Map(entities.map((entity) => [entity.getAttribute('data-qualified-name'), entity.id])); + expect([...byName.keys()].some((name) => name?.endsWith('g1'))).toBe(true); const link = svg.querySelector('g.link')!; expect(link).not.toBeNull(); expect(link.getAttribute('data-entity-1')).toBe([...byName].find(([name]) => name?.endsWith('g1'))![1]); expect(link.getAttribute('data-entity-2')).toBe(byName.get('g2')); + const converter = createPlantUMLDocumentConverter({ dialect }), doc = converter.from(source); doc.graph!.nodes[1].label = 'Changed'; const changed = getSVG(await getPlantUMLOracle(converter.to(doc))); expect([...changed.querySelectorAll('text')].map((node) => node.textContent)).toContain('Changed'); + }); + it('native SVG validates literal quotes/backslashes/markup/LF encoding rather than JSON assumptions', async () => { + const labels = ['Quote " value', 'Path \\ folder', '**literal** "', 'First\nSecond', '雪🙂']; const source = toPlantUML(createGraph({ nodes: labels.map((label, index) => ({ id: `n${index}`, label })) }), { dialect: 'class' }); + const svg = getSVG(await getPlantUMLOracle(source)), text = [...svg.querySelectorAll('text')].map((node) => node.textContent); + for (const label of labels.filter((label) => !label.includes('\n'))) expect(text).toContain(label); expect(text).toContain('First'); expect(text).toContain('Second'); + const literal = '@startuml\n!pragma layout smetana\nclass "Quote \\" value" as a\n@enduml\n'; const visible = [...getSVG(await getPlantUMLOracle(literal)).querySelectorAll('text')].map((node) => node.textContent); expect(visible).toContain(fromPlantUML(literal).nodes[0].label); + }); + it.each(['a "1" <-- b', 'a <-- "many" b'])('native SVG preserves one-sided reverse multiplicity and direction: %s', async (link) => { + const source = `@startuml\n!pragma layout smetana\nclass "A" as a\nclass "B" as b\n${link}\n@enduml\n`, graph = fromPlantUML(source); expect(graph.edges[0].sourceId).toBe('b'); expect(graph.edges[0].targetId).toBe('a'); + const svg = getSVG(await getPlantUMLOracle(source)); const text = [...svg.querySelectorAll('text')].map((node) => node.textContent); expect(text).toContain(link.includes('"1"') ? '1' : 'many'); const polygon = svg.querySelector('g.link polygon')!; expect(polygon).not.toBeNull(); + const [x, y] = polygon.getAttribute('points')!.split(/[, ]/).map(Number), target = svg.querySelector('g.entity[data-qualified-name="a"] rect')!, origin = svg.querySelector('g.entity[data-qualified-name="b"] rect')!; expect(getRectDistance(x, y, target)).toBeLessThan(getRectDistance(x, y, origin)); + }); + it('native class XMI contains actual class/member and relationship model, independent of sidecars', async () => { + const source = '@startuml\n!pragma layout smetana\nclass "A" as a {\n+x: int\n+f(): void\n}\nclass "B" as b\na --|> b\n@enduml\n'; + const basic = await getPlantUMLOracle(source, 'xmi'); expect(basic).not.toContain('UML:Generalization'); + const xmi = await getPlantUMLOracle(source, 'xmi:star'), xml = new JSDOM(xmi, { contentType: 'text/xml' }).window.document; + const names = [...xml.querySelectorAll('[name]')].map((node) => node.getAttribute('name')); expect(names).toContain('A'); expect(names).toContain('B'); expect(names.some((name) => name?.includes('x'))).toBe(true); + expect([...xml.getElementsByTagName('*')].some((node) => node.localName === 'Generalization')).toBe(true); expect(xml.querySelector('parsererror')).toBeNull(); + }); + it('native display does not decode JSON unicode escapes for NUL/lone surrogates; emission rejects them', async () => { + const source = '@startuml\nclass "NUL \\u0000" as a\nclass "Surrogate \\ud800" as b\n@enduml\n'; + const text = [...getSVG(await getPlantUMLOracle(source)).querySelectorAll('text')].map((node) => node.textContent); + expect(text).toContain('NUL \\u0000'); expect(text).toContain('Surrogate \\ud800'); + for (const label of ['NUL \0', 'Surrogate \ud800', 'CR\r', 'TAB\t', 'DEL\u007f']) expect(() => toPlantUML(createGraph({ nodes: [{ id: 'a', label }] }))).toThrow('display'); + }); + it('official reader rejects empty quoted class names; emitter rejects before native execution', async () => { + await expect(getPlantUMLOracle('@startuml\nclass "" as a\n@enduml\n')).rejects.toThrow(); expect(() => toPlantUML(createGraph({ nodes: [{ id: 'a', label: '' }] }), { dialect: 'class' })).toThrow('empty'); + }); + it('native link labels display literal markup, quotes and backslashes before/after strict patches', async () => { + const quoted = '@startuml\n!pragma layout smetana\ncomponent A\ncomponent B\nA --> B : "Quoted"\n@enduml\n'; expect(fromPlantUML(quoted).edges[0].label).toBe('Quoted'); + const graph = createGraph({ nodes: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', label: 'Literal **bold** " \\ &' }] }); + const source = toPlantUML(graph), text = [...getSVG(await getPlantUMLOracle(source)).querySelectorAll('text')].map((node) => node.textContent); expect(text).toContain(graph.edges[0].label); + const converter = createPlantUMLDocumentConverter(), doc = converter.from(source); doc.graph!.edges[0].label = 'literal ~link'; + expect([...getSVG(await getPlantUMLOracle(converter.to(doc))).querySelectorAll('text')].map((node) => node.textContent)).toContain(doc.graph!.edges[0].label); + }); + +}); diff --git a/tests/native/primary-json.test.ts b/tests/native/primary-json.test.ts new file mode 100644 index 0000000..f7ef499 --- /dev/null +++ b/tests/native/primary-json.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import Graphology from 'graphology'; +import { json } from '@dagrejs/graphlib'; +import { createGraph } from '../../src/graph'; +import { toGraphologyJSON, fromGraphologyJSON, graphologyDocumentConverter } from '../../src/formats/graphology'; +import { toGraphlibJSON, fromGraphlibJSON, graphlibDocumentConverter } from '../../src/formats/graphlib'; +import { toNetworkXNodeLink, fromNetworkXNodeLink } from '../../src/formats/networkx'; +import { getCarrierData } from '../../src/formats/preservation'; +import { getNetworkXOracle } from './networkx-oracle'; + +function getPersisted(value: T): T { return JSON.parse(JSON.stringify(value)); } +describe('Graphology adapter independent native reader', () => { + it('observes mixed modes, parallel multiplicity, self-loop, isolates and full data values', () => { + const graph = createGraph({ mode: 'undirected', data: 42, nodes: [{ id: 'a', label: '', data: [1, null] }, { id: 'b', data: null }, { id: 'isolated' }, { id: 'empty' }], edges: [{ id: 'd', sourceId: 'a', targetId: 'b', mode: 'directed' }, { id: 'u', sourceId: 'b', targetId: 'a' }, { id: 'p', sourceId: 'a', targetId: 'b' }, { id: 'loop', sourceId: 'empty', targetId: 'empty' }] }); + graph.nodes[3].id = ''; graph.edges[3].sourceId = ''; graph.edges[3].targetId = ''; + const source = toGraphologyJSON(graph); const native = Graphology.from(getPersisted(source)); + expect(native.type).toBe('mixed'); expect(native.order).toBe(4); expect(native.size).toBe(4); + expect(native.directedEdges('a', 'b')).toEqual(['d']); expect(native.undirectedEdges('a', 'b')).toEqual(['p', 'u']); + expect(native.degree('isolated')).toBe(0); expect(native.hasEdge('', '')).toBe(true); + expect(native.getNodeAttribute('a', 'label')).toBe(''); expect(native.getNodeAttribute('a', 'data')).toEqual([1, null]); expect(native.getAttribute('data')).toBe(42); + native.setNodeAttribute('a', 'label', 'Actual edit'); native.setNodeAttribute('a', 'unknown', { preserved: true }); + const exported = native.export(); const graphEdit = fromGraphologyJSON(exported); graphEdit.nodes[0].x = 10; + const second = Graphology.from(toGraphologyJSON(getPersisted(graphEdit))); + expect(second.getNodeAttribute('a', 'label')).toBe('Actual edit'); expect(second.getNodeAttribute('a', 'unknown')).toEqual({ preserved: true }); expect(second.getNodeAttribute('a', 'x')).toBe(10); + }); + it('validates source-authored metadata changes and document patches through the actual reader', () => { + const native = new Graphology({ type: 'mixed', multi: true }); native.addNode('a', { label: 'A', vendor: 42 }); native.addNode('b'); native.addUndirectedEdgeWithKey('e', 'a', 'b', { weight: 1 }); + const document = getPersisted(graphologyDocumentConverter.from(native.export())); document.source.nodes.reverse(); document.source.nodes.push({ key: 'added' }); document.graph!.nodes[0].label = 'Changed'; document.graph!.edges[0].weight = 2; + const reader = Graphology.from(graphologyDocumentConverter.to(document)); expect(reader.nodes()).toEqual(['b', 'a', 'added']); expect(reader.getNodeAttribute('a', 'label')).toBe('Changed'); expect(reader.getNodeAttribute('a', 'vendor')).toBe(42); expect(reader.getEdgeAttribute('e', 'weight')).toBe(2); expect(reader.isUndirected('e')).toBe(true); + }); + it.each(['constructor', '__proto__', 'toString'])('rejects %s before known native adjacency failure', (id) => { + expect(() => toGraphologyJSON(createGraph({ nodes: [{ id: 'a' }, { id }], edges: [{ id: 'e', sourceId: 'a', targetId: id }] }))).toThrow('unsafe'); + const native = new Graphology({ multi: true }); native.addNode('a'); native.addNode(id); native.addDirectedEdgeWithKey('e', 'a', id); + expect(() => native.directedEdges('a', id)).toThrow(); + }); + it('reads bounded large output with stable ordered node and edge identities', () => { + const nodes = Array.from({ length: 2000 }, (_, index) => ({ id: `n${index}` })); const edges = nodes.slice(1).map((node, index) => ({ id: `e${index}`, sourceId: nodes[index].id, targetId: node.id })); + const native = Graphology.from(toGraphologyJSON(createGraph({ nodes, edges }))); + expect(native.order).toBe(2000); expect(native.size).toBe(1999); expect(native.extremities('e1998')).toEqual(['n1998', 'n1999']); + }); +}); +describe('Graphlib adapter independent native reader', () => { + it.each([false, true])('native reader proves inserted/retargeted edges and keys, multigraph=%s', (multigraph) => { + const source = { options: { directed: true, multigraph, compound: false }, nodes: [{ v: 'a' }, { v: 'b' }, { v: 'c' }], edges: [{ v: 'a', w: 'b', ...(multigraph && { name: 'old' }), value: { data: [42], __statelyai: { foreign: true } } }] }; + const graph = fromGraphlibJSON(source); const oldId = graph.edges[0].id; graph.edges[0].targetId = 'c'; graph.nodes.push({ type: 'node', id: 'added', label: '', data: null }); + graph.edges.push({ type: 'edge', id: 'new', sourceId: 'b', targetId: 'added', label: 'New', data: [1, null] }); graph.nodes.reverse(); graph.edges.reverse(); + const output = toGraphlibJSON(graph); const native = json.read(getPersisted(output)); + expect(native.hasEdge('a', 'c', multigraph ? 'old' : undefined)).toBe(true); expect(native.hasEdge('b', 'added', multigraph ? 'new' : undefined)).toBe(true); expect(native.hasEdge('a', 'b', multigraph ? 'old' : undefined)).toBe(false); + expect(native.edge('a', 'c', multigraph ? 'old' : undefined).data).toEqual([42]); expect(native.edge('a', 'c', multigraph ? 'old' : undefined).__statelyai).toEqual({ foreign: true }); expect(native.edge('b', 'added', multigraph ? 'new' : undefined).label).toBe('New'); + const restored = fromGraphlibJSON(json.write(native)); expect(restored.edges.map((edge) => edge.id)).toEqual(['new', oldId]); expect(restored.edges[0].data).toEqual([1, null]); + graph.edges.push({ type: 'edge', id: 'parallel', sourceId: 'b', targetId: 'added', label: '', data: null }); + if (multigraph) { const reader = json.read(toGraphlibJSON(graph)); expect(reader.edgeCount()).toBe(3); expect(reader.hasEdge('b', 'added', 'parallel')).toBe(true); } + else expect(() => toGraphlibJSON(graph)).toThrow('duplicate'); + }); + it.each(['directed', 'undirected'] as const)('observes %s native topology, names and compound parents', (mode) => { + const graph = createGraph({ mode, data: null, nodes: [{ id: 'parent' }, { id: 'empty', parentId: 'parent', label: '', data: [1, null] }, { id: 'b', data: 42 }, { id: 'isolated' }], edges: [{ id: 'first', sourceId: 'empty', targetId: 'b' }, { id: 'zero', sourceId: 'empty', targetId: 'b', weight: 0 }, { id: 'loop', sourceId: 'b', targetId: 'b' }] }); + graph.nodes[1].id = ''; graph.edges[0].sourceId = ''; graph.edges[1].sourceId = ''; graph.edges[1].id = ''; + const native = json.read(getPersisted(toGraphlibJSON(graph))); expect(native.isDirected()).toBe(mode === 'directed'); expect(native.isMultigraph()).toBe(true); expect(native.isCompound()).toBe(true); + expect(native.nodeCount()).toBe(4); expect(native.edgeCount()).toBe(3); expect(native.parent('')).toBe('parent'); expect(native.node('').data).toEqual([1, null]); expect(native.edge('', 'b', '').weight).toBe(0); + expect(native.nodeEdges('isolated')).toEqual([]); expect(native.hasEdge('b', 'b', 'loop')).toBe(true); + native.node('').label = 'Native label'; native.node('').custom = [42]; const restored = fromGraphlibJSON(json.write(native)); restored.nodes.find((node) => node.id === '')!.x = 2; + const reader = json.read(toGraphlibJSON(restored)); expect(reader.node('').label).toBe('Native label'); expect(reader.node('').custom).toEqual([42]); expect(reader.node('').x).toBe(2); + }); + it('keeps authored opaque values and precise named tuple identity in documents', () => { + const source = { options: { directed: true, multigraph: true, compound: false }, value: [1, null], nodes: [{ v: '', value: 42 }, { v: 'b', value: null }, { v: 'c', value: false }], edges: [{ v: '', w: 'b', name: 'same', value: ['edge'] }, { v: 'b', w: 'c', name: 'same', value: 0 }] }; + const doc = getPersisted(graphlibDocumentConverter.from(source)); expect(graphlibDocumentConverter.to(doc)).toEqual(source); expect(new Set(doc.graph!.edges.map((edge) => edge.id)).size).toBe(2); + doc.graph!.nodes[0].data = 43; const native = json.read(graphlibDocumentConverter.to(doc)); expect(native.node('')).toBe(43); expect(native.graph()).toEqual([1, null]); expect(native.edge('', 'b', 'same')).toEqual(['edge']); + }); + it('guards native __proto__ compound failure before exporting', () => { + expect(() => toGraphlibJSON(createGraph({ nodes: [{ id: '__proto__' }, { id: 'child', parentId: '__proto__' }] }))).toThrow('unsafe'); + expect(() => json.read({ options: { directed: true, multigraph: true, compound: true }, nodes: [{ v: 'parent' }, { v: '__proto__', parent: 'parent' }], edges: [] })).toThrow(); + }); + it('reads a deep native hierarchy without adapter recursion', () => { + const nodes = Array.from({ length: 2000 }, (_, index) => ({ id: `n${index}`, ...(index && { parentId: `n${index - 1}` }) })); + const native = json.read(toGraphlibJSON(createGraph({ nodes }))); expect(native.nodeCount()).toBe(2000); expect(native.parent('n1999')).toBe('n1998'); + }); +}); +describe('NetworkX inserted/retargeted identities through actual Python native reader', () => { + it.each([false, true])('native scalar endpoints and Graph edge identities survive, multigraph=%s', async (multigraph) => { + const source = { directed: true, multigraph, graph: {}, nodes: [{ id: 1 }, { id: '1' }, { id: 2 }], edges: [{ source: 1, target: '1', ...(multigraph && { key: 42 }), data: [42], __statelyai: { foreign: true } }] }; + const graph = fromNetworkXNodeLink(source); const oldId = graph.edges[0].id; graph.edges[0].targetId = graph.nodes[2].id; graph.nodes.push({ type: 'node', id: 'added', label: '', data: null }); + graph.edges.push({ type: 'edge', id: 'new', sourceId: graph.nodes[1].id, targetId: 'added', label: 'New', data: [1, null] }); graph.edges.reverse(); graph.nodes.reverse(); + const output = toNetworkXNodeLink(getPersisted(graph)); const oracle = await getNetworkXOracle({ operation: 'node-link', data: output, options: { edges: 'edges' } }); + if (!oracle.summary) throw new Error('NetworkX oracle did not return node-link summary'); + expect(oracle.summary.directed).toBe(true); expect(oracle.summary.multigraph).toBe(multigraph); expect(oracle.summary.nodes.map((node) => node.id)).toEqual(['added', 2, '1', 1]); + const old = oracle.summary.edges.find((edge) => edge.source === 1 && edge.target === 2)!; const added = oracle.summary.edges.find((edge) => edge.source === '1' && edge.target === 'added')!; + expect(old).toBeDefined(); expect(added.attributes.label).toBe('New'); expect(old.attributes.data).toEqual([42]); expect(old.attributes.__statelyai).toEqual({ foreign: true }); + if (multigraph) { expect(old.key).toBe(42); expect(added.key).toBe('new'); } else { expect(old.key).toBeUndefined(); expect(added.key).toBeUndefined(); } + const restored = fromNetworkXNodeLink(output); expect(restored.edges.map((edge) => edge.id)).toEqual(['new', oldId]); expect(restored.edges[0].data).toEqual([1, null]); expect(getCarrierData(restored.data, 'networkx')).toEqual({}); + graph.edges.push({ type: 'edge', id: 'parallel', sourceId: graph.edges[0].sourceId, targetId: 'added', label: '', data: null }); + if (multigraph) expect((await getNetworkXOracle({ operation: 'node-link', data: toNetworkXNodeLink(graph), options: { edges: 'edges' } })).summary!.edges).toHaveLength(3); + else expect(() => toNetworkXNodeLink(graph)).toThrow('duplicate'); + }); +}); diff --git a/tests/native/scipy-oracle.ts b/tests/native/scipy-oracle.ts new file mode 100644 index 0000000..1cddab5 --- /dev/null +++ b/tests/native/scipy-oracle.ts @@ -0,0 +1,17 @@ +import { execFile } from 'node:child_process'; +import { resolve } from 'node:path'; +export interface SciPyOracleResult { + oracle: 'scipy'; scipy: '1.18.1'; numpy: '2.5.4'; shape: [number, number]; sparse: boolean; + entries: Array<{ row: number; col: number; value: number }>; storedZeros: number | null; + dense?: number[][]; roundTrip: string; +} +export async function getSciPyOracle(source: string, options?: { symmetry?: 'general' | 'symmetric'; field?: 'real' | 'integer' | 'pattern' }): Promise { + const python = process.env.GRAPH_NATIVE_PYTHON ?? resolve('.venv-native/bin/python'); + const output = await new Promise((resolveResult, reject) => { + const child = execFile(python, [resolve('scripts/native/scipy_oracle.py')], { timeout: 10_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => error ? reject(new Error(`SciPy oracle failed: ${stderr.trim() || error.message}`)) : resolveResult(stdout)); + child.stdin!.end(JSON.stringify({ source, options })); + }); + const result = JSON.parse(output) as SciPyOracleResult; + if (result.scipy !== '1.18.1' || result.numpy !== '2.5.4') throw new Error('SciPy oracle version mismatch'); + return result; +} diff --git a/tests/native/structured-oracles.test.ts b/tests/native/structured-oracles.test.ts new file mode 100644 index 0000000..8dd21c7 --- /dev/null +++ b/tests/native/structured-oracles.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from 'vitest'; +import Graphology from 'graphology'; +import { json as graphlibJSON } from '@dagrejs/graphlib'; +import cytoscape from 'cytoscape'; +import { forceLink, forceSimulation } from 'd3-force'; +import ELK from 'elkjs'; +import { createGraph, createVisualGraph } from '../../src/graph'; +import { toCytoscapeJSON, fromCytoscapeJSON, cytoscapeDocumentConverter } from '../../src/formats/cytoscape'; +import { toD3Graph, fromD3Graph, d3DocumentConverter, type D3Graph } from '../../src/formats/d3'; +import { toELK, fromELK, elkDocumentConverter, type ElkNode } from '../../src/formats/elk'; +import { createGraphologyNativeFixture, createGraphlibNativeFixture } from './fixtures/structured/fixtures'; + +function getPersisted(value: T): T { return JSON.parse(JSON.stringify(value)); } +function getLinkIds(source: D3Graph): Array<[string, string]> { + return source.links.map((link) => [(link.source as any).id, (link.target as any).id]); +} +function getInitializedD3(source: D3Graph, numeric = false): D3Graph { + const link = forceLink(source.links); + if (!numeric) link.id((node) => node.id); + const simulation = forceSimulation(source.nodes).force('link', link).stop(); + simulation.tick(2); simulation.stop(); + return source; +} +function getELKIds(root: ElkNode): string[] { + const ids: string[] = [], queue = [root]; + for (let i = 0; i < queue.length; i++) { + const node = queue[i]; ids.push(node.id); + ids.push(...(node.ports ?? []).map((port) => port.id)); + for (const edge of node.edges ?? []) { ids.push(edge.id); ids.push(...(edge.sections ?? []).map((section) => section.id)); } + queue.push(...(node.children ?? [])); + } + return ids; +} + +// These are controls for the future serialization adapters, not production mappings. +describe('independent Graphology and Graphlib native controls', () => { + it('Graphology observes mixed modes, distinct parallel IDs, empty IDs and isolates', () => { + const input = createGraphologyNativeFixture(); + const native = Graphology.from(getPersisted(input)); + expect(native.type).toBe('mixed'); expect(native.multi).toBe(true); + expect(native.order).toBe(4); expect(native.size).toBe(4); + expect(native.nodes()).toEqual(input.nodes.map((node) => node.key)); + expect(native.edges()).toEqual(input.edges!.map((edge) => edge.key)); + expect(native.hasDirectedEdge('a', 'b')).toBe(true); + expect(native.hasDirectedEdge('b', 'a')).toBe(false); + expect(native.hasUndirectedEdge('', 'b')).toBe(true); + expect(native.isUndirected('undirected')).toBe(true); + expect(native.directedEdges('a', 'b')).toHaveLength(2); + expect(native.degree('isolated')).toBe(0); expect(native.hasEdge('', '')).toBe(true); + expect(native.getNodeAttribute('a', 'data')).toBeNull(); + expect(native.getNodeAttribute('b', 'data')).toEqual(['array', 2]); + const exported = native.export(); + const second = Graphology.from(getPersisted(exported)); + expect(second.export()).toEqual(exported); + }); + it('Graphology rejects malformed endpoint identity and unsupported typed modes', () => { + const fixture = createGraphologyNativeFixture(); + fixture.edges![0].target = 'missing'; + expect(() => new Graphology({ type: 'mixed', multi: true }).import(fixture)).toThrow(); + expect(() => new Graphology({ type: 'directed', multi: true }).import(createGraphologyNativeFixture())).toThrow(); + }); + it.each([true, false])('Graphlib reads native global directed=%s, compound labels and named multiplicity', (directed) => { + const input = createGraphlibNativeFixture(directed); + const native = graphlibJSON.read(getPersisted(input)); + expect(native.isDirected()).toBe(directed); expect(native.isMultigraph()).toBe(true); + expect(native.nodeCount()).toBe(4); expect(native.edgeCount()).toBe(3); + expect(native.parent('a')).toBe('parent'); expect(native.node('')).toBe(42); + expect(native.node('isolated')).toBeNull(); + expect(native.edge('a', '', 'second')).toEqual(['edge', null]); + expect(native.edge('', 'a', 'first')).toEqual(directed ? undefined : { weight: 1, label: '' }); + expect(native.nodeEdges('isolated')).toEqual([]); + expect(graphlibJSON.write(graphlibJSON.read(getPersisted(graphlibJSON.write(native))))).toEqual(graphlibJSON.write(native)); + }); + it.each([null, 42, 'root', [1, null], false].map((value) => ({ value })))('Graphlib preserves arbitrary root value $value through its reader/writer', ({ value }) => { + const source = { ...createGraphlibNativeFixture(), value }; + const native = graphlibJSON.read(getPersisted(source)); + expect(native.graph()).toEqual(value); + expect(graphlibJSON.write(native).value).toEqual(value); + }); + it('Graphlib labels cannot change native global edge direction', () => { + const fixture = createGraphlibNativeFixture(true); + (fixture.edges[0].value as any).mode = 'undirected'; + const native = graphlibJSON.read(fixture); + expect(native.isDirected()).toBe(true); + expect(native.hasEdge('', 'a', 'first')).toBe(false); + }); +}); + +describe('upstream reserved-name reader boundaries', () => { + it('records Graphology 0.26.0 reserved adjacency failure instead of declaring a valid native profile', () => { + const source = { nodes: [{ key: 'a' }, { key: 'constructor' }], edges: [{ key: 'e', source: 'a', target: 'constructor' }] }; + expect(() => new Graphology({ multi: true }).import(source).directedEdges('a', 'constructor')).toThrow(); + }); + it('records Graphlib 4.0.1 compound __proto__ failure as an unsupported native profile', () => { + expect(() => graphlibJSON.read({ options: { compound: true }, nodes: [{ v: 'parent' }, { v: '__proto__', parent: 'parent' }], edges: [] })).toThrow(); + }); +}); + +describe('Cytoscape native interpretation', () => { + it('observes positions, Unicode/reserved IDs, scalar/array data, parallels, self-loop and isolate', () => { + const graph = createGraph({ data: null, nodes: [ + { id: '__proto__', label: '', data: 42, x: 10, y: 20 }, + { id: '雪🙂', label: null, data: [1, null], x: 50, y: 60 }, { id: 'isolated', data: null }, + ], edges: [ + { id: 'e1', sourceId: '__proto__', targetId: '雪🙂', data: false }, + { id: 'e2', sourceId: '__proto__', targetId: '雪🙂', mode: 'undirected' }, + { id: 'loop', sourceId: '雪🙂', targetId: '雪🙂' }, + ] }); + const source = toCytoscapeJSON(graph, { nativeCarrier: false }); + const cy = cytoscape({ headless: true, elements: source.elements as any, layout: { name: 'preset' } }); + try { + expect(cy.nodes().map((node) => node.id())).toEqual(['__proto__', '雪🙂', 'isolated']); + expect(cy.edges().map((edge) => edge.id())).toEqual(['e1', 'e2', 'loop']); + expect(cy.getElementById('__proto__').position()).toEqual({ x: 10, y: 20 }); + expect(cy.getElementById('__proto__').data('nodeData')).toBe(42); + expect(cy.getElementById('雪🙂').data('nodeData')).toEqual([1, null]); + expect(cy.getElementById('雪🙂').data('label')).toBeNull(); + expect(cy.getElementById('e2').data('mode')).toBe('undirected'); + expect(cy.getElementById('isolated').connectedEdges()).toHaveLength(0); + expect(cy.getElementById('loop').source().id()).toBe('雪🙂'); + expect(cy.getElementById('loop').target().id()).toBe('雪🙂'); + } finally { cy.destroy(); } + }); + it('native changes to geometry/data/labels and UI fields survive Graph and document edits', () => { + const initial = { elements: { nodes: [{ data: { id: 'a', label: 'Before', score: 1, __statelyai: { foreign: true } }, position: { x: 1, y: 2 } }, { data: { id: 'b' } }], edges: [{ data: { id: 'e', source: 'a', target: 'b' } }] } }; + const cy = cytoscape({ headless: true, elements: initial.elements as any, layout: { name: 'preset' } }); + try { + cy.getElementById('a').position({ x: 7, y: 8 }).data('label', 'Native').data('score', 2).addClass('foreign-class'); + const source: any = getPersisted(cy.json()); + const graph = fromCytoscapeJSON(source, { metadata: 'native' }); + expect(graph.nodes[0]).toMatchObject({ label: 'Native', x: 7, y: 8, data: { score: 2, __statelyai: { foreign: true } } }); + const document = getPersisted(cytoscapeDocumentConverter.from(source)); document.graph!.nodes[0].label = 'Document'; + const result = cytoscapeDocumentConverter.to(document); + const reread = cytoscape({ headless: true, elements: result.elements as any, layout: { name: 'preset' } }); + try { + expect(reread.getElementById('a').data('label')).toBe('Document'); + expect(reread.getElementById('a').data('score')).toBe(2); + expect(reread.getElementById('a').hasClass('foreign-class')).toBe(true); + expect(reread.getElementById('a').position()).toEqual({ x: 7, y: 8 }); + } finally { reread.destroy(); } + } finally { cy.destroy(); } + }); + it('patches the actual native flat shape without grouping or dropping insertion order', () => { + const cy = cytoscape({ headless: true }); + try { + cy.add([{ data: { id: 'a' } }, { data: { id: 'b' } }, { data: { id: 'e', source: 'a', target: 'b' } }, { data: { id: 'c', label: 'Before', __statelyai: { foreign: true } } }]); + const source: any = getPersisted((cy as any).json(true)); + expect(Array.isArray(source.elements)).toBe(true); + const order = source.elements.map((element: any) => element.data.id); + const document = getPersisted(cytoscapeDocumentConverter.from(source)); + document.graph!.nodes.find((node) => node.id === 'c')!.label = 'After'; + const output: any = cytoscapeDocumentConverter.to(document); + expect(output.elements.map((element: any) => element.data.id)).toEqual(order); + const reread = cytoscape({ headless: true, elements: output.elements }); + try { expect(reread.getElementById('c').data('label')).toBe('After'); expect(reread.getElementById('e').source().id()).toBe('a'); } + finally { reread.destroy(); } + } finally { cy.destroy(); } + }); + it('loads a bounded large chain with distinct identities and observed native endpoints', () => { + const count = 1000; + const source = toCytoscapeJSON(createGraph({ nodes: Array.from({ length: count }, (_, i) => ({ id: `n${i}` })), edges: Array.from({ length: count - 1 }, (_, i) => ({ id: `e${i}`, sourceId: `n${i}`, targetId: `n${i + 1}` })) })); + const cy = cytoscape({ headless: true, elements: source.elements as any }); + try { + expect(cy.nodes()).toHaveLength(count); expect(cy.edges()).toHaveLength(count - 1); + expect(cy.getElementById(`e${count - 2}`).target().id()).toBe(`n${count - 1}`); + } finally { cy.destroy(); } + }); + it('retains tagged-source UI edits added by the native reader', () => { + const initial = toCytoscapeJSON(createGraph({ nodes: [{ id: 'a' }] })); + const cy = cytoscape({ headless: true, elements: initial.elements as any, data: initial.data, layout: { name: 'preset' } }); + try { + cy.getElementById('a').addClass('reader-edit'); + const source: any = getPersisted(cy.json()); + const output: any = toCytoscapeJSON(fromCytoscapeJSON(source)); + expect(output.elements.nodes[0].classes).toBe('reader-edit'); + } finally { cy.destroy(); } + }); + it('allocates reader-valid identities when Graph node and edge IDs collide', () => { + const graph = createGraph({ nodes: [{ id: 'a' }, { id: 'same' }], edges: [{ id: 'same', sourceId: 'a', targetId: 'same' }] }); + const source = toCytoscapeJSON(graph); + const cy = cytoscape({ headless: true, elements: source.elements as any }); + try { + expect(cy.nodes()).toHaveLength(2); expect(cy.edges()).toHaveLength(1); + expect(cy.edges()[0].source().id()).toBe(source.elements.nodes[0].data.id); + expect(cy.edges()[0].target().id()).toBe(source.elements.nodes[1].data.id); + expect(source.elements.nodes[0].data.id).not.toBe(''); + } finally { cy.destroy(); } + }); +}); + +describe('D3 forceLink native endpoint interpretation', () => { + it('resolves emitted string IDs with the native ID accessor, preserving scalar/array data and isolates', () => { + const graph = createGraph({ nodes: [{ id: 'a', data: 1 }, { id: 'b', label: '', data: [1, 2] }, { id: 'isolated' }], edges: [{ id: 'first', sourceId: 'a', targetId: 'b' }, { id: 'second', sourceId: 'a', targetId: 'b' }, { id: 'loop', sourceId: 'b', targetId: 'b' }] }); + const native = getInitializedD3(toD3Graph(graph)); + expect(getLinkIds(native)).toEqual([['a', 'b'], ['a', 'b'], ['b', 'b']]); + expect(native.nodes[0].data).toBe(1); expect(native.nodes[1].data).toEqual([1, 2]); + expect(native.links[0].source).toBe(native.nodes[0]); expect(native.nodes[2].id).toBe('isolated'); + expect(native.nodes.every((node) => Number.isFinite(node.x) && Number.isFinite(node.y))).toBe(true); + }); + it('resolves rebased numeric indices after Graph reorder, insertion/deletion and JSON persistence', () => { + const graph = getPersisted(fromD3Graph({ nodes: [{ id: 'a' }, { id: 'unused' }, { id: 'b' }], links: [{ source: 0, target: 2, custom: true }] })); + graph.nodes = [graph.nodes[2], graph.nodes[0], createGraph({ nodes: [{ id: 'new' }] }).nodes[0]]; + const source = toD3Graph(graph); + expect(source.links[0]).toMatchObject({ source: 1, target: 0, custom: true }); + const native = getInitializedD3(source, true); + expect(getLinkIds(native)).toEqual([['a', 'b']]); + expect(native.links[0].source).toBe(native.nodes[1]); expect(native.links[0].target).toBe(native.nodes[0]); + }); + it('rebinds live endpoint objects after imported Graph rename/reorder and JSON persistence', () => { + const a = { id: 'a', label: 'Before', custom: true }, b = { id: 'b' }; + const graph = getPersisted(fromD3Graph({ nodes: [a, b], links: [{ source: a, target: b }] })); + graph.nodes[0].id = 'renamed'; graph.edges[0].sourceId = 'renamed'; graph.nodes.reverse(); + const native = getInitializedD3(toD3Graph(graph)); + expect(getLinkIds(native)).toEqual([['renamed', 'b']]); + expect(native.links[0].source).toBe(native.nodes[1]); expect((native.links[0].source as any).custom).toBe(true); + }); + it('preserves simulation edits and fixed positions from actual native updates', () => { + const source: D3Graph = { nodes: [{ id: 'a', x: 10, y: 20, fx: 10, fy: 20 }, { id: 'b', x: 60, y: 20 }], links: [{ source: 'a', target: 'b' }] }; + const native = getInitializedD3(source); + expect(native.nodes[0]).toMatchObject({ x: 10, y: 20, fx: 10, fy: 20 }); + const graph = fromD3Graph(native); graph.nodes[0].label = 'Edited'; + const reread = getInitializedD3(toD3Graph(graph)); + expect(reread.nodes[0]).toMatchObject({ x: 10, y: 20, fx: 10, fy: 20, label: 'Edited' }); + expect(getLinkIds(reread)).toEqual([['a', 'b']]); + }); + it('rejects invalid indices and independently verifies dangling IDs fail native resolution', () => { + expect(() => fromD3Graph({ nodes: [{ id: 'a' }], links: [{ source: 3, target: 0 }] })).toThrow(/invalid endpoint/); + expect(() => getInitializedD3({ nodes: [{ id: 'a' }], links: [{ source: 'a', target: 'missing' }] })).toThrow(/node not found/); + }); + it('document numeric tokens keep original topology when observed by the default native accessor', () => { + const document = getPersisted(d3DocumentConverter.from({ nodes: [{ id: 'a', label: 'Before' }, { id: 'b' }], links: [{ source: 0, target: 1 }] })); + document.graph!.nodes[0].label = 'After'; + const native = getInitializedD3(d3DocumentConverter.to(document), true); + expect(getLinkIds(native)).toEqual([['a', 'b']]); expect(native.nodes[0].label).toBe('After'); + }); + it('handles a bounded large chain with native endpoint resolution', () => { + const count = 2000; + const graph = createGraph({ nodes: Array.from({ length: count }, (_, i) => ({ id: `n${i}` })), edges: Array.from({ length: count - 1 }, (_, i) => ({ id: `e${i}`, sourceId: `n${i}`, targetId: `n${i + 1}` })) }); + const native = getInitializedD3(toD3Graph(graph)); + expect(native.links).toHaveLength(count - 1); + expect((native.links[0].source as any).id).toBe('n0'); expect((native.links.at(-1)!.target as any).id).toBe(`n${count - 1}`); + }); +}); + +describe('ELK native layout interpretation', () => { + it('lays out globally unique qualified IDs and ports while retaining actual endpoint ownership', async () => { + const graph = createVisualGraph({ id: 'a', direction: 'right', nodes: [ + { id: 'a', label: '', width: 40, height: 30, data: [1, null], ports: [{ name: 'p', direction: 'out', width: 4, height: 4 }] }, + { id: 'a__p', label: 'B', width: 40, height: 30, ports: [{ name: 'p', direction: 'in', width: 4, height: 4 }] }, + { id: 'isolated', label: null, width: 20, height: 20, data: null }, + ], edges: [{ id: 'a', sourceId: 'a', targetId: 'a__p', sourcePort: 'p', targetPort: 'p', label: '', data: 42 }] }); + const source = toELK(graph); source.layoutOptions!['elk.algorithm'] = 'layered'; + for (const node of source.children!) node.layoutOptions!['elk.portConstraints'] = 'FIXED_SIDE'; + const ids = getELKIds(source); expect(new Set(ids).size).toBe(ids.length); + const native = await new ELK().layout(source); + const a = native.children!.find((node) => node.id === 'a')!, b = native.children!.find((node) => node.id === 'a__p')!; + expect(b.x!).toBeGreaterThan(a.x!); expect(a.width).toBe(40); expect(b.width).toBe(40); + expect(native.edges![0].sources).toEqual([a.ports![0].id]); expect(native.edges![0].targets).toEqual([b.ports![0].id]); + expect(native.edges![0].sections![0].incomingShape).toBe(a.ports![0].id); + expect(native.edges![0].sections![0].outgoingShape).toBe(b.ports![0].id); + expect(native.edges![0].sections![0].startPoint.x).toBeLessThan(native.edges![0].sections![0].endPoint.x); + const imported = fromELK(native); + expect(imported.id).toBe('a'); expect(imported.edges[0]).toMatchObject({ id: 'a', sourceId: 'a', targetId: 'a__p', sourcePort: 'p', targetPort: 'p', data: 42 }); + expect(imported.nodes[0].data).toEqual([1, null]); + expect(imported.nodes.every((node) => Number.isFinite(node.x) && Number.isFinite(node.y))).toBe(true); + }, 10000); + it('native layouts and label edits override mapped snapshots', async () => { + const source = toELK(createVisualGraph({ direction: 'down', nodes: [{ id: 'a', label: 'Before', width: 20, height: 20 }, { id: 'b', width: 20, height: 20 }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }] })); + source.layoutOptions!['elk.algorithm'] = 'layered'; + const native = await new ELK().layout(source); native.children![0].labels![0].text = 'Native'; native.children![0].x = 99; + const imported = fromELK(native); + expect(imported.nodes[0]).toMatchObject({ label: 'Native', x: 99 }); + expect(imported.edges[0].points).toHaveLength((native.edges![0].sections![0].bendPoints ?? []).length + 2); + const document = getPersisted(elkDocumentConverter.from(native)); document.graph!.nodes[0].label = 'Document'; + const output = elkDocumentConverter.to(document); + expect(output.children![0].labels![0].text).toBe('Document'); expect(output.children![0].x).toBe(99); + expect((output.edges![0] as any).sections).toEqual(native.edges![0].sections); + }, 10000); + it('retains bounded deep native containment and fresh parent geometry', async () => { + const depth = 20; + const graph = createVisualGraph({ nodes: Array.from({ length: depth }, (_, i) => ({ id: `n${i}`, ...(i > 0 && { parentId: `n${i - 1}` }), width: 20, height: 20 })).reverse() }); + const source = toELK(graph); source.layoutOptions!['elk.algorithm'] = 'layered'; + const native = await new ELK().layout(source); + let current = native.children![0]; + for (let i = 0; i < depth; i++) { expect(current.id).toBe(`n${i}`); if (i < depth - 1) current = current.children![0]; } + expect(native.children![0].width!).toBeGreaterThan(20); + const imported = fromELK(native); + expect(imported.nodes.map((node) => node.id)).toEqual(graph.nodes.map((node) => node.id)); + expect(imported.nodes.find((node) => node.id === 'n0')!.width).toBe(native.children![0].width); + expect(imported.nodes.find((node) => node.id === 'n19')!.parentId).toBe('n18'); + }, 10000); + it('normalizes primitive inputs to actual reader-accepted extended output without losing port topology', async () => { + const graph = fromELK({ id: 'root', children: [{ id: 'a', width: 20, height: 20, ports: [{ id: 'p', width: 4, height: 4 }] }, { id: 'b', width: 20, height: 20 }], edges: [{ id: 'e', source: 'a', sourcePort: 'p', target: 'b', sourcePoint: { x: 1, y: 2 }, targetPoint: { x: 3, y: 4 } }] }); + const source = toELK(graph); source.layoutOptions = { ...source.layoutOptions, 'elk.algorithm': 'layered' }; + expect(source.edges![0].sources).toEqual(['p']); expect(source.edges![0]).not.toHaveProperty('source'); + const native = await new ELK().layout(source); + expect(native.edges![0].sources).toEqual(['p']); expect(native.edges![0].targets).toEqual(['b']); + expect(native.edges![0].sections![0].startPoint.x).toBeTypeOf('number'); + }, 10000); + it('qualifies new primitive-routing section IDs against every existing native element', async () => { + const source = toELK(fromELK({ id: 'root', children: [{ id: 'a', width: 20, height: 20 }, { id: 'b', width: 20, height: 20 }, { id: 'e__section', width: 20, height: 20 }], edges: [{ id: 'e', source: 'a', target: 'b', sourcePoint: { x: 1, y: 2 }, targetPoint: { x: 3, y: 4 } }] })); + const ids = getELKIds(source); expect(new Set(ids).size).toBe(ids.length); + source.layoutOptions = { ...source.layoutOptions, 'elk.algorithm': 'layered' }; + const native = await new ELK().layout(source); + expect(native.children).toHaveLength(3); expect(native.edges![0].sources).toEqual(['a']); + expect(native.edges![0].targets).toEqual(['b']); + }, 10000); + it('rejects malformed duplicate identities and retains unprojectable native hyperedges explicitly', () => { + expect(() => fromELK({ id: 'root', children: [{ id: 'a', ports: [{ id: 'p' }] }, { id: 'b', ports: [{ id: 'p' }] }] })).toThrow(/duplicate/); + const source = { id: 'root', children: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], edges: [{ id: 'e', sources: ['a', 'b'], targets: ['c'] }] }; + const document = getPersisted(elkDocumentConverter.from(source)); + expect(document.graph).toBeNull(); expect(elkDocumentConverter.to(document)).toEqual(source); + }); +}); diff --git a/tests/native/xml-coverage.md b/tests/native/xml-coverage.md new file mode 100644 index 0000000..56dcc62 --- /dev/null +++ b/tests/native/xml-coverage.md @@ -0,0 +1,65 @@ +# XML/JGF/Graphviz/Python native validation scope + +Schemas are pinned under `fixtures/` with original URLs, retrieval dates, SHA256, +licenses and every local resolver rewrite. Schema bytes are unmodified; only +`schemaLocation` URLs are rewritten in memory. libxml2 runs with `--nonet`. + +| Profile | Independent oracle | Proven | +| --- | --- | --- | +| GraphML 1.0 | xmllint-wasm 5.3.0 / libxml2 2.13.8; all three normative dependencies | Standard exports, ports/nesting, scoped data/defaults, boolean direction, Unicode labels/BMP IDs, identity failures, invalid XML, missing references | +| GEXF 1.2draft | Same; all six core/data/dynamics/hierarchy/phylogeny/viz files | Published www namespace and native static input | +| GEXF 1.3 | Same; core/dynamics/viz files | Standard root carrier, native visual fields, weight/mutual/default direction, class-scoped declarations, descriptions, null/empty labels, numeric character references, invalid weights/XML | +| JGF v1 | Official pinned draft-04 schema; Ajv draft04 plugin 1.0.0 / Ajv 8.20.0 | Standard array exports, nullable metadata/directed defaults, native v1 round-trip | +| JGF v2.1 | Official pinned draft-07 schema; Ajv 8.20.0 | Standard keyed-node exports, metadata, edge direction, conversion from v1, sparse import canonical export, unavailable hyperedge projection | +| DOT | @hpcc-js/wasm-graphviz 1.22.0 / Graphviz 15.0.0 | Emitted topology, parallel/isolated nodes, Unicode, modes/arrows, strict merges, ports/compass, native defaults, ranks/clusters, attributes, explicit empty labels | +| GML standard | Pinned Python NetworkX 3.7 (`parse_gml(label=None)`), portable native CI | Fresh numeric IDs, entity/Unicode labels, native missing/empty label presence, isolated nodes, numeric weights, parallel multigraph output and malformed blocks | +| NetworkX node-link JSON | Pinned Python NetworkX 3.7 `node_link_graph`/`node_link_data`, portable native CI | Current edges/legacy links, scalar identities, arbitrary attributes, isolated nodes, loops/parallel edges, endpoint-scoped keys and missing-key allocation; native bool/numeric identity collapse rejected by adapter | + +## Explicit discrepancies and limits + +- Legacy default GraphML `id=""` fails NMTOKEN. The opt-in standard profile omits it. +- Supplementary-plane GraphML IDs fail this XSD/libxml2 profile; export rejects them. +- Published GraphML descendant key/keyref constraints produce ambiguous matches for + a nested-edge fixture with physically unique IDs. The fixture remains a negative + oracle case; the flat standard export validates. No schema semantic edits. +- Legacy default GEXF custom graph attributes fail XSD. Standard profile uses a + discriminated root-only description carrier and preserves user descriptions. +- The GEXF 1.3 public `/viz` example disagrees with the published XSD's core visual + namespace. Both inputs import, but the standard profile follows the pinned XSD. +- Undefined HTML entities fail libxml2 even though the convenience parser enables + its HTML-entity option to decode numeric XML references. Import is not runtime XSD validation. +- Official JGF v2 nodes-only/empty graph alternatives overlap `oneOf`; adding + `edges: []` makes the standard binary profile unambiguous. Legacy hybrid output + intentionally fails both official schemas; explicit versions are tested valid. +- Graphviz default `\\N` and literal empty labels differ. Native provenance retains + that distinction; document edits to absent/default-ID labels reject, with native + source edits available. DOT weights remain native attributes unless stored in a + Graph extension; complete Graphviz grammar/semantics are not claimed. +- XSD validation does not execute temporal semantics, prove arbitrary foreign + extensions, or establish 100% Graph/native semantic round-tripping. Document + source preservation is separately exact; supported edits remain bounded. +- NetworkX also accepts quoted string GML IDs; that reader extension does not + establish conformance to the integer-ID GML specification. GML `null` labels + require the library's private presence extension; native GML has quoted strings. +- Python node-link accepts broader identities and silently merges duplicate + Python-equivalent nodes/keys or creates undeclared endpoints. The adapter's + explicit scalar-ID profile rejects cases that would collapse identity or topology. +- The Python helper also exposes Pajek for future fixture work. NetworkX Pajek + parsing is a limited interoperability oracle, not a complete native validator: + it uses labels as node identity and can collapse distinct equal-label vertices. +- Pinned igraph 1.0.0 provides an additional C-backed Pajek `.net` reader. Actual + duplicate-label vertices retain independent numeric identity, comments parse, + and uniform weighted/parallel/isolated cases are captured. Mixed sections lose + individual edge direction by selecting a global mode; project/temporal formats + remain outside this oracle's documented subset. +- Draw.io preparation pins its Apache-2.0 generated-file XSD and fixtures. Actual + maxgraph 0.25.0 `ModelXmlSerializer.import` provides mxGraph model compatibility + proof for layers/groups/UserObject/HTML/style/geometry/waypoints; this is not + the draw.io renderer. Compressed page XML is independently inflated with Node + zlib before model import. The unmodified published XSD rejects compressed text + pages despite describing them; its validated profile is uncompressed generation. + +Primary sources: https://graphml.graphdrawing.org/specification.html, +https://gexf.net/schema.html, https://gexf.net/viz.html, +https://github.com/jsongraph/json-graph-specification, +https://graphviz.org/docs/outputs/json/. diff --git a/tests/native/xml-oracle.ts b/tests/native/xml-oracle.ts new file mode 100644 index 0000000..faf3d77 --- /dev/null +++ b/tests/native/xml-oracle.ts @@ -0,0 +1,23 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { validateXML, type XMLValidationResult } from 'xmllint-wasm'; + +interface SchemaManifest { entry: string; files: Array<{ file: string; sha256: string; resolverRewrites: Array<{ from: string; to: string }> }> } + +/** Real libxml2 XSD validation; every imported/redefined schema is vendored and hash checked. */ +export async function getXMLValidation(source: string, profile: 'graphml/1.0' | 'gexf/1.3' | 'gexf/1.2draft' | 'drawio'): Promise { + const directory = new URL(`./fixtures/${profile}/`, import.meta.url); + const manifest = JSON.parse(readFileSync(new URL('provenance.json', directory), 'utf8')) as SchemaManifest; + const schemas = manifest.files.map((file) => { + const bytes = readFileSync(fileURLToPath(new URL(file.file, directory))); + if (createHash('sha256').update(bytes).digest('hex') !== file.sha256) throw new Error(`Native schema provenance changed: ${profile}/${file.file}`); + let contents = bytes.toString('utf8'); + for (const rewrite of file.resolverRewrites) contents = contents.split(`schemaLocation="${rewrite.from}"`).join(`schemaLocation="${rewrite.to}"`); + return { fileName: file.file, contents }; + }).filter((file) => file.fileName.endsWith('.xsd')); + const entry = schemas.find((schema) => schema.fileName === manifest.entry); + if (!entry) throw new Error(`Missing native schema entry: ${profile}`); + return validateXML({ xml: { fileName: 'fixture.xml', contents: source }, schema: entry, + preload: schemas.filter((schema) => schema !== entry), modifyArguments: (args) => ['--nonet', ...args] }); +} diff --git a/tests/native/xml.test.ts b/tests/native/xml.test.ts new file mode 100644 index 0000000..4097d1b --- /dev/null +++ b/tests/native/xml.test.ts @@ -0,0 +1,222 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../../src/graph'; +import { fromGraphML, toGraphML } from '../../src/formats/graphml'; +import { fromGEXF, toGEXF } from '../../src/formats/gexf'; +import { getXMLValidation } from './xml-oracle'; + +describe('official XML schemas through libxml2', () => { + it('compiles GraphML 1.0 including normative redefine and XLink imports', async () => { + const source = ''; + const result = await getXMLValidation(source, 'graphml/1.0'); + expect(result.errors).toEqual([]); + expect(result.valid).toBe(true); + }); + it.each(['1.3', '1.2draft'] as const)('compiles published GEXF %s core/dynamics/viz schemas', async (version) => { + const ns = version === '1.3' ? 'http://gexf.net/1.3' : 'http://www.gexf.net/1.2draft'; + const source = ``; + const result = await getXMLValidation(source, `gexf/${version}`); + expect(result.errors).toEqual([]); + expect(result.valid).toBe(true); + expect(fromGEXF(source).nodes[0].label).toBe('A'); + }); + it('documents legacy empty GraphML graph ID schema violation independently', async () => { + const result = await getXMLValidation(toGraphML(createGraph({ nodes: [{ id: 'a' }] })), 'graphml/1.0'); + expect(result.valid).toBe(false); + expect(result.rawOutput).toContain('NMTOKEN'); + }); + it('documents legacy GEXF graph attributes outside its published schema', async () => { + const result = await getXMLValidation(toGEXF(createGraph({ nodes: [{ id: 'a' }] })), 'gexf/1.3'); + expect(result.valid).toBe(false); + expect(result.rawOutput).toContain('not allowed'); + }); + it('rejects undeclared HTML entities even when the convenience parser accepts them', async () => { + const source = ' '; + expect(fromGraphML(source).nodes[0].label).toBe('\u00a0'); + const result = await getXMLValidation(source, 'graphml/1.0'); + expect(result.valid).toBe(false); + expect(result.rawOutput).toContain('Entity'); + }); +}); + +it('validates opt-in GraphML output without silently normalizing IDs or labels', async () => { + const graph = createGraph({ nodes: [{ id: 'a', parentId: null, initialNodeId: null, label: 'A\rB', ports: [{ name: 'p', data: { control: '\u0001' } }] }, { id: 'b', parentId: null, initialNodeId: null, label: '' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', sourcePort: 'p', label: null, weight: 2 }] }); + const source = toGraphML(graph, { standard: true }); + const result = await getXMLValidation(source, 'graphml/1.0'); + expect(result.errors).toEqual([]); + expect(result.valid).toBe(true); + expect(fromGraphML(source)).toEqual(graph); +}); +it.each(['', 'a b', 'a\nb', 'a\u0001b'])('GraphML standard profile rejects an unrepresentable node ID: %j', (id) => { + const graph = createGraph({ nodes: [{ id: 'valid' }] }); + graph.nodes[0].id = id; + expect(() => toGraphML(graph, { standard: true })).toThrow(/NMTOKEN/); +}); +it('GraphML standard profile rejects invalid XML characters in visible labels', () => { + expect(() => toGraphML(createGraph({ nodes: [{ id: 'a', label: 'bad\u0001' }] }), { standard: true })).toThrow(/invalid XML/); +}); + +it('validates GEXF standard output and preserves Graph-only root fields through a schema-valid description', async () => { + const graph = createGraph({ id: 'network 🕸️', mode: 'bidirectional', data: { note: '\u0001' }, direction: 'right', style: { color: 'red' }, initialNodeId: 'a', + nodes: [{ id: 'a', parentId: null, initialNodeId: null, label: null, x: 2, y: 3, width: 20, height: 10, color: 'arbitrary-color', data: 42 }, { id: 'b', parentId: null, initialNodeId: null, label: '' }], + edges: [{ id: 'e', sourceId: 'a', targetId: 'b', label: '', mode: 'bidirectional', weight: 2.5 }] }); + const source = toGEXF(graph, { standard: true }); + const result = await getXMLValidation(source, 'gexf/1.3'); + expect(result.errors).toEqual([]); + expect(result.valid).toBe(true); + expect(fromGEXF(source, { standard: true })).toEqual(graph); +}); +it('retains native descriptions separately from GEXF standard root metadata', async () => { + const source = 'ExternalUSER DESCRIPTION'; + const graph = fromGEXF(source); + graph.id = 'graph-only'; + const output = toGEXF(graph, { standard: true }); + const result = await getXMLValidation(output, 'gexf/1.3'); + expect(result.errors).toEqual([]); + expect(output).toContain('USER DESCRIPTION'); + const imported = fromGEXF(output, { standard: true }); + expect(imported.id).toBe('graph-only'); + expect(imported.data._gexf.meta.description).toBe('USER DESCRIPTION'); +}); +it('preserves valid standard GEXF core visualization and native edits', async () => { + const source = ''; + const result = await getXMLValidation(source, 'gexf/1.3'); + expect(result.errors).toEqual([]); + expect(fromGEXF(source).nodes[0]).toMatchObject({ x: 2, y: 3, width: 4, height: 4, color: '#010203' }); +}); + +it('validates native GraphML namespace scopes, labels, nested defaults and ports independently', async () => { + const source = readFileSync(new URL('./fixtures/graphml/cases/scopes-ports.graphml', import.meta.url), 'utf8'); + const validation = await getXMLValidation(source, 'graphml/1.0'); + expect(validation.errors).toEqual([]); + const graph = fromGraphML(source); + expect(graph.nodes.find((node) => node.id === 'a')).toMatchObject({ parentId: null, label: 'A α', ports: [{ name: 'p' }, { name: 'child' }] }); + expect(graph.nodes.find((node) => node.id === 'isolated')!.label).toBe(''); + expect(graph.edges.find((edge) => edge.id === 'nestedEdge')).toMatchObject({ sourcePort: 'p', mode: 'undirected' }); + const output = toGraphML(graph, { standard: true }); + const revalidation = await getXMLValidation(output, 'graphml/1.0'); + expect(revalidation.errors).toEqual([]); +}); +it('validates native GEXF defaults, weight, mutual direction, scoped declarations and visualization', async () => { + const source = readFileSync(new URL('./fixtures/gexf/cases/scopes-mutual.gexf', import.meta.url), 'utf8'); + const validation = await getXMLValidation(source, 'gexf/1.3'); + expect(validation.errors).toEqual([]); + const graph = fromGEXF(source); + expect(graph.mode).toBe('undirected'); + expect(graph.nodes.find((node) => node.id === 'a')).toMatchObject({ label: 'A α', color: '#010203', x: 2, y: 3 }); + expect(graph.nodes.find((node) => node.id === 'b')!.label).toBe(''); + expect(graph.edges[0]).toMatchObject({ weight: 2.5, mode: 'bidirectional' }); + expect(graph.nodes[0].data._gexf.native).toMatchObject({ alpha: '0.5', z: '4' }); + const output = toGEXF(graph, { standard: true }); + const revalidation = await getXMLValidation(output, 'gexf/1.3'); + expect(revalidation.errors).toEqual([]); + expect(fromGEXF(output, { standard: true })).toEqual(graph); +}); +it('rejects malformed XML, undefined node references and invalid native weight through real validators', async () => { + const malformed = await getXMLValidation('', 'graphml/1.0'); + expect(malformed.valid).toBe(false); + const missing = await getXMLValidation('', 'graphml/1.0'); + expect(missing.valid).toBe(false); + expect(missing.rawOutput).toContain('key'); + const weight = await getXMLValidation('', 'gexf/1.3'); + expect(weight.valid).toBe(false); + expect(weight.rawOutput).toContain('weight'); +}); +it('documents official GEXF1.3 viz-namespace example/schema mismatch without changing the schema', async () => { + const source = ''; + const validation = await getXMLValidation(source, 'gexf/1.3'); + expect(validation.valid).toBe(false); + expect(validation.rawOutput).toContain('position'); +}); +it('GEXF standard root carrier never overrides explicitly edited native root fields', () => { + const graph = createGraph({ id: 'carrier-id', data: 42, initialNodeId: 'a', nodes: [{ id: 'a' }] }); + const source = toGEXF(graph, { standard: true }).replace(' { + const source = readFileSync(new URL('./fixtures/graphml/cases/nested-keyrefs.graphml', import.meta.url), 'utf8'); + expect(source.match(/ node.id === 'a')!.parentId).toBe('parent'); + expect(graph.edges.find((edge) => edge.id === 'nestedEdge')!.mode).toBe('undirected'); + const flattened = await getXMLValidation(toGraphML(graph, { standard: true }), 'graphml/1.0'); + expect(flattened.errors).toEqual([]); +}); +it('rejects supplementary-plane GraphML IDs that the published XSD validator cannot accept', async () => { + const source = ''; + expect((await getXMLValidation(source, 'graphml/1.0')).valid).toBe(false); + expect(() => toGraphML(createGraph({ nodes: [{ id: '😀' }] }), { standard: true })).toThrow(/NMTOKEN/); +}); +it('retains numeric-looking GEXF metadata text through native standard export', async () => { + const source = '001'; + const graph = fromGEXF(source); + expect(graph.data._gexf.meta.description).toBe('001'); + const output = toGEXF(graph, { standard: true }); + expect(output).toContain('001'); + expect((await getXMLValidation(output, 'gexf/1.3')).valid).toBe(true); +}); +it('rejects duplicate GraphML IDs, unresolved endpoints, invalid ports and nonfinite native values explicitly', () => { + const graph = createGraph({ nodes: [{ id: 'a', ports: [{ name: 'p' }] }, { id: 'b' }], edges: [{ id: 'e', sourceId: 'a', targetId: 'b', sourcePort: 'p' }] }); + const duplicate = JSON.parse(JSON.stringify(graph)); duplicate.nodes[1].id = 'a'; + expect(() => toGraphML(duplicate, { standard: true })).toThrow(/duplicate/); + const unresolved = JSON.parse(JSON.stringify(graph)); unresolved.edges[0].targetId = 'missing'; + expect(() => toGraphML(unresolved, { standard: true })).toThrow(/unresolved/); + const port = JSON.parse(JSON.stringify(graph)); port.nodes[0].ports[0].name = 'invalid port'; + expect(() => toGraphML(port, { standard: true })).toThrow(/NMTOKEN/); + graph.edges[0].weight = Number.NaN; + expect(() => toGraphML(graph, { standard: true })).toThrow(/nonfinite/); +}); + +it('GEXF standard output preserves native attribute whitespace via numeric references', async () => { + const graph = createGraph({ nodes: [{ id: 'a\nb', label: '\tNew\nline\r', parentId: null, initialNodeId: null }], edges: [] }); + const source = toGEXF(graph, { standard: true }); + expect(source).toContain('id="a b"'); + expect((await getXMLValidation(source, 'gexf/1.3')).errors).toEqual([]); + expect(fromGEXF(source, { standard: true })).toEqual(graph); +}); +it('GEXF standard export rejects invalid visible XML characters instead of normalizing them', () => { + expect(() => toGEXF(createGraph({ nodes: [{ id: 'a', label: 'bad\u0001' }] }), { standard: true })).toThrow(/invalid XML/); +}); +it('GEXF standard root metadata rejects reserved-description collisions and ambiguous native carriers', () => { + const marker = JSON.stringify({ format: '@statelyai/graph/gexf-root', version: 1, root: { id: 'foreign' } }).replace(/"/g, '"'); + const source = `${marker}`; + const graph = fromGEXF(source); + expect(() => toGEXF(graph, { standard: true })).toThrow(/carrier collision/); + expect(graph.data._gexf.meta.description).toContain('foreign'); + const ambiguous = source.replace('', `${marker}`); + expect(() => fromGEXF(ambiguous, { standard: true })).toThrow(/ambiguous/); +}); +it('GEXF carrier interpretation is explicit, preserving malformed marker-shaped descriptions as native text', () => { + const marker = '{"format":"@statelyai/graph/gexf-root","version":1,"root":[]}'; + const source = `${marker}`; + const graph = fromGEXF(source, { standard: true }); + expect(graph.data._gexf.meta.description).toContain('[]'); + expect(graph.id).toBe(''); +}); +it('GraphML standard output validates nonempty graph IDs, both endpoint ports and BMP Unicode IDs', async () => { + const graph = createGraph({ id: 'native', nodes: [{ id: 'α', ports: [{ name: 'p' }], label: '' }, { id: '汉字', ports: [{ name: 'q' }] }], edges: [{ id: 'e', sourceId: 'α', targetId: '汉字', sourcePort: 'p', targetPort: 'q' }] }); + const source = toGraphML(graph, { standard: true }); + expect((await getXMLValidation(source, 'graphml/1.0')).errors).toEqual([]); + expect(fromGraphML(source).edges[0]).toMatchObject({ sourceId: 'α', targetId: '汉字', sourcePort: 'p', targetPort: 'q' }); +}); + +it('preserves an explicit empty GEXF user description beside the standard root carrier', async () => { + const source = ''; + const graph = fromGEXF(source); + const output = toGEXF(graph, { standard: true }); + expect((await getXMLValidation(output, 'gexf/1.3')).errors).toEqual([]); + expect(fromGEXF(output, { standard: true }).data._gexf.meta.description).toBe(''); +}); +it('reads an explicitly selected GEXF root carrier with a local namespace declaration', async () => { + const marker = JSON.stringify({ format: '@statelyai/graph/gexf-root', version: 1, root: { id: 'native-metadata-id', initialNodeId: null, data: false } }).replace(/"/g, '"'); + const source = `${marker}`; + expect((await getXMLValidation(source, 'gexf/1.3')).errors).toEqual([]); + expect(fromGEXF(source, { standard: true })).toMatchObject({ id: 'native-metadata-id', data: false }); + expect(fromGEXF(source).id).toBe(''); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 8415024..628fd76 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ // Formats — each gets its own subpath export 'src/formats/adjacency-list/index.ts', 'src/formats/converter/index.ts', + 'src/formats/csv/index.ts', + 'src/formats/document/index.ts', 'src/formats/cytoscape/index.ts', 'src/formats/d2/index.ts', 'src/formats/d3/index.ts', @@ -19,12 +21,22 @@ export default defineConfig({ 'src/formats/edge-list/index.ts', 'src/formats/gexf/index.ts', 'src/formats/gml/index.ts', + 'src/formats/graphlib/index.ts', + 'src/formats/graphology/index.ts', 'src/formats/graphml/index.ts', 'src/formats/jgf/index.ts', 'src/formats/tgf/index.ts', 'src/formats/elk/index.ts', 'src/formats/xyflow/index.ts', 'src/formats/mermaid/index.ts', + 'src/formats/networkx/index.ts', + 'src/formats/adjacency-matrix/index.ts', + 'src/formats/matrix-market/index.ts', + 'src/formats/graphviz-json/index.ts', + 'src/formats/drawio/index.ts', + 'src/formats/pajek/index.ts', + 'src/formats/plantuml/index.ts', + // Layout — contract + per-engine adapters (optional peers) 'src/layout/index.ts', 'src/layout/elk.ts', diff --git a/vitest.config.ts b/vitest.config.ts index 94ede10..fc99c85 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,42 @@ -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; -export default defineConfig({}); +export default defineConfig({ + test: { + exclude: [...configDefaults.exclude, 'tests/native/**'], + coverage: { + include: ['src/formats/**/*.ts'], + reporter: ['text', 'json-summary'], + reportOnFailure: true, + thresholds: { + lines: 95, statements: 91, functions: 93, branches: 84, + 'src/formats/d2/parser.ts': { lines: 91, branches: 75 }, + 'src/formats/d2/emitter.ts': { lines: 91, branches: 77 }, + 'src/formats/mermaid/flowchart.ts': { lines: 90, branches: 79 }, + 'src/formats/mermaid/class-diagram.ts': { lines: 91, branches: 78 }, + 'src/formats/mermaid/sequence.ts': { lines: 95, branches: 83 }, + 'src/formats/graphml/index.ts': { lines: 99, branches: 93 }, + 'src/formats/gexf/index.ts': { lines: 98, branches: 92 }, + 'src/formats/elk/index.ts': { lines: 99, branches: 93 }, + 'src/formats/csv/index.ts': { lines: 97, branches: 82 }, + 'src/formats/csv/table.ts': { lines: 99, branches: 92 }, + 'src/formats/graphology/index.ts': { lines: 98, branches: 85 }, + 'src/formats/graphlib/index.ts': { lines: 98, branches: 91 }, + 'src/formats/networkx/index.ts': { lines: 98, branches: 94 }, + 'src/formats/adjacency-matrix/core.ts': { lines: 97, branches: 86 }, + 'src/formats/adjacency-matrix/index.ts': { lines: 97, branches: 90 }, + 'src/formats/matrix-market/index.ts': { lines: 95, branches: 81 }, + 'src/formats/graphviz-json/index.ts': { lines: 99, branches: 86 }, + 'src/formats/drawio/index.ts': { lines: 99, branches: 80 }, + 'src/formats/pajek/index.ts': { lines: 97, branches: 81 }, + 'src/formats/plantuml/index.ts': { lines: 99, branches: 81 }, + 'src/formats/mermaid/architecture.ts': { lines: 99, branches: 86 }, + 'src/formats/mermaid/requirement.ts': { lines: 99, branches: 87 }, + 'src/formats/mermaid/sankey.ts': { lines: 99, branches: 86 }, + 'src/formats/mermaid/git-graph.ts': { lines: 99, branches: 88 }, + 'src/formats/mermaid/c4.ts': { lines: 97, branches: 79 }, + 'src/formats/mermaid/use-case.ts': { lines: 99, branches: 83 }, + 'src/formats/mermaid/profile-shared.ts': { lines: 99, branches: 85 }, + }, + }, + }, +}); diff --git a/vitest.coverage.config.ts b/vitest.coverage.config.ts new file mode 100644 index 0000000..e97cab3 --- /dev/null +++ b/vitest.coverage.config.ts @@ -0,0 +1,18 @@ +import { configDefaults, defineConfig } from 'vitest/config'; +import base from './vitest.config'; + +if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('Combined converter coverage includes native validators and requires Node 24'); + +export default defineConfig({ + ...base, + test: { + ...base.test, + include: ['tests/**/*.test.ts'], + exclude: configDefaults.exclude, + environment: 'node', + pool: 'forks', + maxWorkers: 2, + testTimeout: 30_000, + hookTimeout: 30_000, + }, +}); diff --git a/vitest.native.config.ts b/vitest.native.config.ts new file mode 100644 index 0000000..5a90e90 --- /dev/null +++ b/vitest.native.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('Native validation requires Node 24; standard core tests remain available on Node 20'); + +export default defineConfig({ + test: { + include: ['tests/native/**/*.test.ts'], + environment: 'node', + pool: 'forks', + maxWorkers: 2, + testTimeout: 30_000, + hookTimeout: 30_000, + }, +});