From 3d8090b2320f78a711ba2e2c8744600fe30e1519 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Mon, 12 Oct 2026 18:11:59 +0200 Subject: [PATCH 1/2] Add scoped domain graph formats and guard document identities --- .changeset/domain-formats.md | 7 + .gitattributes | 7 + .github/workflows/ci.yml | 2 + README.md | 16 +- docs/native-validation.md | 34 +- package.json | 17 +- plans/006-domain-exchange-formats.md | 26 + plans/007-graphson-profile.md | 25 + plans/008-rdf-dataset-profile.md | 29 + .../009-reject-ambiguous-source-identities.md | 29 + .../010-complete-document-identity-guards.md | 31 + plans/011-domain-formats-verification.md | 166 +++ plans/README.md | 22 +- pnpm-lock.yaml | 135 +++ requirements/native.txt | 4 + scripts/native/graphson-tools.json | 71 ++ scripts/native/graphson_oracle.groovy | 12 + scripts/native/rdflib_oracle.py | 131 +++ scripts/native/setup-graphson.mjs | 19 + scripts/smoke-package.ts | 72 ++ src/formats/cx2/README.md | 53 + src/formats/cx2/index.ts | 742 +++++++++++++ src/formats/cytoscape/README.md | 9 + src/formats/d3/README.md | 10 + src/formats/dgml/README.md | 58 ++ src/formats/dgml/index.ts | 610 +++++++++++ src/formats/dot/README.md | 10 + src/formats/dot/document.ts | 24 +- src/formats/gexf/README.md | 9 + src/formats/gml/README.md | 9 + src/formats/gml/document.ts | 27 +- src/formats/graphml/README.md | 9 + src/formats/graphology/README.md | 4 + src/formats/graphson/README.md | 77 ++ src/formats/graphson/index.ts | 973 ++++++++++++++++++ src/formats/graphviz-json/README.md | 12 + src/formats/graphviz-json/index.ts | 24 +- src/formats/index.ts | 60 ++ src/formats/jgf/README.md | 4 + src/formats/networkx/README.md | 4 + src/formats/pajek/README.md | 6 +- src/formats/pajek/index.ts | 22 +- src/formats/plantuml/README.md | 4 + src/formats/plantuml/index.ts | 22 + src/formats/preservation.ts | 27 + src/formats/rdf/README.md | 161 +++ src/formats/rdf/index.ts | 148 +++ src/formats/rdf/syntax.ts | 128 +++ src/formats/structured-json.ts | 45 + src/formats/support.ts | 72 ++ src/formats/tgf/README.md | 9 + src/formats/xml-document.ts | 27 +- tests/contracts/domain-formats.test.ts | 64 ++ tests/cx2.test.ts | 397 +++++++ tests/dgml.test.ts | 302 ++++++ tests/document-identity-additional.test.ts | 359 +++++++ tests/document-identity.test.ts | 140 +++ tests/formats/support-matrix.test.ts | 6 +- tests/graphson.test.ts | 445 ++++++++ tests/native/cx2-oracle.py | 18 + tests/native/cx2-oracle.ts | 32 + tests/native/cx2.test.ts | 89 ++ tests/native/dgml.test.ts | 95 ++ tests/native/fixtures/cx2/profile.json | 103 ++ tests/native/fixtures/cx2/provenance.json | 10 + tests/native/fixtures/dgml/dgml.xsd | 913 ++++++++++++++++ tests/native/fixtures/dgml/provenance.json | 9 + tests/native/fixtures/graphson/modern.json | 441 ++++++++ .../native/fixtures/graphson/provenance.json | 7 + tests/native/fixtures/rdf/provenance.json | 29 + tests/native/fixtures/rdf/scoped.trig | 9 + tests/native/graphson-oracle.ts | 51 + tests/native/graphson.test.ts | 263 +++++ tests/native/graphviz.test.ts | 16 +- tests/native/rdf.test.ts | 63 ++ tests/native/rdflib-oracle.ts | 19 + tests/rdf-syntax.test.ts | 76 ++ tests/rdf.test.ts | 71 ++ tsdown.config.ts | 5 + vitest.config.ts | 5 + 80 files changed, 8251 insertions(+), 40 deletions(-) create mode 100644 .changeset/domain-formats.md create mode 100644 plans/006-domain-exchange-formats.md create mode 100644 plans/007-graphson-profile.md create mode 100644 plans/008-rdf-dataset-profile.md create mode 100644 plans/009-reject-ambiguous-source-identities.md create mode 100644 plans/010-complete-document-identity-guards.md create mode 100644 plans/011-domain-formats-verification.md create mode 100644 scripts/native/graphson-tools.json create mode 100644 scripts/native/graphson_oracle.groovy create mode 100644 scripts/native/rdflib_oracle.py create mode 100644 scripts/native/setup-graphson.mjs create mode 100644 src/formats/cx2/README.md create mode 100644 src/formats/cx2/index.ts create mode 100644 src/formats/dgml/README.md create mode 100644 src/formats/dgml/index.ts create mode 100644 src/formats/graphson/README.md create mode 100644 src/formats/graphson/index.ts create mode 100644 src/formats/rdf/README.md create mode 100644 src/formats/rdf/index.ts create mode 100644 src/formats/rdf/syntax.ts create mode 100644 tests/contracts/domain-formats.test.ts create mode 100644 tests/cx2.test.ts create mode 100644 tests/dgml.test.ts create mode 100644 tests/document-identity-additional.test.ts create mode 100644 tests/document-identity.test.ts create mode 100644 tests/graphson.test.ts create mode 100644 tests/native/cx2-oracle.py create mode 100644 tests/native/cx2-oracle.ts create mode 100644 tests/native/cx2.test.ts create mode 100644 tests/native/dgml.test.ts create mode 100644 tests/native/fixtures/cx2/profile.json create mode 100644 tests/native/fixtures/cx2/provenance.json create mode 100644 tests/native/fixtures/dgml/dgml.xsd create mode 100644 tests/native/fixtures/dgml/provenance.json create mode 100644 tests/native/fixtures/graphson/modern.json create mode 100644 tests/native/fixtures/graphson/provenance.json create mode 100644 tests/native/fixtures/rdf/provenance.json create mode 100644 tests/native/fixtures/rdf/scoped.trig create mode 100644 tests/native/graphson-oracle.ts create mode 100644 tests/native/graphson.test.ts create mode 100644 tests/native/rdf.test.ts create mode 100644 tests/native/rdflib-oracle.ts create mode 100644 tests/rdf-syntax.test.ts create mode 100644 tests/rdf.test.ts diff --git a/.changeset/domain-formats.md b/.changeset/domain-formats.md new file mode 100644 index 0000000..8eeef1d --- /dev/null +++ b/.changeset/domain-formats.md @@ -0,0 +1,7 @@ +--- +"@statelyai/graph": minor +--- + +Add explicit GraphSON 3 typed TinkerGraph, DGML, CX2 and RDF 1.1 dataset/syntax adapters with strict native profiles and source-preserving document converters. RDF syntax uses optional N3; pure JSON/dataset modules remain dependency-free. + +Fix wrong-entity document edits across 13 legacy converters and new DGML. Ambiguous source identity changes and unsupported mappings now reject with diagnostics rather than targeting another entity or replaying stale source. Expand pinned independent native validators, package subpath contracts and measured coverage gates. diff --git a/.gitattributes b/.gitattributes index 822a551..a78a221 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,10 @@ 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 + +# Official DGML schema retains upstream bytes. +tests/native/fixtures/dgml/*.xsd -text -whitespace + +# Authored RDF/GraphSON fixture hashes require stable LF bytes. +tests/native/fixtures/rdf/scoped.trig -text +tests/native/fixtures/graphson/modern.json -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 480ee32..ac3c69a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,5 +113,7 @@ jobs: java-version: '21' - name: Install Pinned PlantUML Oracle run: pnpm setup:native:plantuml + - name: Install Pinned GraphSON Oracle + run: pnpm setup:native:graphson - name: Validate Native Formats and Converter Coverage run: pnpm test:coverage diff --git a/README.md b/README.md index 4d87b83..983e36f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A graph is just `{ nodes, edges }` data, and every operation is a standalone, tr - **One model for real diagrams.** Directed and undirected edges (even mixed), nested nodes, named ports, and positions and sizes: what node editors, statecharts, and architecture diagrams need, and most graph libraries leave out. - **Fast.** Fastest in most of our [cross-library benchmarks](./docs/benchmarks.md) against graphology, ngraph, graphlib, and cytoscape. For example, it builds a 100k-node graph 9–15× faster than graphology. - **Algorithms you can trust.** Shortest paths, centrality, communities, flow, matching, isomorphism, and more. Every query and algorithm is tested against edge cases such as self-loops, parallel edges, and unknown ids, and algorithms are iterative, so deep graphs won't overflow the stack. -- **Works with your tools.** Convert to and from 14 formats, including Graphviz DOT, Mermaid, GraphML, D2, React Flow, and Cytoscape. Lay out with 8 engines, including ELK, dagre, and Graphviz. Turn XState machines into graphs. Each adapter is an optional subpath import. +- **Works with your tools.** Convert to and from graph formats including Graphviz DOT, Mermaid, GraphML, D2, React Flow, and Cytoscape. Lay out with 8 engines, including ELK, dagre, and Graphviz. Turn XState machines into graphs. Each adapter is an optional subpath import. - **Typed end to end.** Generic data types for nodes, edges, ports, and the graph itself. ## Installation @@ -67,6 +67,7 @@ supported mappings; they do not establish conformance to an entire format. | --- | --- | --- | --- | | 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 | +| GraphSON3 typed TinkerGraph objects, DGML, CX2, RDF1.1 dataset/syntax profiles | Partial | Partial | Partial scoped sources | | 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 | @@ -80,7 +81,9 @@ without making them native features. Use a GraphDocument when those details matt 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). +[Pajek](src/formats/pajek/README.md), [PlantUML](src/formats/plantuml/README.md), +[GraphSON](src/formats/graphson/README.md), [DGML](src/formats/dgml/README.md), +[CX2](src/formats/cx2/README.md), [RDF](src/formats/rdf/README.md). ```ts import { getFormatSupportEntry } from '@statelyai/graph/format-support'; @@ -121,6 +124,12 @@ 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). +GraphSON uses an explicit typed TinkerGraph object profile; DGML and CX2 preserve +scoped native declarations. RDF maps subject/object terms to nodes and statements +to directed edges, retaining predicate and graph terms. The dependency-free +`/rdf` dataset API is separate from optional N3-backed `/rdf/syntax`. These +domain mappings reject incompatible Graph state rather than guessing. + 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 @@ -128,6 +137,9 @@ 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. +Ambiguous source identity edits reject when generated IDs cannot establish correspondence; +matching private lineage does not override live native identity. + 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, diff --git a/docs/native-validation.md b/docs/native-validation.md index bdc0e04..5ebc52d 100644 --- a/docs/native-validation.md +++ b/docs/native-validation.md @@ -21,7 +21,8 @@ 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 +expanded Mermaid profiles with their shared parser helpers, plus scoped GraphSON, +DGML, CX2 and RDF core/syntax modules. Coverage output is ignored. Native semantic assertions complement coverage; neither proves full language coverage. @@ -40,6 +41,9 @@ language coverage. | 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 | +| Apache TinkerPop Java / Gremlin JavaScript | 3.8.2 | GraphSON3 typed TinkerGraph object/element/property semantics | +| NDEx ndex2 (Python) | 3.12.0 | CX2 native declarations/defaults/topology | +| RDFLib (Python) / N3 (JavaScript) | 7.6.0 / 2.13.12 | RDF1.1 selected syntax/dataset semantics | | 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 @@ -140,3 +144,31 @@ label emission/projection/patches reject them, while astral characters and safe 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. + + + +GraphSON native setup uses `pnpm setup:native:graphson`: twelve Maven Central +JARs pinned individually by SHA256, derived from the verified official Apache +TinkerPop 3.8.2 distribution. The manifest records URLs, versions, distribution +provenance and licensing. Tools stay in ignored `.tools-native/` and never enter +runtime imports or package contents. Java subprocesses have a 20-second bound; +`GRAPH_NATIVE_JAVA` selects the task Java 21 executable. Actual Java mapper tests +use `TinkerIoRegistryV3` plus extended value types, preserve unsafe magnitudes in +raw serialized strings, and assert observed native topology/property semantics. +No GraphSON adjacency/writeGraph interoperability is claimed. + +DGML uses the official Microsoft 2009 XSD with xmllint, including negative schema +controls for dynamic property attributes; this does not establish Windows renderer +behavior. CX2 uses the maintained NDEx Python reader with observed default/null +limitations. RDF uses N3 strict grammars and independent RDFLib parser/store +observations and dataset isomorphism; empty-graph and language-case distinctions +are checked before the native store normalizes them. Per-format READMEs and +fixture provenance describe each bounded interpretation claim. Exact document +source retention is separate from these native semantic assertions. + + + +Final combined evidence belongs in the +[domain formats verification report](../plans/011-domain-formats-verification.md). +Isolated measurements do not establish a combined release gate; existing global +coverage thresholds remain unchanged. diff --git a/package.json b/package.json index 8c14c7a..0ab08fc 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,11 @@ "./adjacency-matrix": "./dist/formats/adjacency-matrix/index.mjs", "./converter": "./dist/formats/converter/index.mjs", "./csv": "./dist/formats/csv/index.mjs", + "./cx2": "./dist/formats/cx2/index.mjs", "./cytoscape": "./dist/formats/cytoscape/index.mjs", "./d2": "./dist/formats/d2/index.mjs", "./d3": "./dist/formats/d3/index.mjs", + "./dgml": "./dist/formats/dgml/index.mjs", "./document": "./dist/formats/document/index.mjs", "./dot": "./dist/formats/dot/index.mjs", "./drawio": "./dist/formats/drawio/index.mjs", @@ -44,6 +46,7 @@ "./graphlib": "./dist/formats/graphlib/index.mjs", "./graphml": "./dist/formats/graphml/index.mjs", "./graphology": "./dist/formats/graphology/index.mjs", + "./graphson": "./dist/formats/graphson/index.mjs", "./graphviz-json": "./dist/formats/graphviz-json/index.mjs", "./jgf": "./dist/formats/jgf/index.mjs", "./matrix-market": "./dist/formats/matrix-market/index.mjs", @@ -51,6 +54,8 @@ "./networkx": "./dist/formats/networkx/index.mjs", "./pajek": "./dist/formats/pajek/index.mjs", "./plantuml": "./dist/formats/plantuml/index.mjs", + "./rdf": "./dist/formats/rdf/index.mjs", + "./rdf/syntax": "./dist/formats/rdf/syntax.mjs", "./tgf": "./dist/formats/tgf/index.mjs", "./xyflow": "./dist/formats/xyflow/index.mjs", "./kernel": "./dist/kernel.mjs", @@ -97,7 +102,8 @@ "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" + "setup:native:plantuml": "node scripts/native/setup-plantuml.mjs", + "setup:native:graphson": "node scripts/native/setup-graphson.mjs" }, "devDependencies": { "@changesets/changelog-github": "^0.5.2", @@ -111,6 +117,7 @@ "@types/d3-force": "^3.0.10", "@types/d3-hierarchy": "^3.1.7", "@types/jsdom": "30.0.0", + "@types/n3": "1.26.4", "@types/node": "^25.0.3", "@vitest/coverage-v8": "^4.0.18", "@xyflow/system": "^0.0.75", @@ -132,8 +139,10 @@ "graphology-metrics": "^2.4.0", "graphology-shortest-path": "^2.1.0", "graphology-types": "^0.24.8", + "gremlin": "3.8.2", "jsdom": "30.1.2", "mermaid": "12.1.0", + "n3": "2.13.12", "ngraph.graph": "^20.1.2", "ngraph.path": "^1.6.1", "publint": "^0.3.15", @@ -160,7 +169,8 @@ "graphology-layout-forceatlas2": "^0.10.0", "webcola": "^3.4.0", "xstate": "^5.19.0", - "zod": "^4.0.0" + "zod": "^4.0.0", + "n3": "^2.13.12" }, "peerDependenciesMeta": { "@dagrejs/dagre": { @@ -204,6 +214,9 @@ }, "fflate": { "optional": true + }, + "n3": { + "optional": true } } } diff --git a/plans/006-domain-exchange-formats.md b/plans/006-domain-exchange-formats.md new file mode 100644 index 0000000..782e8da --- /dev/null +++ b/plans/006-domain-exchange-formats.md @@ -0,0 +1,26 @@ +# Plan 006: Add DGML and CX2 semantic profiles + +Planned at `8d1b139`, 2026-10-12. Priority P2; effort L; risk MED; direction. Depends on DONE plans 001–004. Worktree `/Users/davidkpiano/.codex/worktrees/graph/domain-formats`, branch `feature/domain-formats`, fetched main base. Never modify shared checkout. + +## Why and current state + +The merged verification report identifies DGML and CX2 as missing domain formats. Graph uses plain JSON nodes/edges; converters are prefixed standalone functions. `src/formats/graphlib/index.ts` exemplifies `createFormatConverter`, native carrier storage, and `createGraphDocumentConverter({format,codec,project,patch})`. The document API keeps original source independent of its partial projection, reparses edits, and rejects unsupported changes. `src/formats/structured-json.ts` and `preservation.ts` contain reusable validation/identity helpers. Follow these contracts; do not change Graph or document core. + +## Scope and ownership + +Executor owns `src/formats/dgml/**`, `src/formats/cx2/**`, `tests/dgml*.test.ts`, `tests/cx2*.test.ts`, matching `tests/native/*` tests/fixtures/helpers, and format READMEs. Another executor owns shared barrels, package/build/coverage/CI/contracts and root docs. Coordinate required dependencies with it. Do not edit plans or other converters; never revert others' edits. No commits until reviewer approval. + +## Steps + +1. Drift check: `git diff --stat 8d1b139..HEAD -- src/formats/document src/formats/preservation.ts src/formats/structured-json.ts` must be empty. Read local AGENTS and exemplars. Ask integration owner to install exact lockfile dependencies first. +2. Research authoritative DGML Microsoft reference and NDEx CX2 specification/current maintained reader. Pin source/version provenance. Define explicit profiles in README before coding. DGML maps Id/Label and directed links, category/property definitions, Contains membership, groups; reject multiple/cyclic containment rather than guessing. Native DGML implicitly creates undeclared link endpoint nodes: support with declaration provenance, exact no-op source, bounded explicit declaration edits only with reparse proof. CX2 maps aspect-oriented numeric IDs/topology and declared typed attributes/defaults/aliases; retain opaque visual/custom aspects. Native identities must remain distinct and exact; numeric unsafe IDs reject. Duplicate entities and CX2 dangling endpoints reject. Self-loops/parallel links supported when native permits. +3. Implement import/export with plain serializable typed native data; native carrier retains unknown fields/aspects. Live mapped fields authoritative over stale carrier. Do not silently change declared data types. Fresh Graph export either represent fields explicitly or reject unsupported semantics. Do not advertise full interpretation. +4. Add document registrations: exact source no-op after JSON persistence, unsupported source retains source with null projection; bounded label/property edits only when semantic proof available, otherwise strict rejection. Preserve comments/unknown XML or aspect records on edits. No broad regex that confuses nested elements/namespaces. Use shared lexical XML helpers where appropriate. +5. Regression tests: IDs, aliases/defaults/types, group/category distinction, missing vs null vs empty, unknown fields, opaque constructs, parallel/self-loop topology, invalid/cyclic native inputs, stale metadata precedence, exact source round-trip, supported edits and rejection/conflict controls, 10k topology performance guard. Native evidence must use official schema or maintained independent reader; self-roundtrip is insufficient. Record any unavailable oracle honestly, no fake native label. +6. Match readme skill `/Users/davidkpiano/.agents/skills/readme/SKILL.md`. Run focused tests and types; report shared wiring to integration owner. + +## Commands and done criteria + +`pnpm install --frozen-lockfile` once by integration owner. `pnpm exec vitest run tests/dgml*.test.ts tests/cx2*.test.ts`; `pnpm typecheck`; `pnpm typecheck:repo`; native tests via actual Node24 `pnpm dlx node@24.15.0 node_modules/vitest/vitest.mjs run --config vitest.native.config.ts` with focused files. Reviewer runs `pnpm verify` and full combined coverage. All exit0; meaningful independent semantic assertions and preservation tests; only owned files changed. Never claim Windows Visual Studio rendering without proof. + +STOP and report if native specification contradicts a proposed mapping, existing API must break, domain behavior remains ambiguous, or dependencies require changing core runtime. Reviewer maintains index and approves publication. Future versions must preserve explicit profiles and evidence limitations. diff --git a/plans/007-graphson-profile.md b/plans/007-graphson-profile.md new file mode 100644 index 0000000..bc940c7 --- /dev/null +++ b/plans/007-graphson-profile.md @@ -0,0 +1,25 @@ +# Plan 007: Add an explicit GraphSON profile and wire domain formats + +Planned at `8d1b139`, 2026-10-12. Priority P2; effort L; risk MED; direction. Depends on DONE plans001–004. Worktree `/Users/davidkpiano/.codex/worktrees/graph/domain-formats`, branch `feature/domain-formats`, fetched main base. Preserve shared checkout. + +## Intent/current state + +GraphSON remains absent from `package.json` exports and support registry. TinkerPop distinguishes GraphSON versions, type wrappers, whole-graph and adjacency forms; do not call arbitrary JSON GraphSON. `src/formats/graphlib/index.ts` shows native metadata/carrier/document conventions. `src/formats/index.ts`, `src/formats/support.ts`, `tsdown.config.ts`, `scripts/smoke-package.ts`, `tests/contracts.test.ts` and `vitest.config.ts` register format surface/build/coverage/contracts. Every function prefixed, Graph first except factories, Graph plain JSON. Never change defaults of existing formats. + +## Ownership + +Own `src/formats/graphson/**`, `tests/graphson*.test.ts`, matching native tests/fixtures/helpers/README. Also own shared integration files: `src/formats/index.ts`, `src/formats/support.ts`, `package.json`, `pnpm-lock.yaml`, `tsdown.config.ts`, `vitest.config.ts`, coverage/native config, `scripts/smoke-package.ts`, `tests/contracts.test.ts`, format-support tests, root README, `docs/native-validation.md`, optional domain ADR, one minor Changeset, requirements/CI only if independently needed. Other executors own DGML/CX2/RDF source/tests. You are not alone: do not revert others; coordinate public names before wiring. Root edits only plans. No commits until reviewer approval. + +## Steps + +1. Confirm `git diff --stat 8d1b139..HEAD -- src/formats/graphlib src/formats/document` empty. Install `pnpm install --frozen-lockfile` before diagnostics; tell other executors ready. Only you change dependency manifests/locks. +2. Use official https://tinkerpop.apache.org/docs/current/dev/io/ and maintained native implementation; pin version. Choose explicit GraphSON3 typed graph profile supported by native reader. Add adjacency/NDJSON only if independently verified; do not invent g:Graph vs g:TinkerGraph. Reject unsupported versions/profiles rather than guessing. Typed IDs, Int64/BigInteger/BigDecimal must not coerce through unsafe JS numbers; lossless wrappers remain JSON. Preserve vertex-property IDs, multi-properties/meta-properties, map keys/sets/type extensions as native data. Directed edges and endpoint identity exact; invalid duplicate IDs/dangling edges reject. Do not flatten typed maps into records. +3. Implement to/from/converter/document APIs matching existing carriers. Source unchanged after JSON persistence exact; source edit authoritative; bounded label/property edits require native proof; unsupported changes reject. Fresh Graph conversion must expose scoped representation/rejection, not silent loss of arbitrary Graph data. Tests include distinct typed IDs, unsafe numbers, parallel/selfloops, empty/native null, unknown wrappers, property identity, stale carrier vs mapped edits, document strict controls. +4. Independent oracle: pinned TinkerPop Python/JS reader or genuine Java runtime with committed upstream fixtures; verify native semantics, not own snapshots. Communicate dependency needs for DGML/CX2/RDF. Add coverage floors for all new modules without lowering existing floors. Integrate exports/support/contracts/package smoke after APIs settle, preserve optional peers lazy from root. +5. Review affected README symbolic sections with `/Users/davidkpiano/.agents/skills/readme/SKILL.md`. Document exact version/profile/native scope and add minor Changeset; refresh native report with actual measured evidence only. + +## Commands/done + +Focused `pnpm exec vitest run tests/graphson*.test.ts`; `pnpm typecheck`; `pnpm typecheck:repo`; `pnpm check:conventions`; `pnpm build`; `pnpm validate:package`; full `pnpm verify`; actualNode24 combined coverage `pnpm dlx node@24.15.0 node_modules/vitest/vitest.mjs run --config vitest.coverage.config.ts --coverage` with native environment configured. All pass; useful independent native tests; source/semantic limits truthful; no shared checkout mutations. Report COMPLETE/STOPPED, steps/commands, changed files and deviations. + +STOP on impossible native profile assumptions, required breaking Graph API, or unresolved architectural mapping. Reviewer maintains plan index and approves publication. RDF JSON-LD asynchronous/network-context processing is deferred unless separately designed; don't guess synchronous support. diff --git a/plans/008-rdf-dataset-profile.md b/plans/008-rdf-dataset-profile.md new file mode 100644 index 0000000..3e58487 --- /dev/null +++ b/plans/008-rdf-dataset-profile.md @@ -0,0 +1,29 @@ +# Plan 008: Add lossless RDF dataset terms and scoped concrete syntaxes + +Planned at `8d1b139`, 2026-10-12. Priority P2; effort L; risk MED; direction. Depends on DONE001–004 and RDF mapping decision. Dedicated `/Users/davidkpiano/.codex/worktrees/graph/domain-formats`, feature/domain-formats from fetched main. Shared checkout untouched. + +## Intent/current state + +RDF is absent. Its typed/language-tagged literals and named graphs require more than a label-only mapping. `src/types.ts` Graph has generic JSON data; no new core fields needed. `src/formats/graphlib/index.ts` exemplifies native carrier and `createGraphDocumentConverter`; document core is synchronous. Plan defines terms/dataset plus N-Triples/N-Quads and Turtle/TriG using a maintained optional parser. JSON-LD processing is async and context-sensitive: record as follow-up, never pretend synchronous support. RDF1.2 triple terms/directional literals require verified parser support or explicit rejection, not silent downgrade. Use authoritative W3C current-status specs; explicitly version the implemented profile. + +## Decision + +Adopted recommended RDF subject/object term nodes and directed statement edges with predicate and named-graph term in data, following unanswered optional preference question and the user's authorization for common-sense choices; assumption announced before dependent implementation. Predicates/named graphs remain edge data with exact terms, document this scope. Literals retain lexical form, language and datatype; numeric lexical values never JS-coerce. Named graph identity is not hierarchy. Keep optional RDF1.1 dataset profile; no reasoning/entailment. If user subsequently selects property mapping, pause and reconcile this plan before implementation continues. + +## Ownership + +Own `src/formats/rdf/**`, `tests/rdf*.test.ts`, matching native tests/fixtures/helpers and RDF README. Other integration executor handles exports/dependencies/locks/support/build/config/contracts/root docs/Changeset; coordinate API and exact peers. Never edit other's files or plans; no commits before approval. + +## Steps + +1. Drift check `git diff --stat 8d1b139..HEAD -- src/types.ts src/formats/document src/formats/preservation.ts` empty. Read AGENTS and existing converter/document/preservation APIs. Wait for reviewer confirming RDF mapping before dependent implementation. Can research native syntax/parser meanwhile. +2. Define plain serializable term/quad DTOs and from/to dataset API. Preserve IRIs vs blank nodes vs literals, default/named graph identity, lexical datatype/language, repeated statement set semantics explicit, blank scope and duplicates. Validate RDF roles (predicate IRI, subject nonliteral) and dangling graph endpoints; native IDs collision-safe. No hidden class instances/BigInt values. Fresh arbitraryGraph→RDF requires explicit term/predicate data or throws; do not invent IRIs from labels. +3. Add scoped syntax APIs NTriples/NQuads/Turtle/TriG using maintained optional n3 parser; no handwritten partial Turtle grammar. Strict format selection and version rejection. NTriples export valid Turtle is acceptable scoped emitter; named graph→triple-only reject. Preserve exact source comments/prefixes/base on document no-op. Initially allow exact source edits and explicit regeneration; unsupported graph patches must reject with diagnostics instead of stale replay. JSON-LD remains explicit follow-up if sync boundary cannot be satisfied. +4. Tests: different numeric lexical forms, literal term identity vs IRI strings, tags/datatypes, blank node scope, named graphs, duplicate statements, RDF collections via parser, prefixed/base IRIs, escaped Unicode/control strings, invalid term positions/profile versions, document persistence/comments/source edits/conflicts/regen losses, sparse10k statements. Independent pinned RDFLib or W3C fixtures prove dataset isomorphism and syntax acceptance; parser and emitter selfroundtrip not sufficient. RDF1.2 unsupported terms reject while document source survives. +5. README symbolic references and concrete example; tell integration owner exports/peer versions/coverage floors. Run focused tests, types, independent native checks. + +## Commands/done + +Install exactdeps by integrationowner; `pnpm exec vitest run tests/rdf*.test.ts`; `pnpm typecheck`; `pnpm typecheck:repo`; focused actualNode24 native tests; reviewer full `pnpm verify` and combinedcoverage. All exit0, useful semantic native assertions, explicit RDF version/profile, no complete-semantic-interpretation claim, ownedfilesonly. Report COMPLETE/STOPPED with actualcommands/files/deviations. + +STOP for user decision conflicting with mapping, required async coreAPI change, RDF term unsupported but currently being silently discarded, or required shared-file edits. Maintainer should gate future JSONLD/RDF1.2 additions with their own semantic/native proof. diff --git a/plans/009-reject-ambiguous-source-identities.md b/plans/009-reject-ambiguous-source-identities.md new file mode 100644 index 0000000..0375efc --- /dev/null +++ b/plans/009-reject-ambiguous-source-identities.md @@ -0,0 +1,29 @@ +# Plan 009: Reject wrong-entity document updates after source identity drift + +Planned at `8d1b139`, 2026-10-12. P1 bug; M effort; MED risk; HIGH confidence. Depends on DONE003. Dedicated worktree `/Users/davidkpiano/.codex/worktrees/graph/domain-formats`, branch feature/domain-formats. Shared checkout preserved. + +## Why/current state + +Reviewer independently reproduced DGML label edits moving to a newly inserted parallel link. Read-only audit reproduced the same successful wrong-target result in DOT, TGF and Graphviz JSON. GraphML/GEXF/GML normally reject edits to generated IDs, but insertion of an explicit ID matching a baseline-generated ID bypasses their current-source-only guard. Reparse proof checks the wrong identity successfully, so it cannot solve this at document-core level. + +Own reads at base: DOT `src/formats/dot/document.ts:123` chooses `context.sourceGraph.edges.findIndex(id)` then current statement; TGF `src/formats/preservation.ts:292` patches `edgeLines[i]`; Graphviz JSON `index.ts:133` maps `graphviz:edge:${_gvid}`, where `_gvid` MUST equal array position (`:73`), then document patch `:188` creates a fresh source baseline. XML `src/formats/xml-document.ts:198` checks current element `id` only. GML `src/formats/gml/document.ts:89` likewise checks current parsed native ID only. Existing helper style is prefixed standalone functions returning `GraphDocumentPatchResult` diagnostics; immutable string edits assembled by `getEditedSource` and verified by generic document core. Do not weaken that proof or change Graph/public core contracts. + +## Scope/ownership + +Executor owns `src/formats/dot/document.ts`, `src/formats/gml/document.ts`, `src/formats/xml-document.ts`, `src/formats/preservation.ts` **TGF/D3/Cytoscape document branches and their scoped identity guards only**, `src/formats/graphviz-json/index.ts` document identity guards; corresponding focused tests (prefer one `tests/document-identity.test.ts`), relevant native tests if needed; only affected DOT/TGF/GML/GraphML/GEXF/Graphviz JSON/D3/Cytoscape README sections. DGML is already being fixed by separate owner; RDF frozen. Shared package/support/docs/Changeset integration belongs to structured_adapters. You are not alone: preserve their edits, no root/shared-file edits beyond listed helper scopes. No commit/push; reviewer maintains plans and publication. + +## Steps + +1. Drift check `git diff --stat 8d1b139..HEAD --` owned paths; compare excerpts if changed. Use installed exact lockfile dependencies; read AGENTS/readme skill. +2. Add RED regressions. DOT: `digraph {a;b;a->b[label="Same",Marker="original"];}`; source inserts same ends/label Marker=inserted before it; Graph edge0 label edit currently touches inserted edge. TGF baseline `a A\nb B\n#\na b Same\n`; source prepends `\ta\tb\tSame` edge; edit touches inserted line. Graphviz JSON valid unlaid objects `_gvid:0 a`, `_gvid:1 b`, original edge `_gvid:0,tail:0,head:1,label:Same,Marker:original`; source prepends same ends/label Marker:inserted, renumbers original to `_gvid:1`; edit changes inserted record. XML/GML baseline idless edge a→b labelSame markeroriginal; source prepends explicitly identified edge with generated baseline ID `a-b-0` GraphML / `e0` GEXF/GML; edit currently changes inserted record. Add removal/reordering controls with distinct opaque data/whitespace, source-only authoritative changes, node-label edits by stable ID, stable explicitly declared and genuine private IDs where existing codec supports them. +3. Guard positional identity against baseline/current native records before Graph edge patches. Conservative exact native edge sequence comparison is acceptable when baseline has no independently stable identity; source changes outside edge records can still merge. Graphviz `_gvid` is an ordinal, never durable merely because present. XML/GML must verify unique explicit/native private identity in BOTH baseline and current projected scopes; a current ID cannot retroactively make a baseline-generated ID explicit. Scope-aware XML traversal must exclude other graphs/namespaces. Do not ban ordinary stable-ID label edits or source-only updates. Reject ambiguity with path/code diagnostics, no stale replay or lossy regeneration by default. + Scope reconciliation: root independently reproduced the explicit-current-ID collision in D3 and Cytoscape. Baseline D3 `{nodes:[{id:'a'},{id:'b'}],links:[{source:'a',target:'b',label:'Same',Marker:'original'}]}`; Cytoscape equivalent `elements` node records and idless edge `data`. Both project `e0`; inserting an explicit `id:'e0'` same-endpoint/label edge before the original makes `.to` patch the inserted record. Add both RED/GREEN controls; only verified baseline/current native IDs or unchanged idless native sequences may allow patches. Stable explicit/private IDs, source-only edits and unrelated stable node patches remain valid. +4. Avoid quadratic rescans: precompute baseline/current record maps/sequences once per update, not per label. Keep existing limits/behavior otherwise. Public APIs unchanged; affected READMEs describe strict ambiguity scope. Inform integration owner of evidence/Changeset text. + Graphviz JSON control found a related safe-rejection gap: generic restoration treats changed `_gvid`-derived IDs as semantic ID edits, losing verified private original edge IDs after renumbering. A local edge-projection repair may preserve private original identity across ordinal-only changes (do not change shared restorer). Verify native protocol identity in both snapshots, uniqueness and endpoint references; forged/duplicate/private-header-only cases must not permit wrong-target patches. Generated IDs still require conservative sequence guards. Cluster/node identity scope is unchanged. +5. Run focused regression suite RED→GREEN, existing document/converter tests, source/repo types, conventions, `git diff --check`. Reviewer runs full verify and actualNode24 native combined coverage; no lowering floors. Native schema/compiler controls can establish selected valid sources, but do not claim full renderer proof. + +## Commands/done + +`pnpm exec vitest run tests/document-identity.test.ts`; existing `tests/document*.test.ts` and relevant converter tests; `pnpm typecheck`; `pnpm typecheck:repo`; `pnpm check:conventions`; reviewer `pnpm verify` and `pnpm dlx node@24.15.0 node_modules/vitest/vitest.mjs run --config vitest.coverage.config.ts --coverage` with configured native tools. All exit0; all six wrong-target repros throw ambiguity diagnostics; stable-ID/source-only controls pass; no out-of-scope edits or weakened verification. Report STATUS COMPLETE/STOPPED, exact STEPS commands/results, FILES CHANGED, NOTES/deviations. + +STOP if generic document-core change becomes necessary, stable-ID support must break, or identity ambiguity cannot be rejected with scoped source evidence. Capture future positional-ID converters as follow-up findings rather than silently broadening this repair. diff --git a/plans/010-complete-document-identity-guards.md b/plans/010-complete-document-identity-guards.md new file mode 100644 index 0000000..60a6423 --- /dev/null +++ b/plans/010-complete-document-identity-guards.md @@ -0,0 +1,31 @@ +# Plan 010: Close the remaining verified document identity gaps + +Planned at `8d1b139`, 2026-10-12. P1 bug; effort M; risk MED; HIGH confidence. Depends on 009's frozen eight-format helper fixes. Worktree `/Users/davidkpiano/.codex/worktrees/graph/domain-formats`, feature/domain-formats; no shared checkout changes. + +## Why/current state + +Read-only audit found five further successful wrong-target updates: JGF, Graphology, NetworkX implicit multigraph keys, PlantUML and Pajek. A pending Graph label edit modifies an inserted edge after source insertion; intended original remains unchanged. Root inspected JGF `index.ts:71` (`edge.id ?? e${index}`), Graphology `index.ts:47` (missing-key generated IDs), NetworkX `index.ts:49` (per-pair implicit keys), PlantUML `index.ts:196` (current projected index to relationship line), Pajek `index.ts:186` (only compares nonlabel token contents), and shared `getAttributeDocumentPatch`. Reparse proof cannot establish identity when the same generated key changes referent. + +## Ownership/scope + +Own `src/formats/structured-json.ts` scoped Graphology/NetworkX identity guards; format-specific Graphology/NetworkX document callback guards if needed; `src/formats/preservation.ts` **JGF document guard only** (009 changes frozen, preserve them); `src/formats/plantuml/index.ts` and `src/formats/pajek/index.ts` scoped document identity guards; new `tests/document-identity-additional.test.ts`, affected native controls if useful, five affected format README sections. Shared integration/support/native docs/Changeset is structured_adapters' responsibility; root maintains plans. DGML/CX2/RDF remain frozen. You are not alone; do not revert others or edit document core. No commits/push before review. + +## Steps + +1. Read AGENTS and README skill. Compare `git diff 8d1b139 --` scoped paths to current code; shared preservation already contains 009, adapt to it. Exact dependencies installed. Host default Node changed to22.11; use `env PATH=/Users/davidkpiano/.nvm/versions/node/v24.17.0/bin:$PATH pnpm ...` (verified project pnpm10.28.2), not global environment edits. +2. RED tests using exact audit inputs: + - JGF: `{graph:{nodes:[{id:'a'},{id:'b'}],edges:[{source:'a',target:'b',label:'Same',metadata:{Marker:'original'}}]}}`; prepend same ends/label `id:'e0',metadata:{Marker:'inserted'}`. + - Graphology: `{options:{type:'directed',multi:true},attributes:{},nodes:[{key:'a'},{key:'b'}],edges:[{source:'a',target:'b',attributes:{label:'Same',Marker:'original'}}]}`; prepend same ends/label `key:'e0',attributes:{label:'Same',Marker:'inserted'}`. + - NetworkX: `{directed:true,multigraph:true,graph:{},nodes:[{id:'a'},{id:'b'}],edges:[{source:'a',target:'b',label:'Same',Marker:'original'}]}`; prepend same ends/label `key:0,Marker:'inserted'`. + - PlantUML: `@startuml\ncomponent "A" as a\ncomponent "B" as b\n' original\na --> b : Same\n@enduml`; prepend `' inserted` and identical relationship before original comment/relationship. + - Pajek: `*Vertices 2\n1 "A"\n2 "B"\n*Arcs\n1 2 1 l "Same"\n`; replace relationship with `% inserted` identical row then `% original` identical row. + In each `.from(base)`, mutate `.source`, then `graph.edges[0].label='Graph edit'`; `.to` currently succeeds on inserted record. Also test deletion/reorder, source-only updates, stable explicit IDs and unaffected stable node patches. Add genuine private sidecar replay controls, particularly Pajek/PlantUML occurrence-based sidecars that might still attach an old ID to a new physical row. +3. Explicit/native identities must be verified in BOTH baseline/current sources. Never let a current explicit ID/key retroactively legitimize a generated baseline identity. NetworkX simple endpoints are intrinsic stable keys; multigraph missing keys are generated and need guards. Honor Python-equivalent key identity where already supported. Missing Graphology/JGF keys may allow patches only with unchanged corresponding native edge sequence. PlantUML/Pajek native relationships are ordinal/occurrence based: conservative unchanged raw relationship section/sequence guard is acceptable, including comments that distinguish otherwise identical rows. Versioned metadata keyed by occurrence alone is not durable after parallel insertion; guard it too. Preserve source-only authority and existing nonambiguous stable-ID operations. Return path/code diagnostics, no lossy fallback or stale replay. +4. Precompute maps/sequences once, no per-label rescanning. Avoid changes to imports/emission/native schema outside identity needs. Update README strict editing scopes, and inform shared owner of fixes/evidence. +5. GREEN focused tests plus existing new/legacy identity suites, source/repo types/conventions/diffcheck; reviewer reruns full verify and actualNode24 native coverage, no floor reductions. Optional native controls may characterize supported inputs but no renderer/full-losslessness claims. + +## Commands/done + +With explicit Node24 PATH: `pnpm exec vitest run tests/document-identity-additional.test.ts tests/document-identity.test.ts`; relevant converter/document tests; `pnpm typecheck`, `pnpm typecheck:repo`, `pnpm check:conventions`, `git diff --check`. All pass, five original wrong-target cases reject, stable source-only/ID controls pass, only scoped edits. Report STATUS COMPLETE/STOPPED, actual command results, files and deviations. Shared coverage dir may be used by another executor; coordinate before native coverage. + +STOP if fixing requires core API changes, a stable native identity operation breaks without justification, or more source ownership is required. Audit controls already found no failures in Graphlib, explicit-ID ELK/drawio, D2/supported Mermaid node patches, matrices or CSV required IDs; do not broaden those without concrete evidence. diff --git a/plans/011-domain-formats-verification.md b/plans/011-domain-formats-verification.md new file mode 100644 index 0000000..897cf55 --- /dev/null +++ b/plans/011-domain-formats-verification.md @@ -0,0 +1,166 @@ +# Domain-format follow-up verification + +Follow-up to merged PR53, reviewed 2026-10-12. Dedicated worktree +`/Users/davidkpiano/.codex/worktrees/graph/domain-formats`, branch +`feature/domain-formats`; shared checkout preserved. Initial main base +`8d1b139`; advanced to current main `5141455` (version-only release) before +final verification. + +## Scope + +Four explicit domain mappings: DGML, CX2, GraphSON3 typed TinkerGraph objects, +and RDF1.1 datasets plus N-Triples/N-Quads/Turtle/TriG syntax. Five package +subpaths have runtime/declaration contracts. RDF dataset, GraphSON and CX2 +modules remain dependency-free; DGML uses optional fast-xml-parser and RDF +syntax uses optional N3. + +The identity audit reproduced wrong-target concurrent-source edits in thirteen +existing converters: DOT, TGF, Graphviz JSON, GraphML, GEXF, GML, D3, +Cytoscape, JGF, Graphology, NetworkX, PlantUML and Pajek. New DGML has the +same positional-identity boundary. Fixes reject ambiguous pending Graph edits +with `ambiguous-native-identity` and an entity path. Explicit IDs must establish +correspondence in both source snapshots; current-only declarations cannot +retroactively validate generated IDs. Occurrence-indexed private metadata cannot +identify an inserted parallel relationship. Source-only edits and verified stable +identities retain their documented behavior. + +## Reviewer gates + +- `pnpm install --frozen-lockfile`: passed with project pnpm10.28.2. +- `pnpm verify` on actual Node24.17.0: passed; 4,273 core tests / 111 files, + source/repository types, generated-schema/convention checks, build, publint, + packed runtime/type smoke including every added subpath. +- Actual Node20.20.2 core run: 4,273 tests / 111 files passed. +- Actual Node24.17.0 combined native/coverage run: 4,532 tests / 133 files passed, + including 259 tests in native-validation suites. All committed coverage floors passed. +- Converter coverage: 96.78% lines, 86.92% branches, 95.42% functions, + 92.54% statements. Existing global thresholds were not lowered. +- Both legacy identity suites pass: 83 regressions/positive controls. New DGML + identity controls also pass within its format suite. +- Focused executor checks: all source/repository types and conventions pass; + no core/API redesign required. Reviewer reran the complete sequential gates. +- Shared checkout retains only its pre-existing untracked `docs/meta.json`. + +Native tooling used isolated Python3.14.8 with pinned requirements and Java +21.0.12.1 / PlantUML1.2026.8. The first executor PlantUML command failed because +the new worktree lacked its default Java path; explicit existing pinned-runtime +overrides resolved it. Final combined validation uses those same overrides and +passes. No global runtime changes were made by this task. + +## Per-converter code coverage + +Measured by the final combined suite; source modules listed separately where +they provide distinct parsing, emission or document behavior. Export barrels +omitted. Values characterize code execution, not grammar completeness. + + + +| Module | Lines % | Branches % | +| --- | ---: | ---: | +| adjacency-list/index.ts | 97.67 | 89.18 | +| adjacency-matrix/index.ts | 97.95 | 92.04 | +| converter/index.ts | 100.00 | 100.00 | +| csv/index.ts | 99.40 | 89.10 | +| cx2/index.ts | 93.72 | 88.67 | +| cytoscape/index.ts | 98.14 | 95.95 | +| d2/emitter.ts | 92.00 | 78.32 | +| d2/index.ts | 100.00 | 100.00 | +| d2/parser.ts | 91.88 | 75.73 | +| d3/index.ts | 97.22 | 87.17 | +| dgml/index.ts | 92.45 | 85.77 | +| document/index.ts | 98.74 | 90.08 | +| dot/document.ts | 100.00 | 87.43 | +| dot/index.ts | 97.77 | 90.54 | +| drawio/index.ts | 100.00 | 82.29 | +| edge-list/index.ts | 87.50 | 86.66 | +| elk/index.ts | 99.53 | 93.18 | +| gexf/index.ts | 99.20 | 92.75 | +| gml/document.ts | 100.00 | 84.61 | +| gml/index.ts | 98.55 | 93.03 | +| graphlib/index.ts | 98.75 | 94.64 | +| graphml/index.ts | 99.28 | 94.21 | +| graphology/index.ts | 98.73 | 86.15 | +| graphson/index.ts | 93.75 | 87.39 | +| graphviz-json/index.ts | 99.31 | 88.88 | +| index.ts | 100.00 | 100.00 | +| jgf/index.ts | 99.00 | 95.43 | +| matrix-market/index.ts | 96.89 | 83.13 | +| mermaid/architecture.ts | 100.00 | 88.52 | +| mermaid/block.ts | 95.52 | 80.95 | +| mermaid/c4.ts | 98.64 | 83.66 | +| mermaid/class-diagram.ts | 92.37 | 80.20 | +| mermaid/document.ts | 100.00 | 89.65 | +| mermaid/er-diagram.ts | 98.90 | 82.89 | +| mermaid/flowchart.ts | 92.87 | 80.51 | +| mermaid/git-graph.ts | 100.00 | 90.37 | +| mermaid/index.ts | 100.00 | 100.00 | +| mermaid/ishikawa.ts | 100.00 | 85.71 | +| mermaid/mindmap.ts | 100.00 | 89.55 | +| mermaid/profile-shared.ts | 100.00 | 87.50 | +| mermaid/requirement.ts | 100.00 | 89.21 | +| mermaid/sankey.ts | 100.00 | 88.09 | +| mermaid/sequence.ts | 95.32 | 83.08 | +| mermaid/shared.ts | 99.13 | 93.16 | +| mermaid/state.ts | 98.45 | 88.55 | +| mermaid/use-case.ts | 100.00 | 85.71 | +| networkx/index.ts | 98.87 | 96.59 | +| pajek/index.ts | 98.73 | 84.21 | +| plantuml/index.ts | 100.00 | 84.81 | +| preservation.ts | 93.63 | 77.52 | +| rdf/index.ts | 98.90 | 94.63 | +| rdf/syntax.ts | 97.46 | 83.87 | +| structured-json.ts | 96.99 | 87.50 | +| tgf/index.ts | 97.82 | 88.88 | +| xml-document.ts | 98.25 | 88.68 | +| xyflow/index.ts | 97.77 | 96.26 | + + +## Independent native evidence + +| Profile | Validator | Evidence and limits | +| --- | --- | --- | +| GraphSON3 `tinker:graph` | Apache TinkerPop Java3.8.2; Gremlin JavaScript3.8.2 | Actual registered mapper reads topology, ID classes, unsafe magnitudes, multi/meta-properties, maps/sets and patched values. Java establishes whole-graph registry behavior. NDJSON/writeGraph/other providers remain outside scope. | +| DGML2009 | Official Microsoft XSD through libxml2; XML DOM | Selected native/emitted schema profiles and attribute normalization. Published XSD rejects dynamic property attributes documented by Microsoft; CLR property mapping is separately scoped. No Visual Studio renderer claim. | +| CX2 version2.0 | Maintained NDEx ndex2 3.12.0 | Topology, declarations/defaults and attributes. Reader omission of false/zero defaults and null typed values has negative controls. Adapter applies falsy defaults; null typed values reject projection. Visual/custom aspects remain opaque. | +| RDF1.1 | N3 2.13.12; independent RDFLib7.6.0 | Strict grammars, scoped blank-node dataset isomorphism and lexical/datatype/language fields. Native parser events observe empty-graph declarations and tag case before unchanged store equality normalizes them. Numeric shorthand normalization is outside author-profile proof. | + +TinkerPop setup downloads twelve SHA256-pinned official Maven artifacts; no +development oracle enters runtime imports or the published package. Native +subprocess deadlines and existing global coverage floors remain unchanged. + +## Preservation contract + +Coverage measures executed code, not native-language grammar completeness. +No converter claims unrestricted 100% Graph/native semantic round-tripping. +Documents retain supplied source text or native JSON values exactly on unchanged +export after JSON persistence, including comments, ordering and opaque fields. +Projection can be partial or null. Supported edits require native identity and +reparse proof; unsupported/conflicting edits reject. Explicit regeneration can +change comments/spelling/blank IDs and reports losses. File-byte encodings and +runtime object alias identity lie outside string/JSON APIs. + +Strict new profiles reject incompatible modes, identities, Graph-only fields or +types rather than guessing. GraphSON's string marker preserves additional Graph +fields as an application convention; a native TinkerPop rewrite discards it. +DGML native links lack stable IDs; `edgeIds: 'positional'` is an explicit identity +normalization choice. CX2 declarations govern types and all-node coordinates. +RDF subjects/objects are term nodes; edges retain exact predicates/graph names, +including scoped blanks and empty named graphs. It does not provide reasoning. + +N3's explicit `^^rdf:dirLangString` RDF1.1 parser gap is tested/documented; +DTO/emission retain that ordinary datatype and unsupported syntax documents +retain exact source with unavailable projection. RDF IRI/language checks cover +the documented lexical profile, not a full RFC3987/registered-tag validator. + +## Deferred representations + +JSON-LD remote-context/asynchronous processing, RDF/XML, RDF1.2 terms, +GraphSON1/2/adjacency formats and graph6/digraph6/sparse6 remain separate work. +RDF syntax parsing/emission stays synchronous. These representations are not +advertised as implemented. + +See the original [verification report](005-final-verification.md) for historical +PR53 evidence and the unchanged non-domain native-validator scopes. +See [native validation](../docs/native-validation.md), +[support inventory](../src/formats/support.ts), affected format READMEs and +plans006–010 for the precise mapped profiles and regression scopes. diff --git a/plans/README.md b/plans/README.md index 42dcf94..7cf49e5 100644 --- a/plans/README.md +++ b/plans/README.md @@ -9,8 +9,28 @@ Planned at `de5ebf8`, 2026-10-12. User authorized implementation, common-sense A | 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. +Source edits belong to executors. Plans001–004 used `/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. + +## Post-merge follow-up + +PR53 merged at `8d1b139`. New isolated worktree `/Users/davidkpiano/.codex/worktrees/graph/domain-formats`, branch `feature/domain-formats`, fetched main base. Advanced to main `5141455` (version-only release) before final gates. Original worktree retained. + +| Plan | Work | Depends on | Status | +| --- | --- | --- | --- | +| 006 | DGML and CX2 explicit semantic profiles | 001–004 | DONE: strict profiles, native validators, reviewer full gates pass | +| 007 | GraphSON explicit profile and shared integration | 001–004; integration after 006/008 | DONE: typed TinkerGraph, native mapper, all subpath/runtime/type contracts pass | +| 008 | RDF dataset/NTriples/NQuads/Turtle/TriG | 001–004; term-node profile adopted | DONE: term-node profile, strict syntax, independent native proof; full gates pass | +| 009 | Reject concurrent-source wrong-entity document edits | 003 | DONE: eight legacy guards; DGML guarded in 006; full gates pass | +| 010 | Complete identity guards in five additional converters | 009 helpers frozen | DONE: five additional guards; 83 total legacy identity controls; full gates pass | +| 011 | Independent final verification and scope report | 006–010 | DONE: 4,273 Node20 core / 4,532 Node24 combined tests, package gate, 96.78% lines / 86.92% branches | + +JSON-LD asynchronous/context processing, full RDF1.2 terms and graph6 family remain explicitly scoped follow-ups. No new format may equate source retention with full semantic interpretation. + +009/010 take precedence over publication: native generated/ordinal IDs cannot be treated as stable entity identity across source insertion/reordering. Existing stable-ID edits and source-only updates remain supported. + +Current [verification report](011-domain-formats-verification.md) includes every +converter's measured coverage, independent native evidence and remaining limits. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ce56ed..b1e4665 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@types/jsdom': specifier: 30.0.0 version: 30.0.0 + '@types/n3': + specifier: 1.26.4 + version: 1.26.4 '@types/node': specifier: ^25.0.3 version: 25.9.3 @@ -104,12 +107,18 @@ importers: graphology-types: specifier: ^0.24.8 version: 0.24.8 + gremlin: + specifier: 3.8.2 + version: 3.8.2 jsdom: specifier: 30.1.2 version: 30.1.2 mermaid: specifier: 12.1.0 version: 12.1.0 + n3: + specifier: 2.13.12 + version: 2.13.12 ngraph.graph: specifier: ^20.1.2 version: 20.1.2 @@ -572,6 +581,9 @@ packages: '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==, tarball: https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz} + '@rdfjs/types@2.0.1': + resolution: {integrity: sha512-uyAzpugX7KekAXAHq26m3JlUIZJOC0uSBhpnefGV5i15bevDyyejoB7I+9MKeUrzXD8OOUI3+4FeV1wwQr5ihA==, tarball: https://registry.npmjs.org/@rdfjs/types/-/types-2.0.1.tgz} + '@rolldown/binding-android-arm64@1.0.0-beta.57': resolution: {integrity: sha512-GoOVDy8bjw9z1K30Oo803nSzXJS/vWhFijFsW3kzvZCO8IZwFnNa6pGctmbbJstKl3Fv6UBwyjJQN6msejW0IQ==, tarball: https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.57.tgz} engines: {node: ^20.19.0 || >=22.12.0} @@ -972,6 +984,9 @@ packages: '@types/jsdom@30.0.0': resolution: {integrity: sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==, tarball: https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz} + '@types/n3@1.26.4': + resolution: {integrity: sha512-PFEJWfs9mL7yLgjHpmVI/BfpZZPrsaDkUN8YiQXJoz8MkBsCYXys0DSo0r20y3+zqQupbUqxAYb8fFe8kbCbTQ==, tarball: https://registry.npmjs.org/@types/n3/-/n3-1.26.4.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} @@ -1031,6 +1046,10 @@ 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} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==, tarball: https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz} + engines: {node: '>=6.5'} + 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: @@ -1081,6 +1100,9 @@ packages: ast-v8-to-istanbul@1.0.4: resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==, tarball: https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==, tarball: https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz} + better-path-resolve@1.0.0: 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'} @@ -1095,6 +1117,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, tarball: https://registry.npmjs.org/braces/-/braces-3.0.3.tgz} engines: {node: '>=8'} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==, tarball: https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz} + bumpp@10.4.1: resolution: {integrity: sha512-X/bwWs5Gbb/D7rN4aHLB7zdjiA6nGdjckM1sTHhI9oovIbEw2L5pw5S4xzk8ZTeOZ8EnwU/Ze4SoZ6/Vr3pM2Q==, tarball: https://registry.npmjs.org/bumpp/-/bumpp-10.4.1.tgz} engines: {node: '>=18'} @@ -1424,6 +1449,13 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, tarball: https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==, tarball: https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz} + engines: {node: '>=6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==, tarball: https://registry.npmjs.org/events/-/events-3.3.0.tgz} engines: {node: '>=0.8.x'} @@ -1547,6 +1579,10 @@ packages: peerDependencies: graphology-types: '>=0.24.0' + gremlin@3.8.2: + resolution: {integrity: sha512-n/Z8btrDG7Umx1yNSKlbTx8nyrG0Sja53cXh/Tr/vXoXobDG9LeS3aEL2CI3B0Ew56U/TztgeUF0c6gy7cZhmA==, tarball: https://registry.npmjs.org/gremlin/-/gremlin-3.8.2.tgz} + engines: {node: '>=20'} + 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} @@ -1576,6 +1612,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==, tarball: https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==, tarball: https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, tarball: https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz} engines: {node: '>= 4'} @@ -1808,6 +1847,10 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==, tarball: https://registry.npmjs.org/mri/-/mri-1.2.0.tgz} engines: {node: '>=4'} + n3@2.13.12: + resolution: {integrity: sha512-z944NsyJi58WXcYevtNkUL2JhVr0KP6nvED9AgymQq5V35TDaNn1N8kO+OWJk/kj4PR0L/ilWB3d7/bWceg1CQ==, tarball: https://registry.npmjs.org/n3/-/n3-2.13.12.tgz} + engines: {node: '>=12.0'} + nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1937,6 +1980,10 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==, tarball: https://registry.npmjs.org/process/-/process-0.11.10.tgz} + engines: {node: '>= 0.6.0'} + publint@0.3.21: resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==, tarball: https://registry.npmjs.org/publint/-/publint-0.3.21.tgz} engines: {node: '>=18'} @@ -1962,6 +2009,10 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==, tarball: https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz} engines: {node: '>=6'} + readable-stream@4.8.0: + resolution: {integrity: sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==, tarball: https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz} engines: {node: '>= 20.19.0'} @@ -2031,6 +2082,9 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==, tarball: https://registry.npmjs.org/sade/-/sade-1.8.1.tgz} engines: {node: '>=6'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, tarball: https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, tarball: https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz} @@ -2078,6 +2132,9 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==, tarball: https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==, tarball: https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==, tarball: https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz} engines: {node: '>=8'} @@ -2214,6 +2271,10 @@ packages: synckit: optional: true + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==, tarball: https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz} + hasBin: true + uuid@14.0.3: resolution: {integrity: sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==, tarball: https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz} hasBin: true @@ -2337,6 +2398,18 @@ packages: engines: {node: '>=8'} hasBin: true + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==, tarball: https://registry.npmjs.org/ws/-/ws-8.22.0.tgz} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: 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'} @@ -2810,6 +2883,10 @@ snapshots: dependencies: quansync: 1.0.0 + '@rdfjs/types@2.0.1': + dependencies: + '@types/node': 25.9.3 + '@rolldown/binding-android-arm64@1.0.0-beta.57': optional: true @@ -3107,6 +3184,11 @@ snapshots: parse5: 8.0.1 undici-types: 8.11.2 + '@types/n3@1.26.4': + dependencies: + '@rdfjs/types': 2.0.1 + '@types/node': 25.9.3 + '@types/node@12.20.55': {} '@types/node@25.9.3': @@ -3192,6 +3274,10 @@ snapshots: '@yomguithereal/helpers@1.1.1': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + ajv-draft-04@1.0.0(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -3234,6 +3320,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + base64-js@1.5.1: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -3248,6 +3336,11 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bumpp@10.4.1(magicast@0.5.3): dependencies: ansis: 4.3.1 @@ -3618,6 +3711,10 @@ snapshots: dependencies: '@types/estree': 1.0.9 + event-target-shim@5.0.1: {} + + eventemitter3@5.0.4: {} + events@3.3.0: {} expect-type@1.3.0: {} @@ -3751,6 +3848,17 @@ snapshots: events: 3.3.0 graphology-types: 0.24.8 + gremlin@3.8.2: + dependencies: + buffer: 6.0.3 + eventemitter3: 5.0.4 + readable-stream: 4.8.0 + uuid: 11.1.1 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + hachure-fill@0.5.2: {} has-flag@4.0.0: {} @@ -3775,6 +3883,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} import-meta-resolve@4.2.0: {} @@ -3990,6 +4100,11 @@ snapshots: mri@1.2.0: {} + n3@2.13.12: + dependencies: + buffer: 6.0.3 + readable-stream: 4.8.0 + nanoid@3.3.15: {} ngraph.events@1.4.0: {} @@ -4087,6 +4202,8 @@ snapshots: prettier@2.8.8: {} + process@0.11.10: {} + publint@0.3.21: dependencies: '@publint/pack': 0.1.4 @@ -4114,6 +4231,14 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readable-stream@4.8.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@5.0.0: {} require-from-string@2.0.2: {} @@ -4223,6 +4348,8 @@ snapshots: dependencies: mri: 1.2.0 + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} saxes@6.0.0: @@ -4256,6 +4383,10 @@ snapshots: std-env@4.1.0: {} + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -4369,6 +4500,8 @@ snapshots: dependencies: rolldown: 1.0.0-rc.17 + uuid@11.1.1: {} + 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): @@ -4454,6 +4587,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + ws@8.22.0: {} + xml-name-validator@5.0.0: {} xml-naming@0.1.0: {} diff --git a/requirements/native.txt b/requirements/native.txt index 9ff2770..898ed05 100644 --- a/requirements/native.txt +++ b/requirements/native.txt @@ -4,3 +4,7 @@ networkx==3.7 scipy==1.18.1 numpy==2.5.4 igraph==1.0.0 + +# Domain interchange native readers +ndex2==3.12.0 +rdflib==7.6.0 diff --git a/scripts/native/graphson-tools.json b/scripts/native/graphson-tools.json new file mode 100644 index 0000000..8c5c974 --- /dev/null +++ b/scripts/native/graphson-tools.json @@ -0,0 +1,71 @@ +{ + "version": "3.8.2", + "profile": "GraphSON3 tinker:graph with TinkerIoRegistryV3 and GraphSONXModuleV3", + "url": "https://dlcdn.apache.org/tinkerpop/3.8.2/apache-tinkerpop-gremlin-console-3.8.2-bin.zip", + "sha512": "2c232c4d7a03652777ae6a103a82373a3538ba4b9aef5f6cc1e7fa67dddec856f77faa9034a1f8866734328ea3075a9272682f662546766f863d85af43fec591", + "license": "Apache-2.0", + "source": "https://github.com/apache/tinkerpop/tree/3.8.2", + "artifacts": [ + { + "filename": "commons-configuration2-2.15.1.jar", + "url": "https://repo.maven.apache.org/maven2/org/apache/commons/commons-configuration2/2.15.1/commons-configuration2-2.15.1.jar", + "sha256": "61ba5eaab8a17087042fe3d6811573bb85ccbdd6bd9d21ad464e6e20559dd1e8" + }, + { + "filename": "commons-lang3-3.20.0.jar", + "url": "https://repo.maven.apache.org/maven2/org/apache/commons/commons-lang3/3.20.0/commons-lang3-3.20.0.jar", + "sha256": "69e5c9fa35da7a51a5fd2099dfe56a2d8d32cf233e2f6d770e796146440263f4" + }, + { + "filename": "commons-logging-1.2.jar", + "url": "https://repo.maven.apache.org/maven2/commons-logging/commons-logging/1.2/commons-logging-1.2.jar", + "sha256": "daddea1ea0be0f56978ab3006b8ac92834afeefbd9b7e4e6316fca57df0fa636" + }, + { + "filename": "commons-text-1.15.0.jar", + "url": "https://repo.maven.apache.org/maven2/org/apache/commons/commons-text/1.15.0/commons-text-1.15.0.jar", + "sha256": "58d2da30f058512a1e7f914e39241deca4dff5c27a085b4ed2faa9e7208067f6" + }, + { + "filename": "gremlin-core-3.8.2.jar", + "url": "https://repo.maven.apache.org/maven2/org/apache/tinkerpop/gremlin-core/3.8.2/gremlin-core-3.8.2.jar", + "sha256": "48034089f405c3d85dcfad5de7bb8ad9de3088096510d8d5b162cfc6a1a776f1" + }, + { + "filename": "gremlin-language-3.8.2.jar", + "url": "https://repo.maven.apache.org/maven2/org/apache/tinkerpop/gremlin-language/3.8.2/gremlin-language-3.8.2.jar", + "sha256": "b33ec23253b5168fdaa76f9ffcd21a4464d5cadca7222286108af92d2f91c2e0" + }, + { + "filename": "gremlin-shaded-3.8.2.jar", + "url": "https://repo.maven.apache.org/maven2/org/apache/tinkerpop/gremlin-shaded/3.8.2/gremlin-shaded-3.8.2.jar", + "sha256": "5849dbce1a21409683abebb6fad66827aa7efbf7ebf206bbd4e4747f79fbc727" + }, + { + "filename": "groovy-4.0.32.jar", + "url": "https://repo.maven.apache.org/maven2/org/apache/groovy/groovy/4.0.32/groovy-4.0.32.jar", + "sha256": "d77815c8f31d503bc50129f28892e60b64e339b490f7babf9df70848ce465698" + }, + { + "filename": "groovy-json-4.0.32.jar", + "url": "https://repo.maven.apache.org/maven2/org/apache/groovy/groovy-json/4.0.32/groovy-json-4.0.32.jar", + "sha256": "4e57a3b28e40e646746063e8deb8863a7672f07d3e3fcbb7b408e0962905000c" + }, + { + "filename": "javatuples-1.2.jar", + "url": "https://repo.maven.apache.org/maven2/org/javatuples/javatuples/1.2/javatuples-1.2.jar", + "sha256": "2eda5b19d9820e1cc2f69fcd01639a715a673c11f8507e3d1ed593cf765d5e0a" + }, + { + "filename": "slf4j-api-1.7.25.jar", + "url": "https://repo.maven.apache.org/maven2/org/slf4j/slf4j-api/1.7.25/slf4j-api-1.7.25.jar", + "sha256": "18c4a0095d5c1da6b817592e767bb23d29dd2f560ad74df75ff3961dbde25b79" + }, + { + "filename": "tinkergraph-gremlin-3.8.2.jar", + "url": "https://repo.maven.apache.org/maven2/org/apache/tinkerpop/tinkergraph-gremlin/3.8.2/tinkergraph-gremlin-3.8.2.jar", + "sha256": "e421344adac650d894a256ca68018a31dc44d9a48d69ff389a5c95a02dbf03f4" + } + ], + "artifactProvenance": "Artifact byte hashes derived from the SHA512-verified official console distribution; default setup downloads only the selected Maven Central jars." +} diff --git a/scripts/native/graphson_oracle.groovy b/scripts/native/graphson_oracle.groovy new file mode 100644 index 0000000..7134382 --- /dev/null +++ b/scripts/native/graphson_oracle.groovy @@ -0,0 +1,12 @@ +import org.apache.tinkerpop.gremlin.structure.io.graphson.GraphSONMapper +import org.apache.tinkerpop.gremlin.structure.io.graphson.GraphSONVersion +import org.apache.tinkerpop.gremlin.structure.io.graphson.GraphSONXModuleV3 +import org.apache.tinkerpop.gremlin.structure.io.graphson.TypeInfo +import org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerGraph +import org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerIoRegistryV3 +import groovy.json.JsonOutput + +def mapper=GraphSONMapper.build().version(GraphSONVersion.V3_0).addRegistry(TinkerIoRegistryV3.instance()).addCustomModule(GraphSONXModuleV3.build().create(false, TypeInfo.PARTIAL_TYPES)).create().createMapper() +def input=System.in.text +def graph=input.trim() == 'GENERATE_MODERN' ? org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerFactory.createModern() : mapper.readValue(input,TinkerGraph) +println JsonOutput.toJson([version:org.apache.tinkerpop.gremlin.util.Gremlin.version(),vertices:graph.vertices().collect{[id:it.id().toString(),type:it.id().getClass().name,label:it.label(),properties:it.properties().collect{p->[id:p.id().toString(),key:p.key(),value:mapper.writeValueAsString(p.value()),valueClass:p.value()?.getClass()?.name,meta:p.properties().collect{m->[key:m.key(),value:mapper.writeValueAsString(m.value())]}]}]},edges:graph.edges().collect{[id:it.id().toString(),type:it.id().getClass().name,label:it.label(),source:it.outVertex().id().toString(),target:it.inVertex().id().toString(),properties:it.properties().collect{p->[key:p.key(),value:mapper.writeValueAsString(p.value()),valueClass:p.value()?.getClass()?.name]}]},roundTrip:mapper.writeValueAsString(graph)]) diff --git a/scripts/native/rdflib_oracle.py b/scripts/native/rdflib_oracle.py new file mode 100644 index 0000000..384e7c0 --- /dev/null +++ b/scripts/native/rdflib_oracle.py @@ -0,0 +1,131 @@ +"""Pinned actual RDFLib parser and isomorphism oracle; no adapter grammar emulation.""" +import json +import platform +import sys +from unittest.mock import patch + +import rdflib +from rdflib import BNode, Dataset, Graph, ConjunctiveGraph, Literal, URIRef +from rdflib.compare import isomorphic +from rdflib.plugins.parsers.notation3 import RDFSink + +EXPECTED_VERSION = '7.6.0' +XSD_STRING = 'http://www.w3.org/2001/XMLSchema#string' +LANG_STRING = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#langString' +DEFAULT = URIRef('urn:x-rdflib:default') +rdflib.NORMALIZE_LITERALS = False + + +def get_term(term, default=False): + if default: + return {'termType': 'DefaultGraph', 'value': ''} + if isinstance(term, URIRef): + return {'termType': 'NamedNode', 'value': str(term)} + if isinstance(term, BNode): + return {'termType': 'BlankNode', 'value': str(term), 'scope': 'native'} + if isinstance(term, Literal): + return {'termType': 'Literal', 'value': str(term), 'language': term.language or '', + 'datatype': {'termType': 'NamedNode', 'value': str(term.datatype or (LANG_STRING if term.language else XSD_STRING))}} + raise ValueError(f'Unsupported native RDF term {type(term).__name__}') + + +def get_quad(subject, predicate, obj, graph): + return {'subject': get_term(subject), 'predicate': get_term(predicate), 'object': get_term(obj), + 'graph': get_term(graph, graph == DEFAULT)} + + +def get_unique(values): + return list({json.dumps(value, sort_keys=True): value for value in values}.values()) + + +def get_parse(source, syntax, base=None): + dataset = Dataset() + statements, graph_events = [], [] + original_graph, original_add, original_conj_add = RDFSink.newGraph, Graph.add, ConjunctiveGraph.add + + def get_graph(sink, identifier): + graph_events.append(identifier) + return original_graph(sink, identifier) + + def add_statement(graph, triple): + # Actual readers normalize their own terms before the public Graph.add call. + # Observe the statement before the unmodified store performs set deduplication. + subject, predicate, obj = triple + statements.append(get_quad(subject, predicate, obj, graph.identifier)) + return original_add(graph, triple) + + def add_conj_statement(graph, triple): + subject, predicate, obj = triple + statements.append(get_quad(subject, predicate, obj, graph.default_context.identifier)) + return original_conj_add(graph, triple) + + with patch.object(RDFSink, 'newGraph', get_graph), patch.object(Graph, 'add', add_statement), patch.object(ConjunctiveGraph, 'add', add_conj_statement): + if syntax in ('nt', 'turtle'): + dataset.default_graph.parse(data=source, format=syntax, publicID=base) + else: + dataset.parse(data=source, format=syntax, publicID=base) + stored = [get_quad(s, p, o, g) for s, p, o, g in dataset.quads()] + names = [get_term(graph.identifier) for graph in dataset.graphs() if graph.identifier != DEFAULT] + names.extend(get_term(identifier) for identifier in graph_events if identifier != DEFAULT) + return {'version': '1.1', 'quads': get_unique(statements), 'namedGraphs': get_unique(names)}, { + 'quadCount': len(stored), 'namedGraphCount': len([graph for graph in dataset.graphs() if graph.identifier != DEFAULT]), + 'observer': 'actual RDFSink.newGraph events for TriG plus public Graph/ConjunctiveGraph.add before unmodified store deduplication for all syntaxes'} + + +def get_isomorphism_graph(dataset): + """Encode dataset roles/names as one RDF graph, retaining shared blank identity.""" + graph, blanks = Graph(), {} + marker = 'urn:stately:native-oracle:rdf:' + + def get_native(term): + kind = term['termType'] + if kind == 'NamedNode': + return URIRef(term['value']) + if kind == 'BlankNode': + key = (term.get('scope', ''), term['value']) + if key not in blanks: + blanks[key] = BNode() + return blanks[key] + if kind == 'Literal': + # Preserve exact lexical/datatype/tag fields before RDFLib's language equality. + fields = [term['value'], term['datatype']['value'], term['language']] + return Literal(json.dumps(fields, ensure_ascii=True), datatype=URIRef(marker + 'literal')) + if kind == 'DefaultGraph': + return URIRef(marker + 'default-graph') + raise ValueError('Unsupported expected term') + + # A synthetic typed default node avoids conflating a user IRI with default identity. + default = BNode() + graph.add((default, URIRef(marker + 'role'), URIRef(marker + 'default'))) + names = list(dataset.get('namedGraphs', [])) + for quad in get_unique(dataset['quads']): + statement = BNode() + for role in ('subject', 'predicate', 'object', 'graph'): + term = quad[role] + graph.add((statement, URIRef(marker + role), default if term['termType'] == 'DefaultGraph' else get_native(term))) + if quad['graph']['termType'] != 'DefaultGraph': + names.append(quad['graph']) + for name in get_unique(names): + graph.add((get_native(name), URIRef(marker + 'role'), URIRef(marker + 'named-graph'))) + return graph + + +def get_result(request): + if rdflib.__version__ != EXPECTED_VERSION: + raise RuntimeError(f'Oracle requires RDFLib {EXPECTED_VERSION}, found {rdflib.__version__}') + result = {'oracle': 'rdflib', 'version': rdflib.__version__, 'python': platform.python_version()} + if request.get('operation') == 'version': + return result + syntax = {'N-Triples': 'nt', 'N-Quads': 'nquads', 'Turtle': 'turtle', 'TriG': 'trig'}[request['format']] + dataset, store = get_parse(request['source'], syntax, request.get('baseIRI')) + result.update({'dataset': dataset, 'store': store}) + if 'expected' in request: + result['isomorphic'] = isomorphic(get_isomorphism_graph(dataset), get_isomorphism_graph(request['expected'])) + return result + + +try: + print(json.dumps(get_result(json.load(sys.stdin)), ensure_ascii=True)) +except Exception as error: + print(str(error), file=sys.stderr) + sys.exit(1) diff --git a/scripts/native/setup-graphson.mjs b/scripts/native/setup-graphson.mjs new file mode 100644 index 0000000..634273a --- /dev/null +++ b/scripts/native/setup-graphson.mjs @@ -0,0 +1,19 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; + +const metadata = JSON.parse(await readFile(new URL('./graphson-tools.json', import.meta.url), 'utf8')); +const directory = resolve('.tools-native/graphson/lib'); +await mkdir(directory, { recursive: true }); +for (const artifact of metadata.artifacts) { + const path = resolve(directory, artifact.filename); + let bytes; + try { bytes = await readFile(path); } catch { + const response = await fetch(artifact.url, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) throw new Error(`GraphSON native artifact: HTTP ${response.status} ${artifact.filename}`); + bytes = Buffer.from(await response.arrayBuffer()); + } + if (createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) throw new Error(`GraphSON native artifact checksum mismatch: ${artifact.filename}`); + await writeFile(path, bytes); +} +console.log(`Pinned TinkerPop ${metadata.version}: ${metadata.artifacts.length} native development jars verified`); diff --git a/scripts/smoke-package.ts b/scripts/smoke-package.ts index 63031e4..1572bc6 100644 --- a/scripts/smoke-package.ts +++ b/scripts/smoke-package.ts @@ -644,7 +644,78 @@ async function main(): Promise { ], }, }; + Object.assign(exportChecks, { + "./graphson": { + phase: "core", + runtime: [ + `const graph = m0.createGraph({ nodes:[{id:'a',label:'A'},{id:'b',label:'B'}], edges:[{id:'e',sourceId:'a',targetId:'b'}] });`, + `const source = $MOD.toGraphSON(graph); assert.equal(source['@type'],'tinker:graph'); assert.equal($MOD.fromGraphSON(source).edges[0].id,'e');`, + ], + types: [ + `const source: $MOD.GraphSONGraph = $MOD.toGraphSON(m0.createGraph({})); $MOD.graphsonConverter.from(source);`, + ], + }, + "./cx2": { + phase: "core", + runtime: [ + `const source = $MOD.toCX2(m0.createGraph({nodes:[{id:'0',label:'A'},{id:'1',label:'B'}],edges:[{id:'2',sourceId:'0',targetId:'1',label:'Go'}]})); assert.equal($MOD.fromCX2(source).edges[0].id,'2');`, + ], + types: [ + `const source: $MOD.CX2JSON = $MOD.toCX2(m0.createGraph({})); $MOD.cx2Converter.from(source);`, + ], + }, + "./dgml": { + phase: "optional", + runtime: [ + `const source = $MOD.toDGML(m0.createGraph({nodes:[{id:'a',label:'A'},{id:'b',label:'B'}],edges:[{id:'dgml_link_0',sourceId:'a',targetId:'b',label:'Go'}]})); assert.equal($MOD.fromDGML(source).edges[0].id,'dgml_link_0');`, + ], + types: [ + `const source: string = $MOD.toDGML(m0.createGraph({})); const graph: $MOD.DGMLGraph = $MOD.fromDGML(source); void graph;`, + ], + }, + "./rdf": { + phase: "core", + runtime: [ + `const source = {version:'1.1',quads:[{subject:{termType:'NamedNode',value:'urn:a'},predicate:{termType:'NamedNode',value:'urn:p'},object:{termType:'NamedNode',value:'urn:b'},graph:{termType:'DefaultGraph',value:''}}]}; assert.deepEqual($MOD.toRDFDataset($MOD.fromRDFDataset(source)),source);`, + `assert.equal(typeof $MOD.getRDFTermId($MOD.getRDFTerm(source.quads[0].subject)),'string'); assert.equal(typeof $MOD.getRDFQuadId(source.quads[0]),'string');`, + ], + types: [ + `const source: $MOD.RDFDataset = {version:'1.1',quads:[]}; const term: $MOD.RDFTerm = $MOD.getRDFTerm({termType:'NamedNode',value:'urn:a'}); $MOD.getRDFTermId(term); $MOD.rdfDatasetConverter.from(source);`, + ], + }, + "./rdf/syntax": { + phase: "optional", + runtime: [ + `const text = ' .'; const graph = $MOD.fromRDF(text,{format:'N-Triples'}); assert.equal(graph.edges.length,1); assert.ok($MOD.toRDF(graph,{format:'N-Triples'}).includes('urn:p')); const codec = $MOD.createRDFDocumentConverter({format:'N-Triples'}); assert.equal(codec.to(JSON.parse(JSON.stringify(codec.from(text)))),text);`, + ], + types: [ + `const options: $MOD.RDFSyntaxOptions = {format:'N-Triples'}; const graph = $MOD.fromRDF(' .',options); $MOD.toRDF(graph,{format:'N-Triples'}); $MOD.createRDFDocumentConverter(options);`, + ], + }, + } satisfies Record); const documentExports: Array<[string, string, string]> = [ + [ + "./graphson", + "graphsonDocumentConverter", + '({"@type": "tinker:graph", "@value": {"vertices": [{"@type": "g:Vertex", "@value": {"id": "a", "label": "A"}}], "edges": []}})', + ], + [ + "./cx2", + "cx2DocumentConverter", + '([{"CXVersion": "2.0", "hasFragments": false}, {"nodes": [{"id": 0}]}, {"edges": []}, {"status": [{"success": true}]}])', + ], + [ + "./rdf", + "rdfDatasetDocumentConverter", + '({"version": "1.1", "quads": []})', + ], + [ + "./dgml", + "dgmlDocumentConverter", + '""', + ], + + ['./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"'], @@ -797,6 +868,7 @@ async function main(): Promise { '--ignore-workspace', 'dotparser', 'fast-xml-parser', + 'n3', 'fflate', 'zod', // Layout adapter optional peers diff --git a/src/formats/cx2/README.md b/src/formats/cx2/README.md new file mode 100644 index 0000000..bee87d5 --- /dev/null +++ b/src/formats/cx2/README.md @@ -0,0 +1,53 @@ +# CX2 + + + +`fromCX2`, `toCX2`, `cx2Converter`, `cx2DocumentConverter` operate on plain CX2 +JSON aspect arrays. No runtime dependency. The profile follows [Cytoscape's CX2 +2.0 specification](https://cytoscape.org/cx/cx2/specification/cytoscape-exchange-format-specification-%28version-2%29/) +(updated July 22, 2025). + +Safe integer node/edge IDs map to canonical decimal Graph IDs. Duplicate IDs, +unsafe numeric identities and dangling endpoints reject. Edges are directed; +parallel edges/self-loops remain distinct. Native declarations determine scalar +and list attribute types, default values and aliases. Names declared with aliases +must use their alias in records. Node/edge `name` string attributes map labels; +other resolved attributes stay in data. Node x/y coordinates map geometry with +CX2's all-node completeness rule; z remains opaque native state. + +Visual properties, bypasses, editor properties and custom aspects remain opaque, +not Graph styles. Fresh export requires canonical numeric Graph IDs and this +explicit typed-attribute profile; hierarchy/ports/incompatible modes and other +unrepresentable Graph fields reject. Attribute declarations preserve types; +edits never silently coerce a value to a different declared type. Null typed +attributes, including null numeric placeholders, are outside this interpreted +profile and retainable through unavailable document projections. Fragmented +entity aspects concatenate in source order; all declarations must precede all +entity/network attribute data. Late declarations/defaults reject projection. + + + +Native carriers retain original aspects and attribute presence. Documents retain +exact original JSON values after JSON persistence, including unsupported source +with a null projection. Bounded label/typed-attribute edits retain unknown aspects +and reparse to the requested Graph; structural or ambiguous edits reject. +`metadata: 'native'` and `nativeCarrier: false` expose explicit native handling. + + + +Independent native tests use the maintained NDEx Python `ndex2` CX2Network reader +with a pinned version. Reader assertions establish selected topology/attribute +profiles, not unrestricted visual rendering or whole-language losslessness. + + + +NDEx3.12.0 omits false/zero defaults and null typed values in the inspected +reader. Tests record those behaviors as upstream-negative controls; the adapter +applies falsy defaults according to the specification, and rejects null typed +values from its interpreted profile. This does not weaken declaration validation. +`nativeCarrier: false` requires a clean explicit native data profile (for example +`cx2DocumentConverter.from(source).graph`); private headers are not erased from +caller-authored data. Native source edits win over unchanged captured Graph fields; +conflicting mapped edits reject. Document editing currently supports entity +labels/typed attributes, with string name declarations already present; root +attributes, schema changes, coordinates and structural edits reject. diff --git a/src/formats/cx2/index.ts b/src/formats/cx2/index.ts new file mode 100644 index 0000000..9e4758d --- /dev/null +++ b/src/formats/cx2/index.ts @@ -0,0 +1,742 @@ +import type { Graph } from '../../types'; +import { getEdgeMode } from '../../mode'; +import { createFormatConverter } from '../converter'; +import { + createGraphDocumentConverter, + type GraphDocumentPatchContext, +} from '../document'; +import { + createNativeCarrier, + getNativeCarrier, + getCarrierData, + getClonedJSON, + getNativeDocumentGraph, + isJSONEqual, + type FormatImportOptions, + type FormatExportOptions, +} from '../preservation'; +import { getValidatedGraph } from '../structured-json'; +export type CX2AttributeType = + | 'string' + | 'long' + | 'integer' + | 'double' + | 'boolean' + | 'list_of_string' + | 'list_of_long' + | 'list_of_integer' + | 'list_of_double' + | 'list_of_boolean'; +export interface CX2AttributeDeclaration { + d: CX2AttributeType; + a?: string; + v?: any; + [key: string]: any; +} +export interface CX2Declarations { + nodes?: Record; + edges?: Record; + networkAttributes?: Record; + [aspect: string]: any; +} +export interface CX2Node { + id: number; + v?: Record; + x?: number; + y?: number; + z?: number; + [key: string]: any; +} +export interface CX2Edge { + id: number; + s: number; + t: number; + v?: Record; + [key: string]: any; +} +export type CX2JSON = Array>; +export interface CX2EntityData { + attributes?: Record; +} +export interface CX2GraphData { + attributes?: Record; + declarations?: CX2Declarations; +} +export type CX2Graph = Graph; +export interface CX2ImportOptions extends FormatImportOptions {} +export interface CX2ExportOptions extends FormatExportOptions {} +function getJSONValue(value: any, optionalUndefined = false): void { + const active = new Set(); + const stack: Array<{ value: any; exit?: boolean; allowUndefined?: boolean }> = + [{ value, allowUndefined: optionalUndefined }]; + while (stack.length) { + const item = stack.pop()!, + current = item.value; + if (item.exit) { + active.delete(current); + continue; + } + if (current === undefined && item.allowUndefined) continue; + if ( + current === null || + typeof current === 'string' || + typeof current === 'boolean' + ) + continue; + if (typeof current === 'number' && Number.isFinite(current)) continue; + if ( + !current || + typeof current !== 'object' || + active.has(current) || + Object.getOwnPropertySymbols(current).length || + (!Array.isArray(current) && + Object.getPrototypeOf(current) !== Object.prototype && + Object.getPrototypeOf(current) !== null) + ) + throw new Error('CX2: JSON values required'); + if (Array.isArray(current)) { + const names = Object.getOwnPropertyNames(current); + if ( + names.length !== current.length + 1 || + Object.keys(current).length !== current.length || + names.some( + (name) => + name !== 'length' && + (!/^(0|[1-9]\d*)$/.test(name) || Number(name) >= current.length) + ) + ) + throw new Error('CX2: sparse/custom arrays unsupported'); + } + active.add(current); + stack.push({ value: current, exit: true }); + for (const [name, descriptor] of Object.entries( + Object.getOwnPropertyDescriptors(current) + )) { + if (Array.isArray(current) && name === 'length') continue; + if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) + throw new Error('CX2: accessors/non-JSON properties unsupported'); + stack.push({ + value: descriptor.value, + allowUndefined: optionalUndefined && !Array.isArray(current), + }); + } + } +} +function getRecord(value: any, message: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error(`CX2: ${message} must be an object`); + return value; +} +function getID(value: any): number { + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + Object.is(value, -0) + ) + throw new Error('CX2: IDs must be exact safe integers'); + return value; +} +function getTypedValue( + value: any, + type: CX2AttributeType, + defaultValue = false +): any { + if (type.startsWith('list_of_')) { + if (!Array.isArray(value)) throw new Error('CX2: list attribute required'); + return value.map((v) => + getTypedValue(v, type.slice(8) as CX2AttributeType, defaultValue) + ); + } + if ( + (type === 'string' && typeof value === 'string') || + (type === 'boolean' && typeof value === 'boolean') + ) + return value; + if (['long', 'integer', 'double'].includes(type)) { + let n = value; + if ( + defaultValue && + typeof value === 'string' && + value.trim() !== '' && + /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value) + ) + n = Number(value); + if ( + typeof n !== 'number' || + !Number.isFinite(n) || + (type !== 'double' && !Number.isSafeInteger(n)) || + (type === 'integer' && (n < -2147483648 || n > 2147483647)) + ) + throw new Error('CX2: invalid/unsafe numeric attribute'); + return n; + } + throw new Error(`CX2: value does not match declared type ${type}`); +} +function getDeclarations(value: CX2Declarations): void { + getRecord(value, 'attribute declarations'); + for (const [aspect, records] of Object.entries(value)) { + getRecord(records, 'aspect declarations'); + const wire = new Set(); + for (const [name, declaration] of Object.entries(records) as Array< + [string, CX2AttributeDeclaration] + >) { + getRecord(declaration, 'declaration'); + if ( + ![ + 'string', + 'long', + 'integer', + 'double', + 'boolean', + 'list_of_string', + 'list_of_long', + 'list_of_integer', + 'list_of_double', + 'list_of_boolean', + ].includes(declaration.d) || + (name === 'id' && (aspect === 'nodes' || aspect === 'edges')) + ) + throw new Error('CX2: invalid attribute declaration'); + if ( + declaration.a !== undefined && + (typeof declaration.a !== 'string' || aspect === 'networkAttributes') + ) + throw new Error('CX2: invalid alias'); + const key = declaration.a ?? name; + if ((aspect === 'nodes' || aspect === 'edges') && key === 'id') + throw new Error('CX2: reserved id alias'); + if (wire.has(key)) throw new Error('CX2: colliding attribute aliases'); + wire.add(key); + if (Object.hasOwn(declaration, 'v')) { + if (!['nodes', 'edges'].includes(aspect)) + throw new Error('CX2: defaults require node/edge declarations'); + getTypedValue(declaration.v, declaration.d, true); + } + if ( + ((aspect === 'nodes' && name === 'represents') || + (aspect === 'networkAttributes' && + ['name', 'description', 'version'].includes(name))) && + declaration.d !== 'string' + ) + throw new Error( + 'CX2: reserved text attribute requires string declaration' + ); + if ( + aspect === 'nodes' && + name === 'alias' && + declaration.d !== 'list_of_string' + ) + throw new Error( + 'CX2: node alias attribute requires list_of_string declaration' + ); + if ( + ['nodes', 'edges'].includes(aspect) && + name === 'name' && + declaration.d !== 'string' + ) + throw new Error('CX2: label name must be declared string'); + } + } +} +function getAttributes( + values: any, + declarations: Record | undefined +): Record { + const attrs = values === undefined ? {} : getRecord(values, 'attributes'); + const result: Record = Object.create(null), + byWire = new Map(); + for (const [name, declaration] of Object.entries(declarations ?? {})) { + byWire.set(declaration.a ?? name, [name, declaration]); + if (Object.hasOwn(declaration, 'v')) + Object.defineProperty(result, name, { + value: getTypedValue(declaration.v, declaration.d, true), + enumerable: true, + writable: true, + configurable: true, + }); + } + for (const [wire, value] of Object.entries(attrs)) { + const declared = byWire.get(wire); + if (!declared) + throw new Error(`CX2: undeclared/full-alias attribute ${wire}`); + Object.defineProperty(result, declared[0], { + value: getTypedValue(value, declared[1].d), + enumerable: true, + writable: true, + configurable: true, + }); + } + return result; +} +function getCX2Source(source: CX2JSON) { + getJSONValue(source); + if ( + !Array.isArray(source) || + !source.length || + source[0]?.CXVersion !== '2.0' || + (source[0].hasFragments !== undefined && + typeof source[0].hasFragments !== 'boolean') + ) + throw new Error('CX2: expected CXVersion 2.0 descriptor'); + const status = source.at(-1)?.status; + if ( + !Array.isArray(status) || + status.length !== 1 || + typeof status[0]?.success !== 'boolean' || + status[0].success !== true + ) + throw new Error('CX2: missing/unsuccessful final status'); + const declarations: CX2Declarations = {}, + nodes: CX2Node[] = [], + edges: CX2Edge[] = [], + nodeData: Record[] = [], + edgeData: Record[] = []; + let attributes: Record = {}, + networkSeen = false, + post = false, + dataStarted = false; + const seen = new Set(), + counts = new Map(); + for (let index = 1; index < source.length - 1; index++) { + const block = getRecord(source[index], 'aspect block'), + keys = Object.keys(block); + if (keys.length !== 1) + throw new Error('CX2: aspect block requires one key'); + const aspect = keys[0], + records = block[aspect]; + if (!Array.isArray(records)) + throw new Error('CX2: aspect records require array'); + if (aspect === 'metaData') { + if (seen.size) post = true; + continue; + } + if (post) throw new Error('CX2: aspect after post metadata'); + if (aspect === 'status') + throw new Error('CX2: status must terminate stream'); + if (seen.has(aspect) && !source[0].hasFragments) + throw new Error('CX2: duplicate nonfragmented aspect'); + seen.add(aspect); + counts.set(aspect, (counts.get(aspect) ?? 0) + records.length); + if (aspect === 'attributeDeclarations') { + if (dataStarted) + throw new Error('CX2: late fragmented declarations unsupported'); + for (const record of records) { + getDeclarations(record); + for (const [kind, defs] of Object.entries(record)) { + const existing = Object.hasOwn(declarations, kind) + ? declarations[kind] + : {}; + for (const [name, definition] of Object.entries(defs as any)) { + if (Object.hasOwn(existing, name)) + throw new Error('CX2: duplicate declaration'); + Object.defineProperty(existing, name, { + value: definition, + enumerable: true, + writable: true, + configurable: true, + }); + } + Object.defineProperty(declarations, kind, { + value: existing, + enumerable: true, + writable: true, + configurable: true, + }); + } + getDeclarations(declarations); + } + continue; + } + if (['nodes', 'edges', 'networkAttributes'].includes(aspect)) + dataStarted = true; + if (aspect === 'networkAttributes') { + if (networkSeen || records.length !== 1) + throw new Error('CX2: single networkAttributes object required'); + networkSeen = true; + attributes = getAttributes(records[0], declarations.networkAttributes); + continue; + } + if (aspect === 'nodes' || aspect === 'edges') + for (const record of records) { + getRecord(record, 'entity'); + getID(record.id); + const attrs = getAttributes(record.v, declarations[aspect]); + if (aspect === 'nodes') { + nodes.push(record); + nodeData.push(attrs); + } else { + getID(record.s); + getID(record.t); + edges.push(record); + edgeData.push(attrs); + } + } + } + const ids = new Set(), + edgeIDs = new Set(); + const layout = nodes.some( + (n) => Object.hasOwn(n, 'x') || Object.hasOwn(n, 'y') + ), + z = nodes.some((n) => Object.hasOwn(n, 'z')); + for (const node of nodes) { + if (ids.has(node.id)) throw new Error('CX2: duplicate node ID'); + ids.add(node.id); + if ( + (layout && + (!Number.isFinite(node.x) || + typeof node.x !== 'number' || + !Number.isFinite(node.y) || + typeof node.y !== 'number')) || + (z && (!layout || typeof node.z !== 'number' || !Number.isFinite(node.z))) + ) + throw new Error('CX2: incomplete/nonfinite node coordinates'); + } + for (const edge of edges) { + if (edgeIDs.has(edge.id) || !ids.has(edge.s) || !ids.has(edge.t)) + throw new Error('CX2: duplicate edge/dangling endpoint'); + edgeIDs.add(edge.id); + } + for (const block of source) + if (Array.isArray(block.metaData)) + for (const metadata of block.metaData) { + if ( + typeof metadata.name !== 'string' || + (metadata.elementCount !== undefined && + (!Number.isSafeInteger(metadata.elementCount) || + metadata.elementCount !== (counts.get(metadata.name) ?? 0))) + ) + throw new Error('CX2: invalid metadata count'); + } + return { declarations, nodes, edges, nodeData, edgeData, attributes }; +} +export function fromCX2( + source: CX2JSON, + _options: CX2ImportOptions = {} +): CX2Graph { + const parsed = getCX2Source(source); + const graph: CX2Graph = { + id: '', + mode: 'directed', + initialNodeId: null, + data: { attributes: parsed.attributes, declarations: parsed.declarations }, + nodes: parsed.nodes.map((node, i) => { + const { name, ...attributes } = parsed.nodeData[i]; + return { + type: 'node', + id: String(node.id), + label: name ?? null, + parentId: null, + initialNodeId: null, + data: { attributes }, + ...(Object.hasOwn(node, 'x') && { x: node.x, y: node.y }), + }; + }), + edges: parsed.edges.map((edge, i) => { + const { name, ...attributes } = parsed.edgeData[i]; + return { + type: 'edge', + id: String(edge.id), + sourceId: String(edge.s), + targetId: String(edge.t), + label: name ?? null, + mode: 'directed', + data: { attributes }, + }; + }), + }; + graph.data = createNativeCarrier(graph.data, 'cx2', source, graph); + (graph.data as any).__statelyaiNative.baselineNative = getClonedJSON(source); + return graph; +} +function getCanonicalID(id: string): number { + const n = Number(id); + if (String(n) !== id) + throw new Error('CX2: canonical numeric Graph IDs required'); + getID(n); + return n; +} +function getInferredType(value: any): CX2AttributeType { + if (typeof value === 'string') return 'string'; + if (typeof value === 'boolean') return 'boolean'; + if (typeof value === 'number' && Number.isFinite(value)) + return Number.isInteger(value) ? 'long' : 'double'; + if (Array.isArray(value) && value.length) { + const type = getInferredType(value[0]); + if ( + type.startsWith('list_of_') || + value.some((v) => getInferredType(v) !== type) + ) + throw new Error('CX2: homogeneous scalar list required'); + return `list_of_${type}` as CX2AttributeType; + } + throw new Error( + 'CX2: explicit declaration required for empty/unrepresentable attribute' + ); +} +function getFreshDeclarations(graph: CX2Graph): CX2Declarations { + getJSONValue(graph.data?.declarations ?? {}, true); + const declarations = getClonedJSON(graph.data?.declarations ?? {}); + for (const [aspect, records] of [ + ['networkAttributes', [graph.data?.attributes ?? {}]], + [ + 'nodes', + graph.nodes.map((n) => ({ + ...n.data?.attributes, + ...(n.label != null && { name: n.label }), + })), + ], + [ + 'edges', + graph.edges.map((e) => ({ + ...e.data?.attributes, + ...(e.label != null && { name: e.label }), + })), + ], + ] as const) { + const defs = declarations[aspect] ?? {}; + for (const attrs of records) + for (const [key, value] of Object.entries(attrs)) { + if (!Object.hasOwn(defs, key)) + Object.defineProperty(defs, key, { + value: { d: getInferredType(value) }, + enumerable: true, + writable: true, + configurable: true, + }); + getTypedValue(value, defs[key].d); + } + declarations[aspect] = defs; + } + getDeclarations(declarations); + return declarations; +} +function getWireAttributes( + attrs: Record, + definitions: Record | undefined +): Record { + const values: Record = {}; + for (const [name, value] of Object.entries(attrs)) { + const def = definitions?.[name]; + if (!def) throw new Error('CX2: undeclared native attribute'); + getTypedValue(value, def.d); + Object.defineProperty(values, def.a ?? name, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } + return values; +} +function toRawCX2(graph: CX2Graph): CX2JSON { + getValidatedGraph(graph, 'CX2'); + if ( + graph.id !== '' || + graph.initialNodeId != null || + graph.mode !== 'directed' || + graph.edges.some((e) => getEdgeMode(graph, e) !== 'directed') + ) + throw new Error('CX2: unsupported identity/initial node/mode'); + function getFields(record: any, allowed: string[]) { + if ( + Object.keys(record).some( + (k) => record[k] !== undefined && !allowed.includes(k) + ) + ) + throw new Error('CX2: unrepresentable Graph field'); + } + getFields(graph, ['id', 'mode', 'initialNodeId', 'nodes', 'edges', 'data']); + getFields(graph.data ?? {}, ['attributes', 'declarations']); + const declarations = getFreshDeclarations(graph), + source: CX2JSON = [ + { CXVersion: '2.0', hasFragments: false }, + { attributeDeclarations: [declarations] }, + { + networkAttributes: [ + getWireAttributes( + graph.data?.attributes ?? {}, + declarations.networkAttributes + ), + ], + }, + ]; + source.push({ + nodes: graph.nodes.map((node) => { + getFields(node, [ + 'type', + 'id', + 'label', + 'parentId', + 'initialNodeId', + 'data', + 'x', + 'y', + ]); + getFields(node.data ?? {}, ['attributes']); + if ( + node.label == null && + Object.hasOwn(declarations.nodes?.name ?? {}, 'v') + ) + throw new Error('CX2: native name default cannot represent null label'); + if ( + node.parentId != null || + node.initialNodeId != null || + (node.label != null && typeof node.label !== 'string') || + Object.hasOwn(node.data?.attributes ?? {}, 'name') + ) + throw new Error('CX2: unsupported hierarchy/label attribute collision'); + return { + id: getCanonicalID(node.id), + v: getWireAttributes( + { + ...node.data?.attributes, + ...(node.label != null && { name: node.label }), + }, + declarations.nodes + ), + ...(node.x !== undefined || node.y !== undefined + ? { x: node.x, y: node.y } + : {}), + }; + }), + }); + source.push( + { + edges: graph.edges.map((edge) => { + getFields(edge, [ + 'type', + 'id', + 'sourceId', + 'targetId', + 'label', + 'mode', + 'data', + ]); + getFields(edge.data ?? {}, ['attributes']); + if ( + edge.label == null && + Object.hasOwn(declarations.edges?.name ?? {}, 'v') + ) + throw new Error( + 'CX2: native name default cannot represent null label' + ); + if ( + (edge.label != null && typeof edge.label !== 'string') || + Object.hasOwn(edge.data?.attributes ?? {}, 'name') + ) + throw new Error('CX2: unsupported label attribute collision'); + return { + id: getCanonicalID(edge.id), + s: getCanonicalID(edge.sourceId), + t: getCanonicalID(edge.targetId), + v: getWireAttributes( + { + ...edge.data?.attributes, + ...(edge.label != null && { name: edge.label }), + }, + declarations.edges + ), + }; + }), + }, + { status: [{ success: true }] } + ); + getCX2Source(source); + return source; +} +function getCX2Patch(context: GraphDocumentPatchContext) { + const source = getClonedJSON(context.source), + parsed = getCX2Source(source), + nodes = new Map(parsed.nodes.map((n) => [String(n.id), n])), + edges = new Map(parsed.edges.map((e) => [String(e.id), e])); + for (const change of context.changes) { + if (change.entity !== 'node' && change.entity !== 'edge') + throw new Error('CX2: only label/typed entity attributes supported'); + const kind = change.entity === 'node' ? 'nodes' : 'edges', + record = + change.entity === 'node' + ? nodes.get(change.id!) + : edges.get(change.id!); + if (!record) throw new Error('CX2: structural edits unsupported'); + const defs = parsed.declarations[kind] ?? {}; + function updateAttribute(name: string, value: any, present: boolean) { + const def = defs[name]; + if (!def) throw new Error('CX2: undeclared attribute edit'); + if (!present) { + if (Object.hasOwn(def, 'v')) + throw new Error('CX2: attribute default prevents absence/null'); + if (record!.v) delete record!.v[def.a ?? name]; + return; + } + getTypedValue(value, def.d); + if (!record!.v) record!.v = {}; + Object.defineProperty(record!.v, def.a ?? name, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } + if (change.field === 'label') { + if (change.after !== null && typeof change.after !== 'string') + throw new Error('CX2: unsupported label value'); + updateAttribute( + 'name', + change.after, + change.after !== null && change.afterPresent + ); + continue; + } + if (change.field === 'data') { + getRecord(change.after, 'entity data'); + if (Object.keys(change.after as any).some((k) => k !== 'attributes')) + throw new Error('CX2: unsupported data key'); + const before = (change.before as CX2EntityData)?.attributes ?? {}, + after = (change.after as CX2EntityData)?.attributes ?? {}; + if (Object.hasOwn(after, 'name')) + throw new Error('CX2: protected label attribute'); + for (const name of new Set([ + ...Object.keys(before), + ...Object.keys(after), + ])) + if ( + Object.hasOwn(before, name) !== Object.hasOwn(after, name) || + !isJSONEqual(before[name], after[name]) + ) + updateAttribute(name, after[name], Object.hasOwn(after, name)); + continue; + } + throw new Error('CX2: unsupported structural/geometry edit'); + } + return { ok: true as const, source }; +} +export function toCX2( + graph: CX2Graph, + options: CX2ExportOptions = {} +): CX2JSON { + const carrier = + options.nativeCarrier === false + ? undefined + : getNativeCarrier(graph.data, 'cx2'); + if (!carrier) return toRawCX2(graph); + const originalSource = ( + carrier as typeof carrier & { baselineNative?: CX2JSON } + ).baselineNative; + if (originalSource === undefined) + throw new Error('CX2: missing native provenance baseline'); + const document = cx2DocumentConverter.from(originalSource); + if (!isJSONEqual(document.baseline.graph, carrier.baseline)) + throw new Error('CX2: invalid native provenance baseline'); + document.source = carrier.native; + document.graph = { ...graph, data: getCarrierData(graph.data, 'cx2') }; + return cx2DocumentConverter.to(document); +} +export const cx2Converter = createFormatConverter(toCX2, fromCX2); +export const cx2DocumentConverter = createGraphDocumentConverter({ + format: 'cx2', + codec: { from: fromCX2, to: toRawCX2 }, + project: (source: CX2JSON) => ({ + graph: getNativeDocumentGraph(fromCX2(source), 'cx2', source), + }), + patch: getCX2Patch, +}); diff --git a/src/formats/cytoscape/README.md b/src/formats/cytoscape/README.md index 015c348..bb28a15 100644 --- a/src/formats/cytoscape/README.md +++ b/src/formats/cytoscape/README.md @@ -117,3 +117,12 @@ 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. + + + +Strict document edge patches require either an unchanged native edge record +sequence or a unique explicit `data.id` independently present in both baseline and +current source. A new edge with `data.id: "e0"` cannot validate an older generated +`e0` identity. Ambiguous insertion/removal/reordering/record changes reject with +`ambiguous-native-identity` and the field path. Stable explicit IDs permit independent +native updates; source-only changes and stable node-label patches remain supported. diff --git a/src/formats/d3/README.md b/src/formats/d3/README.md index a5bb554..c3b2ecc 100644 --- a/src/formats/d3/README.md +++ b/src/formats/d3/README.md @@ -113,3 +113,13 @@ 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. + + + +For strict document edge patches, a generated `e0` identity is valid only while +the native link record sequence remains unchanged. Otherwise a unique explicit +native link `id` must exist in both baseline and current source. A new link bearing +`id: "e0"` cannot retroactively validate a historical ID-less link. Ambiguous +insertion/removal/reordering/record changes reject with `ambiguous-native-identity`. +Stable IDs permit independent native data updates; source-only changes and stable +node-label patches remain supported. diff --git a/src/formats/dgml/README.md b/src/formats/dgml/README.md new file mode 100644 index 0000000..ec409b9 --- /dev/null +++ b/src/formats/dgml/README.md @@ -0,0 +1,58 @@ +# DGML + + + +`fromDGML`, `toDGML`, `dgmlConverter`, `dgmlDocumentConverter` operate on Microsoft +Directed Graph Markup Language XML in `http://schemas.microsoft.com/vs/2009/dgml`. +DGML requires the optional `fast-xml-parser` peer for XML validation. + +Nodes map `Id` and visible `Label` (missing node Label defaults to Id). Directed +links map endpoints and labels. `Category="Contains"` links map parentId rather +than ordinary Graph edges; multiple parents and containment cycles reject. +Undeclared endpoints become inferred nodes, as Microsoft specifies. Declaration +provenance distinguishes inferred nodes; editing their labels rejects until the +source supplies an explicit declaration. + +Node/edge data separates category memberships, raw string attributes and declared +property values. Supported CLR property types are String, Boolean, Int32, Int64 +(unsafe magnitudes retained as exact lexical strings under their declaration) and Double (finite only). Unsupported declared property +values reject projection. Category/property definitions remain in graph data; +category appearance inheritance, Styles and other XML extensions remain opaque. +Native links have no schema-defined stable ID; Graph edge IDs are their original +link positions (`dgml_link_N`, counting Contains links). Strict fresh output +requires those canonical IDs. `edgeIds: 'positional'` explicitly permits identity +normalization; arbitrary Graph edge IDs otherwise reject. Fresh output accepts +this explicit data profile, directed edges and hierarchy; +unrepresentable Graph fields reject rather than disappear. + + + +Native imports carry serializable source state. Unchanged export and documents +retain exact source strings, including comments/namespaces/unknown XML. Document +label/property edits use lexical attribute spans with reparse proof; structural, +implicit-node and unsupported edits reject. Edge patches additionally require the +native Link record sequence to match its original sequence exactly: insertions, +removals, reordering or independent Link-record edits make positional identity +ambiguous and reject. Source changes outside Link records and stable node-ID +patches remain independent. Unsupported source can be retained in +GraphDocument with a null projection. `metadata: 'native'` and `nativeCarrier: +false` disable native-state interpretation. Fresh regeneration requires a clean +explicit data profile, such as a document's Graph projection; private carrier +headers are not silently erased from caller-authored data. + + + +Native tests validate selected external/emitted fixtures against Microsoft's +published DGML XSD through libxml2. Schema acceptance does not prove Visual +Studio rendering, custom property interpretation, or complete DGML semantics. +The authoritative reference is [Microsoft's DGML reference](https://learn.microsoft.com/en-us/visualstudio/modeling/directed-graph-markup-language-dgml-reference?view=visualstudio). + + + +The published XSD rejects dynamic property attributes such as Microsoft's +`DrivingAge` example. Tests record this schema/reference mismatch explicitly; +CLR property parsing follows the documented data type profile, with no independent +Visual Studio property-evaluation claim. XML attribute normalization follows the +native XML parser: literal CR/LF/tab becomes spaces, character references remain +distinct. Unknown qualified attributes survive source-preserving edits; fresh +output rejects unbound/qualified attribute names. diff --git a/src/formats/dgml/index.ts b/src/formats/dgml/index.ts new file mode 100644 index 0000000..ec7c501 --- /dev/null +++ b/src/formats/dgml/index.ts @@ -0,0 +1,610 @@ +import { XMLValidator } from 'fast-xml-parser'; +import type { Graph, GraphNode, GraphEdge } from '../../types'; +import { getEdgeMode } from '../../mode'; +import { createFormatConverter } from '../converter'; +import { + createGraphDocumentConverter, + type GraphDocumentPatchContext, +} from '../document'; +import { + createNativeCarrier, + getNativeCarrier, + getCarrierData, + getNativeDocumentGraph, + isJSONEqual, + type FormatImportOptions, + type FormatExportOptions, +} from '../preservation'; +import { getValidatedGraph } from '../structured-json'; +import { + getXMLElements, + getDecodedXML, + getXMLAttribute, + getEncodedXML, + getEditedSource, + type XMLElement, + type TextEdit, +} from '../xml-document'; +import { getValidatedXMLValue } from '../xml-standard'; +export interface DGMLNodeData { + attributes?: Record; + categories?: string[]; + properties?: Record; + group?: 'Expanded' | 'Collapsed'; + declared?: boolean; +} +export interface DGMLEdgeData { + attributes?: Record; + categories?: string[]; + properties?: Record; +} +export interface DGMLGraphData { + attributes?: Record; + categories?: Array>; + properties?: Array>; +} +export type DGMLGraph = Graph; +export interface DGMLImportOptions extends FormatImportOptions {} +export interface DGMLExportOptions extends FormatExportOptions { + edgeIds?: 'positional'; +} +const NS = 'http://schemas.microsoft.com/vs/2009/dgml'; +function getAttributes(element: XMLElement): Record { + return Object.fromEntries( + element.attributes + .filter((a) => a.name !== 'xmlns' && !a.name.startsWith('xmlns:')) + .map((a) => [a.name, a.value]) + ); +} +function getDGMLSource(source: string) { + getValidatedXMLValue(source); + if (XMLValidator.validate(source) !== true || / e.parent === null); + if ( + roots.length !== 1 || + roots[0].local !== 'DirectedGraph' || + roots[0].uri !== NS + ) + throw new Error('DGML: expected namespaced DirectedGraph'); + for (const element of elements) + for (const attribute of element.attributes) { + const raw = source.slice(attribute.start, attribute.end); + if (/&(?!(?:amp|lt|gt|quot|apos);|#\d+;|#x[0-9a-fA-F]+;)/.test(raw)) + throw new Error('DGML: undeclared XML entity'); + for (const match of raw.matchAll(/&#(x[0-9a-f]+|\d+);/gi)) { + const code = Number.parseInt( + match[1].startsWith('x') ? match[1].slice(1) : match[1], + match[1].startsWith('x') ? 16 : 10 + ); + if (!Number.isSafeInteger(code) || code > 0x10ffff) + throw new Error('DGML: invalid XML character reference'); + getValidatedXMLValue(String.fromCodePoint(code)); + } + attribute.value = getDecodedXML(raw.replace(/\r\n|[\r\n\t]/g, ' ')); + getValidatedXMLValue(attribute.value); + } + const root = roots[0], + rootIndex = elements.indexOf(root); + const sections = new Map(); + for (const index of root.children) { + const e = elements[index]; + if (e.uri !== NS) continue; + if (sections.has(e.local)) throw new Error('DGML: duplicate section'); + sections.set(e.local, e); + } + function getRecords(section: string, local: string) { + return (sections.get(section)?.children ?? []) + .map((i) => elements[i]) + .filter((e) => e.uri === NS && e.local === local); + } + const categories = getRecords('Categories', 'Category').map(getAttributes), + properties = getRecords('Properties', 'Property').map(getAttributes); + for (const [records, kind] of [ + [categories, 'category'], + [properties, 'property'], + ] as const) { + const ids = new Set(); + for (const record of records) { + if (typeof record.Id !== 'string' || ids.has(record.Id)) + throw new Error(`DGML: duplicate/missing ${kind} ID`); + ids.add(record.Id); + } + } + const definitions = new Map(properties.map((p) => [p.Id, p.DataType])); + const nodeRecords = getRecords('Nodes', 'Node'), + linkRecords = getRecords('Links', 'Link'); + return { + root, + rootIndex, + elements, + nodeRecords, + linkRecords, + categories, + properties, + definitions, + }; +} +function getProperty( + value: string, + type: string | undefined +): string | number | boolean { + if (type === 'System.String') return value; + if (type === 'System.Boolean' && /^(true|false)$/i.test(value)) + return value.toLowerCase() === 'true'; + if (type === 'System.Int32' || type === 'System.Int64') { + if (!/^[+-]?\d+$/.test(value)) + throw new Error('DGML: invalid integer property'); + const exact = BigInt(value); + if ( + exact < + (type === 'System.Int32' ? -2147483648n : -9223372036854775808n) || + exact > (type === 'System.Int32' ? 2147483647n : 9223372036854775807n) + ) + throw new Error('DGML: integer property outside CLR range'); + const n = Number(value); + return Number.isSafeInteger(n) ? n : value; + } + if ( + type === 'System.Double' && + /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value) && + Number.isFinite(Number(value)) + ) + return Number(value); + throw new Error(`DGML: unsupported/invalid property type ${type}`); +} +function getData( + element: XMLElement, + elements: XMLElement[], + definitions: Map, + node: boolean +): DGMLNodeData { + const attributes = getAttributes(element), + categories: string[] = []; + if (Object.hasOwn(attributes, 'Category')) + categories.push(attributes.Category); + for (const i of element.children) { + const child = elements[i]; + if (child.uri === NS && child.local === 'Category') { + const ref = getXMLAttribute(child, 'Ref')?.value; + if (ref === undefined) + throw new Error('DGML: category reference missing'); + categories.push(ref); + } + } + const properties: Record = + Object.create(null); + for (const [id, type] of definitions) + if ( + !['Id', 'Label', 'Source', 'Target', 'Category', 'Group'].includes(id) && + Object.hasOwn(attributes, id) + ) { + Object.defineProperty(properties, id, { + value: getProperty(attributes[id], type), + enumerable: true, + writable: true, + configurable: true, + }); + delete attributes[id]; + } + for (const key of [ + 'Id', + 'Label', + 'Source', + 'Target', + 'Category', + ...(node ? ['Group'] : []), + ]) + delete attributes[key]; + const group = node ? getXMLAttribute(element, 'Group')?.value : undefined; + if (group !== undefined && !['Expanded', 'Collapsed'].includes(group)) + throw new Error('DGML: invalid Group value'); + return { + attributes, + categories, + properties, + ...(node && { declared: true }), + ...(group !== undefined && { group: group as DGMLNodeData['group'] }), + }; +} +export function fromDGML( + source: string, + _options: DGMLImportOptions = {} +): DGMLGraph { + const parsed = getDGMLSource(source); + const nodes: GraphNode[] = [], + edges: GraphEdge[] = [], + nodeMap = new Map>(); + function addNativeNode(id: string, element?: XMLElement) { + if (nodeMap.has(id)) { + if (element) throw new Error('DGML: duplicate node ID'); + return; + } + const node: GraphNode = { + type: 'node', + id, + label: element ? getXMLAttribute(element, 'Label')?.value ?? id : id, + parentId: null, + initialNodeId: null, + data: element + ? getData(element, parsed.elements, parsed.definitions, true) + : { attributes: {}, categories: [], properties: {}, declared: false }, + }; + nodeMap.set(id, node); + nodes.push(node); + } + for (const element of parsed.nodeRecords) { + const id = getXMLAttribute(element, 'Id')?.value; + if (id === undefined) throw new Error('DGML: missing node ID'); + addNativeNode(id, element); + } + for (const [index, element] of parsed.linkRecords.entries()) { + const sourceId = getXMLAttribute(element, 'Source')?.value, + targetId = getXMLAttribute(element, 'Target')?.value; + if (sourceId === undefined || targetId === undefined) + throw new Error('DGML: missing link endpoint'); + addNativeNode(sourceId); + addNativeNode(targetId); + const data = getData(element, parsed.elements, parsed.definitions, false); + if (data.categories?.includes('Contains')) { + const child = nodeMap.get(targetId)!; + if (child.parentId !== null) + throw new Error('DGML: multiple containment parents'); + child.parentId = sourceId; + } else + edges.push({ + type: 'edge', + id: `dgml_link_${index}`, + sourceId, + targetId, + label: getXMLAttribute(element, 'Label')?.value ?? null, + mode: 'directed', + data, + }); + } + const complete = new Set(); + for (const node of nodes) { + const visiting = new Set(); + let current: GraphNode | undefined = node; + while (current && !complete.has(current.id)) { + if (visiting.has(current.id)) throw new Error('DGML: cyclic containment'); + visiting.add(current.id); + current = + current.parentId == null ? undefined : nodeMap.get(current.parentId); + } + for (const id of visiting) complete.add(id); + } + const graph: DGMLGraph = { + id: '', + mode: 'directed', + initialNodeId: null, + nodes, + edges, + data: { + attributes: getAttributes(parsed.root), + categories: parsed.categories, + properties: parsed.properties, + }, + }; + graph.data = createNativeCarrier(graph.data, 'dgml', source, graph); + (graph.data as any).__statelyaiNative.baselineNative = source; + return graph; +} +function getPropertyText(value: any, type: string | undefined): string { + if ( + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' + ) + throw new Error('DGML: unsupported property value'); + const text = String(value); + if (!isJSONEqual(getProperty(text, type), value)) + throw new Error('DGML: property edit changes declared type'); + return text; +} +function getRecordText( + name: string, + attrs: Record, + children = '' +): string { + return `<${name}${Object.entries(attrs) + .map(([k, v]) => { + if ( + typeof v !== 'string' || + !/^[A-Za-z_][\w.:-]*$/.test(k) || + k.includes(':') || + k === 'xmlns' + ) + throw new Error('DGML: invalid native attribute name'); + return ` ${k}="${getEncodedXML(v, '"')}"`; + }) + .join('')}${children ? `>${children}` : '/>'}`; +} +function getEntityAttributes( + data: DGMLNodeData | undefined, + definitions: Map, + node = false +): Record { + if ( + data && + Object.keys(data).some( + (key) => + ![ + 'attributes', + 'categories', + 'properties', + ...(node ? ['group', 'declared'] : []), + ].includes(key) + ) + ) + throw new Error('DGML: unrepresentable data key'); + const result = { ...(data?.attributes ?? {}) }; + for (const [key, value] of Object.entries(result)) + if ( + typeof value !== 'string' || + ['Id', 'Label', 'Source', 'Target', 'Category', 'Group'].includes(key) || + definitions.has(key) + ) + throw new Error('DGML: protected/nonstring native attribute'); + for (const [key, value] of Object.entries(data?.properties ?? {})) { + if (['Id', 'Label', 'Source', 'Target', 'Category', 'Group'].includes(key)) + throw new Error('DGML: protected structural property'); + if (!definitions.has(key)) throw new Error('DGML: undeclared property'); + Object.defineProperty(result, key, { + value: getPropertyText(value, definitions.get(key)), + enumerable: true, + }); + } + return result; +} +function toRawDGML(graph: DGMLGraph, options: DGMLExportOptions = {}): string { + getValidatedGraph(graph, 'DGML'); + if ( + graph.mode !== 'directed' || + graph.edges.some((e) => getEdgeMode(graph, e) !== 'directed') + ) + throw new Error('DGML: directed graphs required'); + if (graph.id !== '' || graph.initialNodeId != null) + throw new Error('DGML: unsupported Graph root identity/initial node'); + const data = graph.data ?? {}; + if ( + Object.keys(data).some( + (key) => !['attributes', 'categories', 'properties'].includes(key) + ) + ) + throw new Error('DGML: unrepresentable root data key'); + if (Object.hasOwn(data.attributes ?? {}, 'Id')) + throw new Error('DGML: protected root Id'); + const definitions = new Map( + (data.properties ?? []).map((p) => [p.Id, p.DataType]) + ); + const containmentCount = graph.nodes.filter((n) => n.parentId != null).length; + const nodes = new Map(graph.nodes.map((n) => [n.id, n])), + parents = new Set( + graph.nodes.flatMap((n) => (n.parentId == null ? [] : [n.parentId])) + ); + const lines = [ + `${getRecordText('DirectedGraph', data.attributes ?? {}).slice( + 0, + -2 + )} xmlns="${NS}">`, + '', + ]; + function getCategories(values: string[] | undefined) { + return (values ?? []) + .map((Ref) => getRecordText('Category', { Ref })) + .join(''); + } + function getSupportedFields(record: any, allowed: string[]) { + if ( + Object.keys(record).some( + (k) => !allowed.includes(k) && record[k] !== undefined + ) + ) + throw new Error('DGML: unrepresentable Graph field'); + } + getSupportedFields(graph, [ + 'id', + 'mode', + 'initialNodeId', + 'data', + 'nodes', + 'edges', + ]); + for (const node of graph.nodes) { + getSupportedFields(node, [ + 'type', + 'id', + 'label', + 'parentId', + 'initialNodeId', + 'data', + ]); + if (node.data?.declared === false) + throw new Error( + 'DGML: fresh export cannot retain implicit declaration provenance' + ); + if (node.initialNodeId != null || typeof node.label !== 'string') + throw new Error('DGML: string label/empty initial node required'); + if (node.parentId != null && !nodes.has(node.parentId)) + throw new Error('DGML: missing parent'); + const attrs = getEntityAttributes(node.data, definitions, true); + attrs.Id = node.id; + attrs.Label = node.label; + if (node.data?.group) attrs.Group = node.data.group; + else if (parents.has(node.id)) attrs.Group = 'Expanded'; + lines.push( + getRecordText('Node', attrs, getCategories(node.data?.categories)) + ); + } + lines.push('', ''); + for (const node of graph.nodes) + if (node.parentId != null) + lines.push( + getRecordText('Link', { + Source: node.parentId, + Target: node.id, + Category: 'Contains', + }) + ); + for (const [index, edge] of graph.edges.entries()) { + if ( + options.edgeIds !== 'positional' && + edge.id !== `dgml_link_${containmentCount + index}` + ) + throw new Error( + 'DGML: native links have positional IDs; explicit edgeIds: positional required' + ); + getSupportedFields(edge, [ + 'type', + 'id', + 'sourceId', + 'targetId', + 'label', + 'mode', + 'data', + ]); + if (edge.data?.categories?.includes('Contains')) + throw new Error('DGML: ordinary edge cannot use Contains category'); + const attrs = getEntityAttributes(edge.data, definitions); + attrs.Source = edge.sourceId; + attrs.Target = edge.targetId; + if (edge.label != null) attrs.Label = edge.label; + lines.push( + getRecordText('Link', attrs, getCategories(edge.data?.categories)) + ); + } + lines.push(''); + for (const [section, record, values] of [ + ['Categories', 'Category', data.categories ?? []], + ['Properties', 'Property', data.properties ?? []], + ] as const) + if (values.length) + lines.push( + `<${section}>${values + .map((v) => getRecordText(record, v)) + .join('')}` + ); + lines.push(''); + const result = lines.join('\n'); + fromDGML(result); + return result; +} +function getDGMLPatch(context: GraphDocumentPatchContext) { + const parsed = getDGMLSource(context.source), + nodes = new Map( + parsed.nodeRecords.map((e) => [getXMLAttribute(e, 'Id')?.value, e]) + ); + const links = new Map( + parsed.linkRecords.map((e, i) => [`dgml_link_${i}`, e]) + ); + const edits: TextEdit[] = []; + if (context.changes.some((change) => change.entity === 'edge')) { + const baseline = getDGMLSource(context.baselineSource); + const originalLinks = baseline.linkRecords.map((element) => + context.baselineSource.slice(element.start, element.end) + ); + const currentLinks = parsed.linkRecords.map((element) => + context.source.slice(element.start, element.end) + ); + if (!isJSONEqual(originalLinks, currentLinks)) + throw new Error( + 'DGML: positional Link sequence changed; edge patch is ambiguous' + ); + } + function addAttribute(element: XMLElement, name: string, value: string) { + const attr = getXMLAttribute(element, name); + if (attr) + edits.push({ + start: attr.start, + end: attr.end, + text: getEncodedXML(value, attr.quote), + }); + else { + const at = element.openEnd - (element.empty ? 2 : 1); + edits.push({ + start: at, + end: at, + text: ` ${name}="${getEncodedXML(value, '"')}"`, + }); + } + } + for (const change of context.changes) { + if (change.entity !== 'node' && change.entity !== 'edge') + throw new Error( + 'DGML: only declared entity label/property edits supported' + ); + const element = + change.entity === 'node' ? nodes.get(change.id) : links.get(change.id!); + if (!element) + throw new Error('DGML: implicit node/structural edits unsupported'); + if (change.field === 'label' && typeof change.after === 'string') { + addAttribute(element, 'Label', change.after); + continue; + } + if (change.field === 'data') { + const before = change.before as DGMLNodeData, + after = change.after as DGMLNodeData; + if ( + !isJSONEqual( + { ...before, properties: undefined }, + { ...after, properties: undefined } + ) + ) + throw new Error('DGML: only typed properties in data can be edited'); + for (const key of new Set([ + ...Object.keys(before.properties ?? {}), + ...Object.keys(after.properties ?? {}), + ])) { + if (isJSONEqual(before.properties?.[key], after.properties?.[key])) + continue; + if ( + !Object.hasOwn(after.properties ?? {}, key) || + !parsed.definitions.has(key) + ) + throw new Error( + 'DGML: property deletion/undeclared property unsupported' + ); + addAttribute( + element, + key, + getPropertyText(after.properties![key], parsed.definitions.get(key)) + ); + } + continue; + } + throw new Error('DGML: unsupported structural/native edit'); + } + return { ok: true as const, source: getEditedSource(context.source, edits) }; +} +export function toDGML( + graph: DGMLGraph, + options: DGMLExportOptions = {} +): string { + const carrier = + options.nativeCarrier === false + ? undefined + : getNativeCarrier(graph.data, 'dgml'); + if (!carrier) return toRawDGML(graph, options); + const originalSource = ( + carrier as typeof carrier & { baselineNative?: string } + ).baselineNative; + if (originalSource === undefined) + throw new Error('DGML: missing native provenance baseline'); + const document = dgmlDocumentConverter.from(originalSource); + if (!isJSONEqual(document.baseline.graph, carrier.baseline)) + throw new Error('DGML: invalid native provenance baseline'); + document.source = carrier.native; + document.graph = { ...graph, data: getCarrierData(graph.data, 'dgml') }; + return dgmlDocumentConverter.to(document); +} +export const dgmlConverter = createFormatConverter(toDGML, fromDGML); +export const dgmlDocumentConverter = createGraphDocumentConverter({ + format: 'dgml', + codec: { from: fromDGML, to: toRawDGML }, + project: (source: string) => ({ + graph: getNativeDocumentGraph(fromDGML(source), 'dgml', source), + }), + patch: getDGMLPatch, +}); diff --git a/src/formats/dot/README.md b/src/formats/dot/README.md index 42f62e6..01c2f3c 100644 --- a/src/formats/dot/README.md +++ b/src/formats/dot/README.md @@ -158,3 +158,13 @@ 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. + + + +Generated DOT edge IDs follow statement expansion order. A strict edge-label +patch after any change to the native edge record sequence requires one matching +versioned `_stately.id` in both baseline and current source. Insertions, removals, +reordering or record edits otherwise reject with `ambiguous-native-identity`. +The ordinary DOT `id` attribute is retained native data and is not this private +Graph identity protocol. Comments/defaults outside unchanged edge records and +stable node-label edits remain mergeable; source-only updates remain authoritative. diff --git a/src/formats/dot/document.ts b/src/formats/dot/document.ts index 0f230f9..d16ecb2 100644 --- a/src/formats/dot/document.ts +++ b/src/formats/dot/document.ts @@ -9,7 +9,7 @@ export function getValidatedDOTString(value: string): string { } 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 } +interface Statement { kind: 'node' | 'edge'; private: boolean; id?: string; sourceId?: string; targetId?: string; labels: Token[]; start: number; end: number; privateId?: string } function getTokens(source: string): Token[] { const tokens: Token[] = []; @@ -74,15 +74,17 @@ function getStatements(tokens: Token[]): Statement[] { if (['->', '--', ',', '+'].includes(tokens[next]?.text)) { cursor++; continue; } } else if (['=', ',', '+'].includes(tokens[next]?.text)) { cursor++; continue; } const labels: Token[] = []; - let hasPrivate = false; + let hasPrivate = false, privateId: string | undefined; 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') { + hasPrivate = false; privateId = undefined; try { const value = JSON.parse(getId(tokens[next + 2]).replace(/\\(\\|n)/g, (_, character) => character === 'n' ? '\n' : '\\')); - hasPrivate ||= value?.format === 'stately' && value.version === 1; + hasPrivate = tokens[next + 2].kind === 'quoted' && tokens[next + 3]?.text !== '+' && value?.format === 'stately' && value.version === 1; + if (hasPrivate && typeof value.id === 'string') privateId = value.id; } catch { /* Invalid reserved strings remain native source, not a private carrier. */ } } if (getId(tokens[next]) === 'label') { @@ -98,8 +100,8 @@ function getStatements(tokens: Token[]): Statement[] { } 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 }); + statements.push(kind === 'node' ? { kind, id: getId(first), labels, start: first.start, end, private: hasPrivate, privateId } + : { kind, sourceId: getId(first), targetId: getId(target!), labels, start: first.start, end, private: hasPrivate, privateId }); cursor = next; } return statements; @@ -111,7 +113,12 @@ export function getDOTDocumentPatch(context: GraphDocumentPatchContext): if (invalid) return invalid; const statements = getStatements(getTokens(context.source)); const nodeStatements = new Map(); - const edges = statements.filter((statement) => statement.kind === 'edge'); + const edges = statements.filter((statement) => statement.kind === 'edge'), baselineEdges = getStatements(getTokens(context.baselineSource)).filter((statement) => statement.kind === 'edge'); + const getPrivateIds = (records: Statement[]) => { const ids = new Map(); for (const statement of records) if (statement.privateId !== undefined) { const entries = ids.get(statement.privateId) ?? []; entries.push(statement); ids.set(statement.privateId, entries); } return ids; }; + const currentIds = getPrivateIds(edges), baselineIds = getPrivateIds(baselineEdges); + const sameSequence = JSON.stringify(edges.map((statement) => context.source.slice(statement.start, statement.end))) === JSON.stringify(baselineEdges.map((statement) => context.baselineSource.slice(statement.start, statement.end))); + const edgeIndices = new Map(context.sourceGraph.edges.map((edge, index) => [edge.id, index])); + const simpleEdges = edges.length === context.sourceGraph.edges.length && edges.every((statement, index) => statement.sourceId === context.sourceGraph.edges[index].sourceId && statement.targetId === context.sourceGraph.edges[index].targetId); for (const statement of statements) if (statement.kind === 'node') { const group = nodeStatements.get(statement.id!) ?? []; group.push(statement); nodeStatements.set(statement.id!, group); } @@ -120,8 +127,9 @@ export function getDOTDocumentPatch(context: GraphDocumentPatchContext): 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'); + const index = edgeIndices.get(change.id!) ?? -1; + if ((baselineIds.get(change.id!)?.length ?? 0) > 1 || (currentIds.get(change.id!)?.length ?? 0) > 1 || !sameSequence && (baselineIds.get(change.id!)?.length !== 1 || currentIds.get(change.id!)?.length !== 1)) return { ok: false, diagnostics: [{ code: 'ambiguous-native-identity', message: 'DOT generated edge identity is ambiguous after the native edge record sequence changed; a unique private ID must exist in both source snapshots', path: change.path, severity: 'error' }] }; + if (!simpleEdges || index < 0) 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'); diff --git a/src/formats/gexf/README.md b/src/formats/gexf/README.md index e1278b0..018f224 100644 --- a/src/formats/gexf/README.md +++ b/src/formats/gexf/README.md @@ -160,3 +160,12 @@ and rejects invalid XML characters/nonfinite native numbers. The native suite pi 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. + + + +Strict label patches require a unique explicit native entity `id` in both the +baseline and current projected graph scopes. A newly inserted `id="e0"` cannot +validate a historical generated `e0` edge. Foreign namespaces/unselected graphs +cannot supply the baseline identity. Stable native IDs still permit independent +source edits; source-only updates remain authoritative. Ambiguity reports the +field path with `ambiguous-native-identity` instead of patching another edge. diff --git a/src/formats/gml/README.md b/src/formats/gml/README.md index e2cee9c..7bd25cf 100644 --- a/src/formats/gml/README.md +++ b/src/formats/gml/README.md @@ -144,3 +144,12 @@ Portable native-validation CI uses pinned NetworkX 3.7 `parse_gml(..., label=Non 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. + + + +Strict label patches require a unique native `id` or independently recognized +versioned `originalId` in both baseline and current projected scopes. A new explicit +`id "e0"` cannot validate a historical generated edge identity. Repeated private +identity values do not authorize a patch. Stable IDs permit independent native +attribute edits; source-only updates retain exact text. Ambiguous identity reports +`ambiguous-native-identity` with the requested field path. diff --git a/src/formats/gml/document.ts b/src/formats/gml/document.ts index 1ee246a..c45da92 100644 --- a/src/formats/gml/document.ts +++ b/src/formats/gml/document.ts @@ -58,7 +58,8 @@ function getValues(block: Block, key: string): Token[] { } function getId(block: Block): string | undefined { - for (const token of getValues(block, 'stately')) { + const privateTokens = getValues(block, 'stately'); + for (const token of privateTokens.length === 1 ? privateTokens : []) { try { const value = JSON.parse(token.value); if (value?.format === 'stately' && value.version === 1 && typeof value.originalId === 'string') return value.originalId; @@ -75,19 +76,31 @@ export function getGMLDocumentProjection(source: string, from: (source: string) 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' }] }; } +function getGMLIdentities(blocks: Block[]) { + const graphIndex = blocks[0].entries.find((entry) => entry.key === 'graph' && entry.block !== undefined)?.block; + if (graphIndex === undefined) return undefined; + 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 identities = new Map(); + for (const [index, block] of blocks.entries()) { + if (!['node', 'edge'].includes(block.key) || !(block.key === 'node' ? selected.has(index) : block.parent === graphIndex)) continue; + const id = getId(block); if (id === undefined) continue; + const key = JSON.stringify([block.key, id]), entries = identities.get(key) ?? []; entries.push(block); identities.set(key, entries); + } + return identities; +} + /** 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 current = getGMLIdentities(blocks), baseline = getGMLIdentities(getBlocks(context.baselineSource, decode)); + if (!current) return getUnsupported(context.changes[0], 'Cannot locate the GML graph'); 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 key = JSON.stringify([change.entity, change.id]), candidates = current.get(key) ?? []; + if (candidates.length !== 1 || baseline?.get(key)?.length !== 1) return { ok: false, diagnostics: [{ code: 'ambiguous-native-identity', message: 'GML label editing requires a unique explicit native entity ID (or versioned originalId) in both baseline and current source', path: change.path, severity: 'error' }] }; 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; diff --git a/src/formats/graphml/README.md b/src/formats/graphml/README.md index fa29c1f..f34ef66 100644 --- a/src/formats/graphml/README.md +++ b/src/formats/graphml/README.md @@ -140,3 +140,12 @@ 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. + + + +Strict label patches require a unique explicit native entity `id` in both the +baseline and current projected graph scopes. A newly inserted explicit ID cannot +validate an older generated edge ID, even if its text matches that fallback. +Other graphs and foreign namespaces cannot supply this identity. Stable IDs permit +independent native data edits; source-only changes retain exact source text. +Ambiguous identity rejects with path-bearing `ambiguous-native-identity` diagnostics. diff --git a/src/formats/graphology/README.md b/src/formats/graphology/README.md index d65ad5c..9c2c44f 100644 --- a/src/formats/graphology/README.md +++ b/src/formats/graphology/README.md @@ -27,3 +27,7 @@ Documents retain the complete original JSON source, order, unknown fields and pr 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). + + + +Pending Graph edge edits require a unique explicit native key in both the baseline and current source when the native edge sequence changes. Generated keys cannot acquire identity from a later explicit key. Without those keys, insertion, removal, reordering or edge-record changes reject with `ambiguous-native-identity`. Unchanged sequences, source-only updates and stable node-ID edits remain supported. diff --git a/src/formats/graphson/README.md b/src/formats/graphson/README.md new file mode 100644 index 0000000..0c3ef98 --- /dev/null +++ b/src/formats/graphson/README.md @@ -0,0 +1,77 @@ +# GraphSON 3 typed TinkerGraph objects + + + +```ts +import { fromGraphSON, toGraphSON, graphsonDocumentConverter } from '@statelyai/graph/graphson'; +const source = toGraphSON(graph, { version: 3, profile: 'tinker:graph' }); +const restored = fromGraphSON(source); +const document = graphsonDocumentConverter.from(source); +const exact = graphsonDocumentConverter.to(JSON.parse(JSON.stringify(document))); +``` + +`graphsonConverter` supplies `.from`/`.to`. Public types describe typed values, +vertices, edges, vertex properties and import/export options. Implementation is +synchronous and dependency-free. Defaults select the explicit GraphSON3 +`{"@type":"tinker:graph","@value":{"vertices":[],"edges":[]}}` object profile +registered by Apache `TinkerIoRegistryV3`. GraphSON1/2, untyped JSON, adjacency +`writeGraph`/NDJSON, `g:Graph` and `g:TinkerGraph` are unsupported. + + + +Only directed topology is representable. String, typed Int32, Int64, BigInteger +and UUID identities occupy distinct namespaces. Int64/BigInteger decimal strings +retain unsafe magnitudes; unsafe JavaScript numeric IDs reject. UUID identities +normalize case. Duplicate vertices/edges/property IDs and dangling endpoints +reject. Parallel edges, self-loops and isolates remain distinct. + +Native properties remain typed JSON in node/edge data. Multi-properties, +vertex-property IDs, meta-properties, typed maps with non-string keys, sets and +unknown property extensions are retained, without flattening them into strings +or interpreting arbitrary extension types. Exact BigDecimal values require +string wrappers. Finite Double/Float `weight` properties also project to Graph +weight; their raw property wrappers remain in data. + +Fresh Graph export stores application data in a native property named `data`, +whose value is an opaque JSON **string**. Native labels represent element category +labels, not a `name` property. Null Graph labels use nonempty native fallbacks; +empty labels reject because the native element-label domain requires nonempty +strings. A versioned root string marker retains additional Graph fields, field +presence and user identities. This marker is a Graph preservation convention; +TinkerPop reading/writing discards it. It does not make Graph geometry, hierarchy, +ports, arbitrary root data or other Graph-only fields native TinkerGraph semantics. + + + +Imported source JSON is retained exactly after JSON persistence, including array +order and unknown fields. Native edits remain authoritative. Documents support +nonempty label edits and existing stable property-value edits, retaining property +IDs/order/meta-properties. Vertex-label patches update verified endpoint-label +mirrors; conflicting mirror edits reject. Structural edits, arbitrary Graph-only +field edits, root-data edits, property identity/meta-property changes and Graph +weight edits currently reject with diagnostics. No stale source replay hides edits. + +Bare imported Graph export uses the same strict patch boundary. Explicit +`nativeCarrier: false` regenerates a fresh scoped profile. `metadata: 'native'` +imports reserved marker fields as ordinary native data. Valid-looking private +metadata remains untrusted and validated; exact reserved-schema collisions remain +a convention boundary, so use the native option for foreign lookalikes. + +The JSON domain rejects cycles, functions, undefined, symbols, accessors, +nonenumerable fields, custom prototypes, sparse/custom arrays and nonfinite or +signed-zero numbers. Runtime object alias identity is outside the JSON contract. +Source retention establishes JSON value preservation, not file-byte encoding or +complete interpretation of every GraphSON type extension. + + + +Pinned Apache TinkerPop **3.8.2** Java `GraphSONMapper` with `TinkerIoRegistryV3` +and `GraphSONXModuleV3` reads emitted/foreign graphs. Tests observe native topology, +category labels, Java ID classes, unsafe Int64/BigDecimal magnitudes, +multi/meta-property identity, maps, sets and patched property values. The native +`TinkerFactory.createModern()` fixture is byte-hashed with provenance. A separate +Gremlin JavaScript3.8.2 reader checks supported typed elements/collections; +whole-graph registry proof comes from Java. This proves selected profiles, not +all GraphSON schemas, extensions, adjacency formats or TinkerPop providers. + +[Official GraphSON documentation](https://tinkerpop.apache.org/docs/current/dev/io/). diff --git a/src/formats/graphson/index.ts b/src/formats/graphson/index.ts new file mode 100644 index 0000000..b484449 --- /dev/null +++ b/src/formats/graphson/index.ts @@ -0,0 +1,973 @@ +import type { Graph } from "../../types"; +import { createFormatConverter } from "../converter"; +import { + createGraphDocumentConverter, + type GraphDocumentPatchContext, + type GraphDocumentPatchResult, +} from "../document"; +import { + createNativeCarrier, + getNativeCarrier, + getCarrierData, + getNativeDocumentGraph, + getClonedJSON, + isJSONEqual, + type FormatImportOptions, + type FormatExportOptions, +} from "../preservation"; +import { + getValidatedGraph, + isNativeMetadataRecord, + isNativeField, +} from "../structured-json"; + +export interface GraphSONTypedValue { + "@type": string; + "@value": T; + [field: string]: any; +} +export interface GraphSONVertexProperty { + "@type": "g:VertexProperty"; + "@value": { + id: any; + label: string; + value: any; + properties?: Record; + [field: string]: any; + }; + [field: string]: any; +} +export interface GraphSONVertex { + "@type": "g:Vertex"; + "@value": { + id: any; + label?: string; + properties?: Record; + [field: string]: any; + }; + [field: string]: any; +} +export interface GraphSONEdge { + "@type": "g:Edge"; + "@value": { + id: any; + label?: string; + outV: any; + inV: any; + outVLabel?: string; + inVLabel?: string; + properties?: Record; + [field: string]: any; + }; + [field: string]: any; +} +/** GraphSON3 object serialization with Apache TinkerIoRegistryV3; not writeGraph adjacency JSON. */ +export interface GraphSONGraph { + "@type": "tinker:graph"; + "@value": { + vertices: GraphSONVertex[]; + edges: GraphSONEdge[]; + [field: string]: any; + }; + [field: string]: any; +} +export interface GraphSONImportOptions extends FormatImportOptions { + version?: 3; + profile?: "tinker:graph"; +} +export interface GraphSONExportOptions extends FormatExportOptions { + version?: 3; + profile?: "tinker:graph"; +} +interface GraphSONState { + kind: "@statelyai/graph/graphson"; + version: 1; + profile: "tinker:graph"; + original: Graph; + baseline: Graph; +} +interface GraphSONProjection { + graph: Graph; + vertices: Map; + edges: Map; + state?: GraphSONState; +} +const STATE = "__statelyai"; + +function getProfile( + options: GraphSONImportOptions | GraphSONExportOptions, +): void { + if ( + (options.version !== undefined && options.version !== 3) || + (options.profile !== undefined && options.profile !== "tinker:graph") + ) + throw new Error( + "GraphSON: only version3 typed tinker:graph object profile is supported", + ); +} +function getObject(value: any, label: string): Record { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + ![Object.prototype, null].includes(Object.getPrototypeOf(value)) + ) + throw new Error(`GraphSON: expected ${label} object`); + return value; +} +function getWrapper(value: any, type: string): any { + const record = getObject(value, type); + if ( + !Object.hasOwn(record, "@type") || + record["@type"] !== type || + !Object.hasOwn(record, "@value") + ) + throw new Error(`GraphSON: expected typed ${type}`); + return record["@value"]; +} +function getRequiredValue(record: Record, field: string): any { + if (!Object.hasOwn(record, field)) throw new Error(`GraphSON: missing own ${field}`); + return record[field]; +} +function getInteger( + value: any, + type: string, + min: bigint, + max: bigint, +): string { + if ( + (typeof value !== "string" || !/^[+-]?\d+$/.test(value)) && + (typeof value !== "number" || !Number.isSafeInteger(value)) + ) + throw new Error( + `GraphSON: ${type} requires an exact integer; unsafe JSON numbers reject`, + ); + const number = BigInt(value); + if (number < min || number > max) + throw new Error(`GraphSON: ${type} is out of range`); + return number.toString(); +} +function getIdentity(value: any): string { + let type: string, normalized: any; + if (typeof value === "string") { + type = "string"; + normalized = value; + } else { + const record = getObject(value, "typed ID"); + type = record["@type"]; + if (!Object.hasOwn(record, "@type") || !Object.hasOwn(record, "@value")) + throw new Error("GraphSON: ID requires @value"); + const inner = record["@value"]; + if (type === "g:Int32") + normalized = getInteger(inner, type, -2147483648n, 2147483647n); + else if (type === "g:Int64") + normalized = getInteger( + inner, + type, + -9223372036854775808n, + 9223372036854775807n, + ); + else if (type === "gx:BigInteger") { + if ( + (typeof inner !== "string" || !/^[+-]?\d+$/.test(inner)) && + (typeof inner !== "number" || !Number.isSafeInteger(inner)) + ) + throw new Error("GraphSON: BigInteger ID must be exact"); + normalized = BigInt(inner).toString(); + } else if ( + type === "g:UUID" && + typeof inner === "string" && + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + inner, + ) + ) + normalized = inner.toLowerCase(); + else + throw new Error( + "GraphSON: IDs must be strings or supported typed Int32/Int64/BigInteger/UUID values", + ); + } + return `graphson:${JSON.stringify([type, normalized])}`; +} +function getValidatedValues(source: any, typed = true): void { + const active = new Set(), + stack: Array<{ value: any; leave?: boolean }> = [{ value: source }]; + while (stack.length) { + const frame = stack.pop()!, + value = frame.value; + if (frame.leave) { + active.delete(value); + continue; + } + if (value === null || ["string", "boolean"].includes(typeof value)) + continue; + if (typeof value === "number") { + if (!Number.isFinite(value) || Object.is(value, -0)) + throw new Error("GraphSON: non-finite/signed-zero JSON numbers reject"); + continue; + } + if (!value || typeof value !== "object") + throw new Error("GraphSON: source values must be plain JSON"); + if (active.has(value)) + throw new Error( + "GraphSON: cyclic values are outside the JSON source profile", + ); + active.add(value); + stack.push({ value, leave: true }); + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(value); + if ( + keys.some( + (key) => + typeof key !== "string" || + (!(Array.isArray(value) && key === "length") && + (!descriptors[key].enumerable || + !Object.hasOwn(descriptors[key], "value"))), + ) + ) + throw new Error( + "GraphSON: symbol/accessor/nonenumerable source fields reject", + ); + if (Array.isArray(value)) { + if ( + Object.getPrototypeOf(value) !== Array.prototype || + keys.length !== value.length + 1 || + keys.some( + (key) => + !(Array.isArray(value) && key === "length") && + (typeof key !== "string" || + !/^(?:0|[1-9]\d*)$/.test(key) || + Number(key) >= value.length), + ) + ) + throw new Error("GraphSON: sparse/custom arrays reject"); + } else { + getObject(value, "JSON"); + const type = typed ? value["@type"] : undefined, + inner = value["@value"]; + if (type === "g:Int32") + getInteger(inner, type, -2147483648n, 2147483647n); + if (type === "g:Int64") + getInteger(inner, type, -9223372036854775808n, 9223372036854775807n); + if ( + type === "gx:BigInteger" && + (typeof inner !== "string" || !/^[+-]?\d+$/.test(inner)) && + (typeof inner !== "number" || !Number.isSafeInteger(inner)) + ) + throw new Error("GraphSON: BigInteger value must be exact"); + if ( + type === "gx:BigDecimal" && + (typeof inner !== "string" || + !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(inner)) + ) + throw new Error( + "GraphSON: BigDecimal requires an exact decimal string", + ); + if ( + ["g:Map", "g:List", "g:Set"].includes(type) && + (!Array.isArray(inner) || (type === "g:Map" && inner.length % 2)) + ) + throw new Error("GraphSON: malformed typed collection"); + } + for (const key of keys) + if (!(Array.isArray(value) && key === "length")) + stack.push({ value: descriptors[key as string].value }); + } +} +function getLabel(value: any, fallback: string): string { + if (value === undefined) return fallback; + if (typeof value !== "string" || !value) + throw new Error("GraphSON: native element labels must be nonempty strings"); + return value; +} +function getBodyData(body: Record, fields: string[]): any { + return getClonedJSON( + Object.fromEntries( + Object.entries(body).filter(([key]) => !fields.includes(key)), + ), + ); +} +function isStateGraph(graph: any): boolean { + return ( + graph && + Array.isArray(graph.nodes) && + Array.isArray(graph.edges) && + isNativeMetadataRecord(graph, "graph") && + graph.nodes.every( + (node: any) => + isNativeMetadataRecord(node, "node") && + (!node.ports || + node.ports.every( + (port: any) => + !Object.hasOwn(port, "style") || + isNativeField("style", port.style), + )), + ) && + graph.edges.every((edge: any) => isNativeMetadataRecord(edge, "edge")) + ); +} +function getState( + source: GraphSONGraph, + native: boolean, +): GraphSONState | undefined { + if (native) return undefined; + if (!Object.hasOwn(source["@value"], STATE)) return undefined; + const text = source["@value"][STATE]; + if (typeof text !== "string") return undefined; + let value: any; + try { + value = JSON.parse(text); + } catch { + return undefined; + } + if ( + value?.kind !== "@statelyai/graph/graphson" || + value.version !== 1 || + value.profile !== "tinker:graph" + ) + return undefined; + for (const graph of [value.original, value.baseline]) { + if (!isStateGraph(graph)) return undefined; + try { + getValidatedGraph(graph, "GraphSON state"); + getValidatedValues(graph, false); + } catch { + return undefined; + } + } + if ( + value.original.mode !== "directed" || + value.original.edges.some( + (edge: any) => edge.mode !== undefined && edge.mode !== "directed", + ) + ) + return undefined; + if ( + value.original.nodes.some( + (node: any, index: number) => + getIdentity(node.id) !== value.baseline.nodes[index]?.id, + ) || + value.original.edges.some( + (edge: any, index: number) => + getIdentity(edge.id) !== value.baseline.edges[index]?.id || + getIdentity(edge.sourceId) !== value.baseline.edges[index]?.sourceId || + getIdentity(edge.targetId) !== value.baseline.edges[index]?.targetId, + ) + ) + return undefined; + if ( + value.original.nodes.length !== value.baseline.nodes.length || + value.original.edges.length !== value.baseline.edges.length + ) + return undefined; + return value; +} +function getRawProjection( + source: GraphSONGraph, + native: boolean, +): GraphSONProjection { + getValidatedValues(source); + const body = getObject( + getWrapper(source, "tinker:graph"), + "tinker:graph value", + ); + if ( + !Object.hasOwn(body, "vertices") || + !Object.hasOwn(body, "edges") || + !Array.isArray(body.vertices) || + !Array.isArray(body.edges) + ) + throw new Error( + "GraphSON: typed whole graph requires vertices and edges arrays", + ); + const state = getState(source, native), + vertices = new Map(), + edges = new Map(); + const graph: Graph = { + id: "", + mode: "directed", + data: { + ...getBodyData(source, ["@type", "@value"]), + ...getBodyData(body, ["vertices", "edges", ...(state ? [STATE] : [])]), + }, + nodes: [], + edges: [], + }; + const propertyIds = new Set(); + for (const vertex of body.vertices) { + const record = getObject(getWrapper(vertex, "g:Vertex"), "vertex value"), + id = getIdentity(getRequiredValue(record, "id")); + if (vertices.has(id)) + throw new Error("GraphSON: duplicate typed vertex ID"); + const label = getLabel( + Object.hasOwn(record, "label") ? record.label : undefined, + "vertex", + ); + vertices.set(id, vertex); + if (Object.hasOwn(record, "properties")) + for (const [key, properties] of Object.entries( + getObject(record.properties, "vertex properties"), + )) { + if (!key || !Array.isArray(properties)) + throw new Error("GraphSON: vertex properties require named arrays"); + for (const property of properties) { + const item = getObject( + getWrapper(property, "g:VertexProperty"), + "vertex property value", + ), + propertyId = getIdentity(getRequiredValue(item, "id")); + if ( + propertyIds.has(propertyId) || + item.label !== key || + !Object.hasOwn(item, "value") || + (item.vertex !== undefined && getIdentity(item.vertex) !== id) + ) + throw new Error( + "GraphSON: duplicate/mismatched vertex-property identity", + ); + propertyIds.add(propertyId); + if (item.properties !== undefined) + getObject(item.properties, "meta-properties"); + } + } + graph.nodes.push({ + type: "node", + id, + label, + data: getBodyData(record, ["id", "label"]), + }); + } + for (const edge of body.edges) { + const record = getObject(getWrapper(edge, "g:Edge"), "edge value"), + id = getIdentity(getRequiredValue(record, "id")), + sourceId = getIdentity(getRequiredValue(record, "outV")), + targetId = getIdentity(getRequiredValue(record, "inV")); + if (edges.has(id)) throw new Error("GraphSON: duplicate typed edge ID"); + if (!vertices.has(sourceId) || !vertices.has(targetId)) + throw new Error("GraphSON: dangling typed endpoint"); + if (Object.hasOwn(record, "properties")) + for (const [key, property] of Object.entries( + getObject(record.properties, "edge properties"), + )) { + const item = getObject( + getWrapper(property, "g:Property"), + "edge property value", + ); + if (!key || item.key !== key || !Object.hasOwn(item, "value")) + throw new Error("GraphSON: mismatched edge property"); + } + const weight = record.properties?.weight?.["@value"]?.value; + const numericWeight = + weight?.["@type"] === "g:Double" || weight?.["@type"] === "g:Float" + ? weight["@value"] + : undefined; + edges.set(id, edge); + graph.edges.push({ + type: "edge", + id, + sourceId, + targetId, + label: getLabel( + Object.hasOwn(record, "label") ? record.label : undefined, + "edge", + ), + data: getBodyData(record, [ + "id", + "label", + "outV", + "inV", + "outVLabel", + "inVLabel", + ]), + ...(typeof numericWeight === "number" && + Number.isFinite(numericWeight) && { weight: numericWeight }), + }); + } + return { graph, vertices, edges, state }; +} +function getDataComparison( + data: any, + baseline: any, + entity: "node" | "edge", +): { before: any; current: any; property: any; prior: any } { + const before = getClonedJSON(baseline), + current = getClonedJSON(data); + const property = + entity === "node" + ? current?.properties?.data?.[0] + : current?.properties?.data; + const prior = + entity === "node" + ? before?.properties?.data?.[0] + : before?.properties?.data; + if ( + property && + prior && + isJSONEqual( + { ...property, "@value": { ...property["@value"], value: null } }, + { ...prior, "@value": { ...prior["@value"], value: null } }, + ) + ) + property["@value"].value = prior["@value"].value; + if (entity === "edge") { + const weight = data?.properties?.weight?.["@value"]?.value; + if ( + weight === undefined || + (["g:Double", "g:Float"].includes(weight?.["@type"]) && + typeof weight?.["@value"] === "number" && + Number.isFinite(weight["@value"])) + ) { + for (const record of [before, current]) + if (record?.properties) { + delete record.properties.weight; + if (!Object.keys(record.properties).length) delete record.properties; + } + } + } + return { before, current, property, prior }; +} +function getDataValue( + data: any, + original: any, + baseline: any, + entity: "node" | "edge", +): any { + if (isJSONEqual(data, baseline)) return getClonedJSON(original); + const compared = getDataComparison(data, baseline, entity); + let userData = getClonedJSON(original); + const property = + entity === "node" ? data?.properties?.data?.[0] : data?.properties?.data; + if ( + property && + compared.prior && + isJSONEqual( + { ...property, "@value": { ...property["@value"], value: null } }, + { + ...compared.prior, + "@value": { ...compared.prior["@value"], value: null }, + }, + ) + ) { + const value = property["@value"]?.value; + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + getValidatedValues(parsed, false); + userData = parsed; + } catch { + userData = value; + } + } + } + return isJSONEqual(compared.before, compared.current) + ? userData + : { graphData: userData, native: getClonedJSON(data) }; +} +function getProjection( + source: GraphSONGraph, + options: GraphSONImportOptions = {}, +): GraphSONProjection { + getProfile(options); + const projection = getRawProjection(source, options.metadata === "native"), + state = projection.state; + if (!state) return projection; + const root = getClonedJSON(state.original); + const originals = new Map( + state.baseline.nodes.map((node, index) => [ + node.id, + { before: node, original: state.original.nodes[index] }, + ]), + ); + const restoredNodes = projection.graph.nodes.map((node) => { + const item = originals.get(node.id); + if (!item) return node; + const restored = getClonedJSON(item.original); + if (node.label !== item.before.label) restored.label = node.label; + restored.data = getDataValue( + node.data, + item.original.data, + item.before.data, + "node", + ); + return restored; + }); + const ids = new Map( + projection.graph.nodes.map((node, index) => [ + node.id, + restoredNodes[index].id, + ]), + ); + const priorEdges = new Map( + state.baseline.edges.map((edge, index) => [ + edge.id, + { before: edge, original: state.original.edges[index] }, + ]), + ); + const restoredEdges = projection.graph.edges.map((edge) => { + const item = priorEdges.get(edge.id); + if (!item) + return { + ...edge, + sourceId: ids.get(edge.sourceId)!, + targetId: ids.get(edge.targetId)!, + }; + const restored = getClonedJSON(item.original); + if (edge.label !== item.before.label) restored.label = edge.label; + if (edge.weight !== item.before.weight) { + if (edge.weight === undefined) delete restored.weight; + else restored.weight = edge.weight; + } + restored.sourceId = ids.get(edge.sourceId)!; + restored.targetId = ids.get(edge.targetId)!; + restored.data = getDataValue( + edge.data, + item.original.data, + item.before.data, + "edge", + ); + return restored; + }); + if (!isJSONEqual(projection.graph.data, state.baseline.data)) + root.data = { graphData: root.data, native: projection.graph.data }; + root.nodes = restoredNodes; + root.edges = restoredEdges; + getValidatedGraph(root, "GraphSON"); + return { ...projection, graph: root }; +} + +/** Only typed GraphSON3 tinker:graph; typed IDs and properties remain lossless JSON wrappers. */ +export function fromGraphSON( + source: GraphSONGraph, + options: GraphSONImportOptions = {}, +): Graph { + const graph = getProjection(source, options).graph; + graph.data = createNativeCarrier(graph.data, "graphson", source, graph); + return graph; +} +function getProperty(value: any, key: string): GraphSONTypedValue { + return { "@type": "g:Property", "@value": { key, value } }; +} +function toRawGraphSON( + graph: Graph, + options: GraphSONExportOptions = {}, +): GraphSONGraph { + getProfile(options); + getValidatedValues(graph, false); + getValidatedGraph(graph, "GraphSON"); + if (!isStateGraph(graph)) + throw new Error("GraphSON: invalid typed Graph fields"); + if ( + graph.mode !== "directed" || + graph.edges.some( + (edge) => edge.mode !== undefined && edge.mode !== "directed", + ) + ) + throw new Error("GraphSON: only directed graphs are representable"); + const labels = new Map( + graph.nodes.map((node) => [ + node.id, + getLabel(node.label ?? undefined, "vertex"), + ]), + ); + const source: GraphSONGraph = { + "@type": "tinker:graph", + "@value": { + vertices: graph.nodes.map((node, index) => { + const body: GraphSONVertex["@value"] = { + id: node.id, + label: labels.get(node.id)!, + }; + if (node.data !== undefined) + body.properties = { + data: [ + { + "@type": "g:VertexProperty", + "@value": { + id: `@statelyai/data/${index}`, + label: "data", + value: JSON.stringify(node.data), + }, + }, + ], + }; + return { "@type": "g:Vertex", "@value": body }; + }), + edges: graph.edges.map((edge) => { + const body: GraphSONEdge["@value"] = { + id: edge.id, + label: getLabel(edge.label ?? undefined, "edge"), + outV: edge.sourceId, + inV: edge.targetId, + outVLabel: labels.get(edge.sourceId)!, + inVLabel: labels.get(edge.targetId)!, + }; + if (edge.data !== undefined || edge.weight !== undefined) + body.properties = { + ...(edge.data !== undefined && { + data: getProperty(JSON.stringify(edge.data), "data"), + }), + ...(edge.weight !== undefined && { + weight: getProperty( + { "@type": "g:Double", "@value": edge.weight }, + "weight", + ), + }), + }; + return { "@type": "g:Edge", "@value": body }; + }), + }, + }; + const baseline = getRawProjection(source, true).graph; + const state: GraphSONState = { + kind: "@statelyai/graph/graphson", + version: 1, + profile: "tinker:graph", + original: getClonedJSON(graph), + baseline, + }; + source["@value"][STATE] = JSON.stringify(state); + return source; +} +function getPropertyPatch( + before: any, + after: any, + entity: "node" | "edge", +): any | undefined { + const copy = getClonedJSON(before); + if ( + !before || + !after || + !isJSONEqual( + Object.keys(before.properties ?? {}), + Object.keys(after.properties ?? {}), + ) + ) + return undefined; + for (const key of Object.keys(before.properties ?? {})) { + const originals = + entity === "node" ? before.properties[key] : [before.properties[key]], + updates = + entity === "node" ? after.properties[key] : [after.properties[key]]; + if (!Array.isArray(updates) || originals.length !== updates.length) + return undefined; + for (let index = 0; index < originals.length; index++) { + const old = originals[index], + update = updates[index]; + if (!old?.["@value"] || !update?.["@value"]) return undefined; + if ( + !isJSONEqual( + { ...old, "@value": { ...old["@value"], value: null } }, + { ...update, "@value": { ...update["@value"], value: null } }, + ) + ) + return undefined; + const property = + entity === "node" ? copy.properties[key][index] : copy.properties[key]; + property["@value"].value = getClonedJSON(update["@value"].value); + } + } + return isJSONEqual(copy, after) ? copy : undefined; +} +function getPatch( + context: GraphDocumentPatchContext, +): GraphDocumentPatchResult { + const source = getClonedJSON(context.source), + projection = getProjection(source), + state = projection.state; + const nativeBaselines = new Map( + [...(state?.baseline.nodes ?? []), ...(state?.baseline.edges ?? [])].map( + (entity) => [`${entity.type}:${entity.id}`, entity.data], + ), + ); + const endpointLabels = new Map< + string, + Array<{ body: GraphSONEdge["@value"]; field: "outVLabel" | "inVLabel" }> + >(); + for (const edge of source["@value"].edges) + for (const field of ["outV", "inV"] as const) { + const id = getIdentity(edge["@value"][field]), + entries = endpointLabels.get(id) ?? []; + entries.push({ + body: edge["@value"], + field: field === "outV" ? "outVLabel" : "inVLabel", + }); + endpointLabels.set(id, entries); + } + const nodes = new Map(context.graph.nodes.map((node) => [node.id, node])), + edges = new Map(context.graph.edges.map((edge) => [edge.id, edge])); + const nodeRecords = new Map(), + edgeRecords = new Map(); + const nodeIds = new Map( + state?.baseline.nodes.map((node, index) => [ + node.id, + state.original.nodes[index].id, + ]), + ); + const edgeIds = new Map( + state?.baseline.edges.map((edge, index) => [ + edge.id, + state.original.edges[index].id, + ]), + ); + for (const [id, record] of projection.vertices) + nodeRecords.set(nodeIds.get(id) ?? id, record); + for (const [id, record] of projection.edges) + edgeRecords.set(edgeIds.get(id) ?? id, record); + for (const change of context.changes) { + const entity = + change.entity === "node" ? nodes.get(change.id!) : edges.get(change.id!); + const record = + change.entity === "node" + ? nodeRecords.get(change.id!) + : edgeRecords.get(change.id!); + if (!entity || !record || change.entity === "graph") + return { + ok: false, + diagnostics: [ + { + code: "unsupported-native-edit", + severity: "error", + path: change.path, + message: + "GraphSON Graph-only/structural fields have no bounded native patch", + }, + ], + }; + const body = record["@value"]; + if (change.field === "label") { + if (!entity.label || typeof entity.label !== "string") + return { + ok: false, + diagnostics: [ + { + code: "unsupported-native-edit", + severity: "error", + message: "GraphSON label patches require a nonempty native label", + }, + ], + }; + if ( + change.entity === "node" && + (endpointLabels.get(getIdentity(body.id)) ?? []).some( + (entry) => + entry.body[entry.field] !== undefined && + entry.body[entry.field] !== getLabel(body.label, "vertex"), + ) + ) + return { + ok: false, + diagnostics: [ + { + code: "ambiguous-native-mirror", + severity: "error", + message: + "Native endpoint-label edits disagree with their vertex; label patch would overwrite an independent source field", + }, + ], + }; + body.label = entity.label; + if (change.entity === "node") + for (const entry of endpointLabels.get(getIdentity(body.id)) ?? []) + entry.body[entry.field] = entity.label; + } else if (change.field === "data") { + if (state) { + const property = + change.entity === "node" + ? (record as GraphSONVertex)["@value"].properties?.data?.[0] + : (record as GraphSONEdge)["@value"].properties?.data; + const compared = getDataComparison( + getBodyData( + body, + change.entity === "node" + ? ["id", "label"] + : ["id", "label", "outV", "inV", "outVLabel", "inVLabel"], + ), + nativeBaselines.get(`${change.entity}:${getIdentity(body.id)}`), + change.entity as "node" | "edge", + ); + if (!property || !isJSONEqual(compared.before, compared.current)) + return { + ok: false, + diagnostics: [ + { + code: "unsupported-native-edit", + severity: "error", + message: + "Native data extension/absence has no unambiguous user JSON property mapping", + }, + ], + }; + property["@value"].value = JSON.stringify(entity.data); + } else { + const before = getBodyData( + body, + change.entity === "node" + ? ["id", "label"] + : ["id", "label", "outV", "inV", "outVLabel", "inVLabel"], + ); + const patched = getPropertyPatch( + before, + entity.data, + change.entity as "node" | "edge", + ); + if (!patched) + return { + ok: false, + diagnostics: [ + { + code: "unsupported-native-edit", + severity: "error", + message: + "Only existing stable native property values can be changed; property IDs/order/meta-properties remain intact", + }, + ], + }; + Object.assign(body, patched); + } + } else + return { + ok: false, + diagnostics: [ + { + code: "unsupported-native-edit", + severity: "error", + path: change.path, + message: + "Only native labels and bounded existing property data edits are supported", + }, + ], + }; + } + return { ok: true, source }; +} +export const graphsonDocumentConverter = createGraphDocumentConverter({ + format: "graphson:3:tinker:graph", + codec: { from: fromGraphSON, to: (graph: Graph) => toRawGraphSON(graph) }, + project: (source: GraphSONGraph) => ({ + graph: getNativeDocumentGraph(fromGraphSON(source), "graphson", source), + }), + patch: getPatch, +}); +/** Native imports retain exact source and apply strict verified patches; nativeCarrier:false regenerates. */ +export function toGraphSON( + graph: Graph, + options: GraphSONExportOptions = {}, +): GraphSONGraph { + getProfile(options); + getValidatedValues(graph, false); + const carrier = + options.nativeCarrier === false + ? undefined + : getNativeCarrier(graph.data, "graphson"); + if (!carrier) return toRawGraphSON(graph, options); + const document = graphsonDocumentConverter.from(carrier.native); + document.graph = { + ...getClonedJSON(graph), + data: getCarrierData(graph.data, "graphson"), + }; + return graphsonDocumentConverter.to(document); +} +export const graphsonConverter = createFormatConverter( + toGraphSON, + fromGraphSON, +); diff --git a/src/formats/graphviz-json/README.md b/src/formats/graphviz-json/README.md index 62ebc19..92f5816 100644 --- a/src/formats/graphviz-json/README.md +++ b/src/formats/graphviz-json/README.md @@ -68,3 +68,15 @@ 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. + + + +Native `_gvid` values are required array ordinals, not durable edge identities +across source revisions. A strict edge patch after a changed native edge sequence +requires a unique verified private original edge ID in both source snapshots. +Generated identities otherwise reject with `ambiguous-native-identity`, even when +endpoints and labels coincide. Verified private IDs survive pure `_gvid` +renumbering with unchanged endpoints; cluster/node identity scope is unchanged. +Duplicate/header-only private metadata cannot authorize ambiguous patches. Active +layout/drawing restrictions still apply. Source-only updates remain authoritative, +and stable node-label edits do not require stable edge identities. diff --git a/src/formats/graphviz-json/index.ts b/src/formats/graphviz-json/index.ts index 1f1de94..88a11e4 100644 --- a/src/formats/graphviz-json/index.ts +++ b/src/formats/graphviz-json/index.ts @@ -1,7 +1,7 @@ import { createFormatData } from '../xml-preservation'; import type { Graph } from '../../types'; import { createFormatConverter } from '../converter'; -import { createGraphDocumentConverter } from '../document'; +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'; @@ -19,6 +19,12 @@ function getWireMetadata(record: Record, kind: string, native = fal } return undefined; } +function getPrivateEdgeId(record: GraphvizJSONEdge): string | undefined { + const protocols = Object.entries(record).flatMap(([key, value]) => { if (!/^_stately(?:_\d+)?$/.test(key) || typeof value !== 'string') return []; try { const metadata = getMetadata(JSON.parse(value), `${FORMAT}.edge`); return metadata ? [metadata] : []; } catch { return []; } }); + if (protocols.length !== 1) return undefined; + const original = protocols[0].original, mapped = protocols[0].mapped; + return original?.type === 'edge' && mapped?.type === 'edge' && typeof original.id === 'string' && /^graphviz:edge:(?:0|[1-9]\d*)$/.test(mapped.id) && ['sourceId', 'targetId'].every((key) => typeof original[key] === 'string' && typeof mapped[key] === 'string') ? original.id : undefined; +} function getAttributes(record: Record, known: string[], metadataKey?: string) { return Object.fromEntries(Object.entries(record).filter(([key]) => !known.includes(key) && key !== metadataKey)); } @@ -26,6 +32,8 @@ function getRecord(record: Record, mapped: any, kind: string, known const wire = getWireMetadata(record, kind, native); mapped.data = getAttributes(record, known, wire?.key); const restored = getRestoredRecord(mapped, wire?.metadata); + // _gvid is a file-local ordinal; a verified private edge ID survives pure renumbering. + if (!native && kind === 'edge' && getPrivateEdgeId(record as GraphvizJSONEdge) !== undefined && mapped.sourceId === wire!.metadata.original.sourceId && mapped.targetId === wire!.metadata.original.targetId) restored.id = wire!.metadata.original.id; if (wire && !isJSONEqual(mapped.data, wire.metadata.mapped.data)) restored.data = createFormatData(wire.metadata.original.data, FORMAT, { native: mapped.data }); return restored; } @@ -183,6 +191,18 @@ export function toGraphvizJSON(graph: Graph, options: GraphvizJSONExportOptions return source; } export const graphvizJSONConverter = createFormatConverter(toGraphvizJSON, fromGraphvizJSON); +function getGraphvizDocumentPatch(context: GraphDocumentPatchContext): GraphDocumentPatchResult { + const getIdentities = (source: GraphvizJSON, graph: Graph) => { + const ids = new Map(); + for (const [index, edge] of (source.edges ?? []).entries()) { const id = getPrivateEdgeId(edge); if (id === undefined || id !== graph.edges[index]?.id) continue; const entries = ids.get(id) ?? []; entries.push(edge); ids.set(id, entries); } + return ids; + }; + const baselineIds = getIdentities(context.baselineSource, context.baselineGraph), currentIds = getIdentities(context.source, context.sourceGraph); + const sameSequence = isJSONEqual(context.baselineSource.edges ?? [], context.source.edges ?? []); + for (const change of context.changes) if (change.entity === 'edge' && !sameSequence && (baselineIds.get(change.id!)?.length !== 1 || currentIds.get(change.id!)?.length !== 1)) return { ok: false, diagnostics: [{ code: 'ambiguous-native-identity', severity: 'error', path: change.path, message: 'Graphviz _gvid edge ordinals are ambiguous after the native edge record sequence changed; verified private identity must exist in both sources' }] }; + 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) }] }; } +} 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) }] }; } } }); + patch: getGraphvizDocumentPatch }); diff --git a/src/formats/index.ts b/src/formats/index.ts index d8bf717..03ec840 100644 --- a/src/formats/index.ts +++ b/src/formats/index.ts @@ -107,3 +107,63 @@ export type { IshikawaEdgeData, IshikawaGraphData, } from './mermaid'; + +export { + fromGraphSON, + toGraphSON, + graphsonConverter, + graphsonDocumentConverter, +} from "./graphson"; +export type { + GraphSONGraph, + GraphSONTypedValue, + GraphSONVertex, + GraphSONEdge, + GraphSONVertexProperty, + GraphSONImportOptions, + GraphSONExportOptions, +} from "./graphson"; +export { fromCX2, toCX2, cx2Converter, cx2DocumentConverter } from "./cx2"; +export type { + CX2JSON, + CX2Graph, + CX2EntityData, + CX2GraphData, + CX2Declarations, + CX2Node, + CX2Edge, + CX2ImportOptions, + CX2ExportOptions, +} from "./cx2"; +export { fromDGML, toDGML, dgmlConverter, dgmlDocumentConverter } from "./dgml"; +export type { + DGMLGraph, + DGMLNodeData, + DGMLEdgeData, + DGMLGraphData, + DGMLImportOptions, + DGMLExportOptions, +} from "./dgml"; +export { + fromRDFDataset, + toRDFDataset, + getRDFTerm, + getRDFTermId, + getRDFQuadId, + rdfDatasetConverter, + rdfDatasetDocumentConverter, +} from "./rdf"; +export type { + RDFNamedNode, + RDFBlankNode, + RDFLiteral, + RDFDefaultGraph, + RDFSubject, + RDFObject, + RDFGraphName, + RDFGraphTerm, + RDFTerm, + RDFQuad, + RDFDataset, + RDFExportOptions, +} from "./rdf"; diff --git a/src/formats/jgf/README.md b/src/formats/jgf/README.md index c03e88a..b58e448 100644 --- a/src/formats/jgf/README.md +++ b/src/formats/jgf/README.md @@ -138,3 +138,7 @@ 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. + + + +Pending Graph edge edits require a unique explicit native edge ID in both baseline and current source when the native edge sequence changes. Generated positional IDs cannot acquire identity from a later explicit ID. Without those IDs, insertion, removal, reordering or edge-record changes reject with `ambiguous-native-identity`. Unchanged sequences, source-only updates and stable node-ID edits remain supported. diff --git a/src/formats/networkx/README.md b/src/formats/networkx/README.md index 214b4cf..58ff7eb 100644 --- a/src/formats/networkx/README.md +++ b/src/formats/networkx/README.md @@ -27,3 +27,7 @@ Bare export preserves an imported simple/multigraph profile. Compatible node/edg 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). + + + +Pending Graph multigraph edge edits require an explicit per-endpoint-pair key in both baseline and current source when the native edge sequence changes. Generated keys cannot acquire identity from later explicit keys. Python-equivalent boolean/numeric keys retain their existing equivalence; simple graphs use intrinsic endpoint-pair identity. Ambiguous insertion, removal, reordering or edge-record changes reject with `ambiguous-native-identity`. Source-only updates and stable node-ID edits remain supported. diff --git a/src/formats/pajek/README.md b/src/formats/pajek/README.md index 86afe0f..cdc4865 100644 --- a/src/formats/pajek/README.md +++ b/src/formats/pajek/README.md @@ -50,7 +50,7 @@ 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. +insert missing labels locally; unedited source text survives within the identity limits below. 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 @@ -70,3 +70,7 @@ label and reads a fixed count of physical vertex rows. It cannot certify repeate 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. + + + +Pending edge-label edits require unchanged raw relationship sections and comment sequences, including private capsules. Endpoint/occurrence metadata cannot identify physical rows after insertion, removal, reordering or independent record/comment edits; these operations reject with `ambiguous-native-identity`. This conservative guard also rejects independent comment edits during an edge patch. Source-only updates and stable vertex-ID patches remain supported. diff --git a/src/formats/pajek/index.ts b/src/formats/pajek/index.ts index 6c74a21..0ef4166 100644 --- a/src/formats/pajek/index.ts +++ b/src/formats/pajek/index.ts @@ -170,8 +170,25 @@ function toRawPajek(graph: Graph): string { 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'; } +/** Include comments/capsules: occurrence metadata does not establish stable identity. */ +function getRelationshipSequence(source: string): string[] { + const tokens = source.split(/(\r\n|\n|\r)/), records: string[] = []; + let relationships = false; + for (let index = 0; index < tokens.length; index += 2) { + const line = tokens[index], trimmed = line.trimStart(); + if (trimmed.startsWith('*')) relationships = /^\*(?:arcs|edges)(?:\s|$)/i.test(trimmed); + if (relationships || trimmed.startsWith('%')) records.push(line + (tokens[index + 1] ?? '')); + } + return records; +} + function getPajekPatch(context: GraphDocumentPatchContext, options: PajekImportOptions): GraphDocumentPatchResult { const invalid = getLabelChangeError(context.changes); if (invalid) return invalid; + const edgeChange = context.changes.find((change) => change.entity === 'edge'); + if (edgeChange && !isJSONEqual(getRelationshipSequence(context.baselineSource), getRelationshipSequence(context.source))) { + return { ok: false, diagnostics: [{ code: 'ambiguous-native-identity', severity: 'error', path: edgeChange.path, + message: 'Pajek relationship identity is ambiguous after native relationship/comment sequence changes, including occurrence-indexed capsules' }] }; + } 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[] = []; @@ -183,11 +200,6 @@ function getPajekPatch(context: GraphDocumentPatchContext, options: Paje 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}` }); } diff --git a/src/formats/plantuml/README.md b/src/formats/plantuml/README.md index 389c13a..eecd884 100644 --- a/src/formats/plantuml/README.md +++ b/src/formats/plantuml/README.md @@ -24,3 +24,7 @@ Documents retain exact original source, line endings, comments, class bodies, gr 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). + + + +Pending relationship edits require unchanged raw relationship and comment sequences, including block comments and private sidecars. Ordinals and occurrence-indexed sidecars cannot identify relationships after insertion, removal, reordering or independent record/comment edits; these operations reject with `ambiguous-native-identity`. This conservative guard also rejects independent comment edits during a relationship patch. Source-only updates and stable node-alias patches remain supported. diff --git a/src/formats/plantuml/index.ts b/src/formats/plantuml/index.ts index 019b407..5c3a37b 100644 --- a/src/formats/plantuml/index.ts +++ b/src/formats/plantuml/index.ts @@ -178,8 +178,30 @@ function toRawPlantUML(graph: Graph, options: PlantUMLExportOptions): string { lines.splice(1, 0, `${SIDECAR}${JSON.stringify(sidecar)}`); return `${lines.join('\n')}\n`; } +/** Relationship ordinals and sidecar occurrences are not durable native IDs. */ +function getRelationshipSequence(parsed: ReturnType): string[] { + const relationshipLines = new Set(parsed.edges.map((edge) => edge.line)); + const records: string[] = []; + let blockComment = false; + for (let index = 0; index < parsed.lines.length; index++) { + const line = parsed.lines[index], trimmed = line.trimStart(); + const startsBlock = trimmed.startsWith("/'"); + if (relationshipLines.has(index) || blockComment || startsBlock || trimmed.startsWith("'")) { + records.push(line + parsed.endings[index]); + } + if (blockComment && trimmed.includes("'/")) blockComment = false; + else if (startsBlock && !trimmed.slice(2).includes("'/")) blockComment = true; + } + return records; +} + function getPlantUMLPatch(context: GraphDocumentPatchContext, dialect: PlantUMLDialect): GraphDocumentPatchResult { const parsed = getParsedPlantUML(context.source, { dialect }); + const edgeChange = context.changes.find((change) => change.entity === 'edge'); + if (edgeChange && !isJSONEqual(getRelationshipSequence(getParsedPlantUML(context.baselineSource, { dialect })), getRelationshipSequence(parsed))) { + return { ok: false, diagnostics: [{ code: 'ambiguous-native-identity', severity: 'error', path: edgeChange.path, + message: 'PlantUML relationship identity is ambiguous after native relationship/comment sequence changes, including occurrence-indexed sidecars' }] }; + } 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])); diff --git a/src/formats/preservation.ts b/src/formats/preservation.ts index 2ac86d8..b0a46a5 100644 --- a/src/formats/preservation.ts +++ b/src/formats/preservation.ts @@ -286,6 +286,9 @@ export function getStructuredDocumentPatch(context: any, format: string): any { if (!tokens[i].trim()) continue; if (edges) edgeLines.push(i); else nodeLines.set(tokens[i].trimStart().split(/\s/)[0], i); } + const getEdgeRecords = (text: string) => { let inEdges = false; const records: string[] = []; for (const line of text.split(/\r\n|\n|\r/)) { if (line.trim() === '#') { inEdges = true; continue; } if (inEdges && line.trim()) records.push(line); } return records; }; + const edgeChange = context.changes.find((change: any) => change.entity === 'edge'); + if (edgeChange && !isJSONEqual(getEdgeRecords(context.baselineSource), edgeLines.map((index) => tokens[index]))) return { ok: false, diagnostics: [{ code: 'ambiguous-native-identity', severity: 'error', path: edgeChange.path, message: 'TGF generated edge identity is ambiguous after the native edge record sequence changed' }] }; 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++) { @@ -303,6 +306,30 @@ export function getStructuredDocumentPatch(context: any, format: string): any { } return { ok: true, source: tokens.join('') }; } + if (format === 'd3' || format === 'cytoscape') { + const getEdgeRecords = (native: any): any[] => format === 'd3' ? native.links ?? native.edges ?? [] : (Array.isArray(native.elements) ? native.elements : [...native.elements?.nodes ?? [], ...native.elements?.edges ?? []]).filter((record: any) => record.data && 'source' in record.data && 'target' in record.data); + const getDeclaredIds = (records: any[]) => { const ids = new Map(); for (const record of records) { const id = format === 'd3' ? record.id : record.data.id; if (typeof id === 'string') ids.set(id, (ids.get(id) ?? 0) + 1); } return ids; }; + const baselineRecords = getEdgeRecords(context.baselineSource), currentRecords = getEdgeRecords(source); + const sameSequence = isJSONEqual(baselineRecords, currentRecords), baselineIds = getDeclaredIds(baselineRecords), currentIds = getDeclaredIds(currentRecords); + for (const change of context.changes) if (change.entity === 'edge' && !sameSequence && (baselineIds.get(change.id) !== 1 || currentIds.get(change.id) !== 1)) return { ok: false, diagnostics: [{ code: 'ambiguous-native-identity', severity: 'error', path: change.path, message: `${format} generated edge identity is ambiguous after the native edge record sequence changed; a unique explicit ID must exist in both sources` }] }; + } + if (format === 'jgf') { + const baselineRecords = context.baselineSource.graph.edges ?? []; + const currentRecords = source.graph.edges ?? []; + if (!isJSONEqual(baselineRecords, currentRecords)) { + const getIds = (records: any[]) => { + const ids = new Map(); + for (const record of records) if (typeof record.id === 'string') ids.set(record.id, (ids.get(record.id) ?? 0) + 1); + return ids; + }; + const baselineIds = getIds(baselineRecords), currentIds = getIds(currentRecords); + for (const change of context.changes) if (change.entity === 'edge' + && (baselineIds.get(change.id) !== 1 || currentIds.get(change.id) !== 1)) { + return { ok: false, diagnostics: [{ code: 'ambiguous-native-identity', severity: 'error', path: change.path, + message: 'JGF generated edge identity is ambiguous after the native edge sequence changed; a unique explicit ID must exist in both sources' }] }; + } + } + } const nodes = new Map(), edges = new Map(); const root = format === 'jgf' ? source.graph : source; if (format === 'elk') { diff --git a/src/formats/rdf/README.md b/src/formats/rdf/README.md new file mode 100644 index 0000000..321c527 --- /dev/null +++ b/src/formats/rdf/README.md @@ -0,0 +1,161 @@ +# RDF 1.1 datasets and syntax + + + +```ts +import { + fromRDFDataset, toRDFDataset, getRDFTermId, getRDFQuadId, +} from '@statelyai/graph/rdf'; + +const dataset = { + version: '1.1' as const, + quads: [{ + subject: { termType: 'NamedNode' as const, value: 'urn:alice' }, + predicate: { termType: 'NamedNode' as const, value: 'urn:name' }, + object: { + termType: 'Literal' as const, value: 'Alice', language: 'EN', + datatype: { + termType: 'NamedNode' as const, + value: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#langString', + }, + }, + graph: { termType: 'DefaultGraph' as const, value: '' as const }, + }], + namedGraphs: [{ termType: 'NamedNode' as const, value: 'urn:empty' }], +}; +const graph = fromRDFDataset(dataset); +const original = toRDFDataset(graph); +``` + +`rdfDatasetConverter` provides `.from`/`.to`; `rdfDatasetDocumentConverter` retains +original JSON values and reports set semantics. `getRDFTerm` validates/clones one +term. Public types: `RDFNamedNode`, `RDFBlankNode`, `RDFLiteral`, `RDFDefaultGraph`, +`RDFSubject`, `RDFObject`, `RDFGraphName`, `RDFGraphTerm`, `RDFTerm`, `RDFQuad`, +`RDFDataset`, `RDFExportOptions`. This DTO subpath has no runtime peer dependency. + + + +Subjects/objects become identity-keyed nodes. Directed statement edges carry exact +`data.rdf.predicate` and `data.rdf.graph` terms. Predicates and graph names do not +become separate nodes unless also used as subjects/objects. Named graphs are not +hierarchy; root `data.rdf.namedGraphs` preserves declared empty graphs too. + +Named IRIs, scoped blank nodes, literals and the default graph have disjoint +identities. Blank DTOs require both `value` and `scope`; matching labels in different +scopes remain distinct, while one scoped blank can be shared across named graphs. +Literal lexical strings, datatype IRIs and language tags are never converted to +JavaScript numbers or interpreted through entailment. Ill-typed literals such as +`"abc"^^xsd:integer` are valid terms and remain intact. **RDF 1.1** literal identity +compares language tags character by character: `EN` and `en` remain distinct here. +No RDF 1.2 casefold identity rule is silently substituted. Arbitrary datatype IRIs, +including `rdf:dirLangString` without direction/language fields, remain ordinary +RDF 1.1 unknown datatypes. + +RDF statements are a set. Duplicate term-identical quads project to one edge; +unchanged imported dataset JSON preserves original duplicate occurrences, order +and unknown root metadata through a versioned carrier. Explicit fresh regeneration +emits the set and its named-graph declarations. All DTO contents must be plain JSON; +cycles/classes/functions/BigInt/undefined/nonfinite values reject rather than normalize. + +Fresh Graph export requires explicit node `data.rdf.term`, edge +`data.rdf.predicate`/`data.rdf.graph`, and root `data.rdf.version`/`namedGraphs`. +Use public `getRDFTermId`/`getRDFQuadId` to construct the required derived IDs. +Node labels equal term values and edge labels equal predicate IRIs. Null/absent +hierarchy and inherited directed mode resolve equivalent factory defaults; custom +IDs, display-label changes, hierarchy, styles, arbitrary user data and other +Graph-only fields reject instead of disappearing. This is a scoped RDF term Graph, +not unrestricted lossless export of arbitrary application Graphs. + +Validation enforces RDF 1.1 term roles, scalar Unicode, absolute IRI schemes, +forbidden lexical characters/malformed percent escapes, authority-only IP brackets, +and language/datatype consistency with BCP47 syntax. It does not claim a complete +RFC3987 authority/IRI or registered-language validator. Datatype lexical validity +is deliberately not required. Unknown term/quad fields, variables, triple terms +and directional literals reject; unknown JSON dataset root metadata is retained. + + + +```ts +import { + fromRDF, toRDF, createRDFDocumentConverter, +} from '@statelyai/graph/rdf/syntax'; + +const graph = fromRDF(turtle, { + format: 'Turtle', version: '1.1', blankScope: 'source-one', + baseIRI: 'https://example.org/', +}); +const text = toRDF(graph, { format: 'Turtle' }); +const fresh = toRDF(graph, { format: 'Turtle', nativeCarrier: false }); +``` + +Install optional `n3` (tested with pinned **2.13.12**). `RDFSyntax` permits only +`N-Triples`, `N-Quads`, `Turtle`, `TriG`; `RDFSyntaxOptions` and +`RDFSyntaxExportOptions` declare the options. Parsing and emission are synchronous. +The maintained N3 parser selects each strict grammar; its permissive default/N3 +reasoning grammar is never used. No handwritten Turtle parser is included. +Public parser token/version observers and a local RDFJS factory preserve resolved +empty graph declarations, tag casing and stable anonymous blank IDs. Current +`@types/n3` lacks these observer overloads; the adapter declares the local narrow +signature. Parser/factory objects never escape into the JSON Graph. + +N3 2.13.12 itself rejects explicit `^^rdf:dirLangString` literals before invoking +the factory, although that IRI can be an ordinary unknown datatype in RDF 1.1. +The DTO/emitter preserve it; syntax import reports an unavailable projection and +retains exact source. This is a documented parser coverage gap. + +Explicit RDF version declarations outside `1.1`, quoted/nested triple terms and +directional literals reject. JSON-LD processing, remote contexts, RDF/XML and RDF +1.2 support remain separate follow-ups; no asynchronous processing is hidden inside +the synchronous document API. Source strings are bounded to 16 × 1024² UTF-16 units. + +Blank scope defaults to `document` for stable repeated parses. Supply different +`blankScope` values before combining unrelated sources. Fresh syntax assigns unique +blank labels across all original scopes, preserving dataset relationships up to +blank-node isomorphism. Unspecified export base/scope options inherit the matching captured syntax; +explicit changed options regenerate. Original blank spellings/scopes and derived Graph IDs can +change on regeneration. Turtle authoring emits its valid N-Triples subset; TriG +emits default triples and named graph blocks, including empty declarations. +N-Quads cannot encode empty named graphs and rejects them. N-Triples/Turtle reject +all named graphs rather than silently selecting only the default graph. + + + +```ts +const codec = createRDFDocumentConverter({ format: 'TriG' }); +const document = codec.from(trig); +const persisted = JSON.parse(JSON.stringify(document)); +const exact = codec.to(persisted); +const regenerated = codec.getUpdate(document, { mode: 'regenerate' }); +``` + +No-op output retains exact source **text**, including comments, prefix/base +spellings, order and duplicate statements. File byte encodings lie outside the +string API. Source-only edits are authoritative and reparsed; unsupported/version +unavailable sources retain `graph: null` plus diagnostics. Initially all Graph +patches require explicit regeneration. Strict `.to` rejects instead of returning +stale text; same-field source/Graph conflicts reject. Regeneration emits loss +diagnostics for concrete spelling/comments/IDs and still rejects incoherent or +Graph-only state. The generic document boundary reports partial native projection; +source retention does not establish complete RDF interpretation or entailment. + + + +Independent tests use pinned **RDFLib 7.6.0** actual readers plus +`rdflib.compare.isomorphic`. Dataset roles, graph existence and shared blank +identity are encoded into one RDF graph for independent bijection checking; +lexical/datatype/language descriptors retain exact fields. Native +`RDFSink.newGraph` and public `Graph`/`ConjunctiveGraph.add` calls are observed while +unmodified parser grammar and store equality execute. Raw store controls show +that RDFLib Memory storage drops empty graphs and folds tag case; those store +results cannot certify these distinctions. Parser events independently verify +resolved declarations and pre-store statement terms. No oracle grammar/equality +is patched. Literal normalization is disabled; emitted quoted typed literals keep +numeric lexical forms. Upstream RDFLib numeric shorthand normalization is outside +that author-profile proof. Native malformed-source and wrong-dataset controls +prevent treating adapter self-roundtrips as validation. + +Primary specifications: +[RDF 1.1 Concepts](https://www.w3.org/TR/rdf11-concepts/), +[N-Triples](https://www.w3.org/TR/n-triples/), +[N-Quads](https://www.w3.org/TR/n-quads/), +[Turtle](https://www.w3.org/TR/turtle/), [TriG](https://www.w3.org/TR/trig/). diff --git a/src/formats/rdf/index.ts b/src/formats/rdf/index.ts new file mode 100644 index 0000000..81206bf --- /dev/null +++ b/src/formats/rdf/index.ts @@ -0,0 +1,148 @@ +import type { Graph } from '../../types'; +import { createFormatConverter } from '../converter'; +import { createGraphDocumentConverter } from '../document'; +import { createNativeCarrier, getNativeCarrier, getCarrierData, getClonedJSON, getNativeDocumentGraph, isJSONEqual, type FormatExportOptions } from '../preservation'; +import { getValidatedGraph } from '../structured-json'; + +export interface RDFNamedNode { termType: 'NamedNode'; value: string } +/** Scope separates identically labelled blank nodes from independent source datasets. */ +export interface RDFBlankNode { termType: 'BlankNode'; value: string; scope: string } +export interface RDFLiteral { termType: 'Literal'; value: string; datatype: RDFNamedNode; language: string } +export interface RDFDefaultGraph { termType: 'DefaultGraph'; value: '' } +export type RDFSubject = RDFNamedNode | RDFBlankNode; +export type RDFObject = RDFSubject | RDFLiteral; +export type RDFGraphName = RDFSubject; +export type RDFGraphTerm = RDFGraphName | RDFDefaultGraph; +export type RDFTerm = RDFObject | RDFDefaultGraph; +export interface RDFQuad { subject: RDFSubject; predicate: RDFNamedNode; object: RDFObject; graph: RDFGraphTerm } +/** RDF 1.1 set semantics, with explicit declarations for empty named graphs. */ +export interface RDFDataset { version: '1.1'; quads: RDFQuad[]; namedGraphs?: RDFGraphName[]; [metadata: string]: unknown } +export interface RDFExportOptions extends FormatExportOptions {} +const FORMAT = 'rdf-dataset'; +const LANG_STRING = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#langString'; + +function getScalarString(value: unknown, field: string): string { + if (typeof value !== 'string') throw new Error(`RDF: ${field} must be a string`); + for (const character of value) { const code = character.codePointAt(0)!; if (code >= 0xd800 && code <= 0xdfff) throw new Error(`RDF: ${field} requires Unicode scalar values`); } + return value; +} +function getIRI(value: unknown): string { + const iri = getScalarString(value, 'IRI'); + if (!/^[A-Za-z][A-Za-z0-9+.-]*:/.test(iri) || /[\u0000-\u0020\u007f-\u009f<>"{}|^`\\]/.test(iri) || /%(?![\da-fA-F]{2})/.test(iri)) throw new Error('RDF: absolute IRI required; invalid lexical IRI characters/percent escape'); + if (/[\[\]]/.test(iri)) { + const remainder = iri.slice(iri.indexOf(':') + 1), authority = remainder.startsWith('//') ? remainder.slice(2).split(/[/?#]/)[0] : ''; + const outside = remainder.startsWith('//') ? remainder.slice(2 + authority.length) : remainder; + const host = authority.match(/^(?:[^@\[\]]*@)?\[([^\[\]]+)\](?::\d*)?$/); + if (/[\[\]]/.test(outside) || !host) throw new Error('RDF: brackets are permitted only around an authority IP literal'); + if (!/^v[\da-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]+$/i.test(host[1])) { try { new URL(`http://[${host[1]}]`); } catch { throw new Error('RDF: invalid authority IP literal'); } } + } + return iri; +} +function isLanguageTag(value: string): boolean { + if (/^(?:en-GB-oed|i-(?:ami|bnn|default|enochian|hak|klingon|lux|mingo|navajo|pwn|tao|tay|tsu)|sgn-(?:BE-FR|BE-NL|CH-DE)|art-lojban|cel-gaulish|no-bok|no-nyn|zh-(?:guoyu|hakka|min|min-nan|xiang))$/i.test(value)) return true; + const pattern = /^(?:(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4}|[A-Za-z]{5,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|\d{3}))?(?:-(?:[A-Za-z0-9]{5,8}|\d[A-Za-z0-9]{3}))*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-x(?:-[A-Za-z0-9]{1,8})+)?|x(?:-[A-Za-z0-9]{1,8})+)$/; + if (!pattern.test(value)) return false; + const subtags = value.toLowerCase().split('-'), extensions = new Set(), variants = new Set(); + for (let index = 1; index < subtags.length; index++) { + const tag = subtags[index]; + if (tag === 'x') break; + if (tag.length === 1) { if (extensions.has(tag)) return false; extensions.add(tag); while (subtags[index + 1]?.length > 1) index++; } + else if (tag.length >= 5 || /^\d.{3}$/.test(tag)) { if (variants.has(tag)) return false; variants.add(tag); } + } + return true; +} +function getKeys(value: any, keys: string[]): void { + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((key) => !keys.includes(key))) throw new Error('RDF: unsupported term/quad fields or RDF 1.2 profile'); +} +/** Validate a plain RDF 1.1 term without datatype value coercion or reasoning. */ +export function getRDFTerm(term: RDFTerm): RDFTerm { + if (!term || typeof term !== 'object') throw new Error('RDF: term object required'); + switch (term.termType) { + case 'NamedNode': getKeys(term, ['termType', 'value']); return { termType: 'NamedNode', value: getIRI(term.value) }; + case 'BlankNode': { + getKeys(term, ['termType', 'value', 'scope']); + const value = getScalarString(term.value, 'blank label'), scope = getScalarString(term.scope, 'blank scope'); + if (!value || !scope) throw new Error('RDF: nonempty blank label and scope required'); + return { termType: 'BlankNode', value, scope }; + } + case 'Literal': { + getKeys(term, ['termType', 'value', 'datatype', 'language']); + const value = getScalarString(term.value, 'literal lexical form'), language = getScalarString(term.language, 'literal language'); + if (!term.datatype || term.datatype.termType !== 'NamedNode') throw new Error('RDF: literal datatype must be a NamedNode'); + const datatype = getRDFTerm(term.datatype) as RDFNamedNode; + if (datatype.termType !== 'NamedNode' || (language ? datatype.value !== LANG_STRING || !isLanguageTag(language) : datatype.value === LANG_STRING)) throw new Error('RDF: invalid RDF 1.1 literal datatype/language or unsupported directional literal'); + return { termType: 'Literal', value, datatype, language }; + } + case 'DefaultGraph': getKeys(term, ['termType', 'value']); if (term.value !== '') throw new Error('RDF: default graph value must be empty'); return { termType: 'DefaultGraph', value: '' }; + default: throw new Error('RDF: unsupported term type; RDF 1.2 triple terms and variables are outside the RDF 1.1 profile'); + } +} +/** Stable DTO term identity; RDF 1.1 language tags compare character by character. */ +export function getRDFTermId(term: RDFTerm): string { + const valid = getRDFTerm(term); + return `rdf:${JSON.stringify(valid.termType === 'Literal' ? [valid.termType, valid.value, valid.datatype.value, valid.language] : valid.termType === 'BlankNode' ? [valid.termType, valid.scope, valid.value] : [valid.termType, valid.value])}`; +} +function getQuad(quad: RDFQuad): RDFQuad { + getKeys(quad, ['subject', 'predicate', 'object', 'graph']); + const subject = getRDFTerm(quad.subject), predicate = getRDFTerm(quad.predicate), object = getRDFTerm(quad.object), graph = getRDFTerm(quad.graph); + if (!['NamedNode', 'BlankNode'].includes(subject.termType) || predicate.termType !== 'NamedNode' || object.termType === 'DefaultGraph' || graph.termType === 'Literal') throw new Error('RDF: invalid subject/predicate/object/graph role'); + return { subject: subject as RDFSubject, predicate, object, graph }; +} +export function getRDFQuadId(quad: RDFQuad): string { quad = getQuad(quad); return `rdf:quad:${JSON.stringify([quad.subject, quad.predicate, quad.object, quad.graph].map(getRDFTermId))}`; } +function getJSONSource(value: unknown): void { + const active = new Set(), complete = new Set(); + const pending: Array<{ value: unknown; exit?: boolean }> = [{ value }]; + while (pending.length) { + const entry = pending.pop()!, current = entry.value; + if (entry.exit) { active.delete(current as object); complete.add(current as object); continue; } + if (current === null || typeof current === 'string' || typeof current === 'boolean' || typeof current === 'number' && Number.isFinite(current)) continue; + if (!current || typeof current !== 'object') throw new Error('RDF: dataset metadata must be plain JSON without undefined/functions/BigInt/nonfinite values'); + if (active.has(current)) throw new Error('RDF: dataset JSON cannot contain cycles'); + if (complete.has(current)) continue; + const prototype = Object.getPrototypeOf(current); + if (prototype !== Object.prototype && prototype !== null && !(Array.isArray(current) && prototype === Array.prototype) || Object.getOwnPropertySymbols(current).length) throw new Error('RDF: dataset requires plain JSON objects/arrays'); + if (Array.isArray(current) && Object.keys(current).length !== current.length) throw new Error('RDF: sparse/extended arrays are not plain dataset JSON'); + active.add(current); pending.push({ value: current, exit: true }); + for (const property of Object.values(Object.getOwnPropertyDescriptors(current))) { + if (property.get || property.set) throw new Error('RDF: dataset JSON cannot contain accessors'); + if (property.enumerable) pending.push({ value: property.value }); + } + } +} +function getDataset(source: RDFDataset): RDFDataset { + if (!source || source.version !== '1.1' || !Array.isArray(source.quads) || source.namedGraphs !== undefined && !Array.isArray(source.namedGraphs)) throw new Error('RDF: expected explicit RDF 1.1 dataset and quads'); + getJSONSource(source); + const named = new Map(); + for (const term of source.namedGraphs ?? []) { const valid = getRDFTerm(term); if (!['NamedNode', 'BlankNode'].includes(valid.termType)) throw new Error('RDF: named graph requires IRI or scoped blank node'); named.set(getRDFTermId(valid), valid as RDFGraphName); } + const quads = new Map(); + for (const entry of source.quads) { const quad = getQuad(entry); quads.set(getRDFQuadId(quad), quad); if (quad.graph.termType !== 'DefaultGraph') named.set(getRDFTermId(quad.graph), quad.graph); } + return { version: '1.1', quads: [...quads.values()], namedGraphs: [...named.values()] }; +} +/** Subject/object terms become nodes; predicates and named graph terms remain exact edge data. */ +export function fromRDFDataset(source: RDFDataset): Graph { + const dataset = getDataset(source), nodes = new Map(); + const edges = dataset.quads.map((quad) => { + for (const term of [quad.subject, quad.object]) { const id = getRDFTermId(term); if (!nodes.has(id)) nodes.set(id, { type: 'node', id, parentId: null, initialNodeId: null, label: term.value, data: { rdf: { term } } }); } + return { type: 'edge' as const, id: getRDFQuadId(quad), sourceId: getRDFTermId(quad.subject), targetId: getRDFTermId(quad.object), mode: 'directed' as const, label: quad.predicate.value, data: { rdf: { predicate: quad.predicate, graph: quad.graph } } }; + }); + const graph: Graph = { id: '', mode: 'directed', initialNodeId: null, nodes: [...nodes.values()], edges, data: { rdf: { version: '1.1', namedGraphs: dataset.namedGraphs } } }; + graph.data = createNativeCarrier(graph.data, FORMAT, source, graph); return graph; +} +/** Strict export: no invented IRIs, label-to-property mapping or discarded Graph-only fields. */ +export function toRDFDataset(graph: Graph, options: RDFExportOptions = {}): RDFDataset { + getValidatedGraph(graph, FORMAT); + const current = { ...graph, data: getCarrierData(getCarrierData(graph.data, 'rdf-syntax'), FORMAT) }, carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, FORMAT); + const terms = new Map(current.nodes.map((node) => [node.id, getRDFTerm(node.data?.rdf?.term)])); + const quads = current.edges.map((edge) => getQuad({ subject: terms.get(edge.sourceId) as RDFSubject, predicate: edge.data?.rdf?.predicate, object: terms.get(edge.targetId) as RDFObject, graph: edge.data?.rdf?.graph })); + const dataset = getDataset({ version: current.data?.rdf?.version, quads, namedGraphs: current.data?.rdf?.namedGraphs }); + const projected = fromRDFDataset(dataset), comparison = { ...projected, data: getCarrierData(projected.data, FORMAT) }; + const resolved = { ...current, nodes: current.nodes.map((node) => ({ ...node, parentId: node.parentId ?? null, initialNodeId: node.initialNodeId ?? null })), edges: current.edges.map((edge) => ({ ...edge, mode: edge.mode ?? current.mode })) }; + if (!isJSONEqual(resolved, comparison)) throw new Error('RDF: Graph fields/IDs/labels must match the explicit RDF term projection; unsupported Graph-only state cannot be discarded'); + if (carrier) getDataset(carrier.native as RDFDataset); + if (carrier && isJSONEqual(current, carrier.baseline)) return getClonedJSON(carrier.native) as RDFDataset; + return carrier ? { ...getClonedJSON(carrier.native) as RDFDataset, quads: dataset.quads, namedGraphs: dataset.namedGraphs } : dataset; +} +export const rdfDatasetConverter = createFormatConverter(toRDFDataset, fromRDFDataset); +export const rdfDatasetDocumentConverter = createGraphDocumentConverter({ format: FORMAT, + codec: { from: fromRDFDataset, to: (graph) => toRDFDataset(graph, { nativeCarrier: false }) }, + project: (source: RDFDataset) => ({ graph: getNativeDocumentGraph(fromRDFDataset(source), FORMAT, source), diagnostics: [{ code: 'rdf-set-semantics', severity: 'info', message: 'RDF 1.1 quad set projection; duplicate occurrences and source metadata remain original dataset state' }] }) }); diff --git a/src/formats/rdf/syntax.ts b/src/formats/rdf/syntax.ts new file mode 100644 index 0000000..3384755 --- /dev/null +++ b/src/formats/rdf/syntax.ts @@ -0,0 +1,128 @@ +import { Parser, Writer, Lexer, DataFactory } from 'n3'; +import type { Graph } from '../../types'; +import { createGraphDocumentConverter } from '../document'; +import { createNativeCarrier, getNativeCarrier, getCarrierData, getNativeDocumentGraph, isJSONEqual } from '../preservation'; +import { fromRDFDataset, toRDFDataset, getRDFTerm, getRDFTermId, type RDFDataset, type RDFTerm, type RDFNamedNode, type RDFGraphName, type RDFQuad } from './index'; + +export type RDFSyntax = 'N-Triples' | 'N-Quads' | 'Turtle' | 'TriG'; +export interface RDFSyntaxOptions { format: RDFSyntax; version?: '1.1'; baseIRI?: string; blankScope?: string } +export interface RDFSyntaxExportOptions extends RDFSyntaxOptions { nativeCarrier?: boolean } +const FORMAT = 'rdf-syntax'; +interface NativeTerm { termType: string; value: string; language?: string; direction?: string; datatype?: NativeTerm } +interface NativeToken { type: string; value: string } +interface RDFObservers { onToken(token: NativeToken): void; onVersion(version: string): void } +function getOptions(options: RDFSyntaxOptions): RDFSyntaxOptions { + if (!options || !['N-Triples', 'N-Quads', 'Turtle', 'TriG'].includes(options.format) || options.version !== undefined && options.version !== '1.1') throw new Error('RDF: select a concrete RDF 1.1 syntax; JSON-LD/N3/RDF 1.2 are unsupported'); + if (options.baseIRI !== undefined) getRDFTerm({ termType: 'NamedNode', value: options.baseIRI }); + if (options.blankScope !== undefined && (typeof options.blankScope !== 'string' || !options.blankScope)) throw new Error('RDF: nonempty blankScope required'); + return { format: options.format, version: '1.1', blankScope: options.blankScope ?? 'document', ...(options.baseIRI !== undefined && { baseIRI: options.baseIRI }) }; +} +function getTerm(term: NativeTerm, scope: string): RDFTerm { + if (term.direction) throw new Error('RDF: directional literals require RDF 1.2'); + switch (term.termType) { + case 'NamedNode': return getRDFTerm({ termType: 'NamedNode', value: term.value }); + case 'BlankNode': return getRDFTerm({ termType: 'BlankNode', value: term.value, scope }); + case 'DefaultGraph': return getRDFTerm({ termType: 'DefaultGraph', value: '' }); + case 'Literal': return getRDFTerm({ termType: 'Literal', value: term.value, language: term.language ?? '', datatype: getTerm(term.datatype!, scope) as RDFNamedNode }); + default: throw new Error('RDF: RDF 1.2 triple terms/variables have no RDF 1.1 projection'); + } +} +function getParsed(source: string, options: RDFSyntaxOptions): RDFDataset { + if (typeof source !== 'string' || source.length > 16 * 1024 * 1024) throw new Error('RDF: source must be a string within 16 × 1024² UTF-16 units'); + const selected = getOptions(options), tokens = new Lexer({ lineMode: selected.format === 'N-Triples' || selected.format === 'N-Quads' }).tokenize(source); + // Reserve explicit labels across the entire source before assigning anonymous ones. + const reserved = new Set(tokens.filter((token) => token.type === 'blank').map((token) => token.value)); + let generated = 0, lastNode: NativeTerm | undefined, previous: NativeToken | undefined; + const names = new Map(); + const factory = { ...DataFactory, + namedNode(value: Iri) { const term = DataFactory.namedNode(value); lastNode = term; return term; }, + blankNode(value?: string) { + if (value === undefined) { do { value = `rdf_generated_${generated++}`; } while (reserved.has(value)); reserved.add(value); } + const term = DataFactory.blankNode(value); lastNode = term; return term; + }, + literal(value: string, languageOrDatatype?: any): any { + if (languageOrDatatype && typeof languageOrDatatype === 'object' && 'direction' in languageOrDatatype) throw new Error('RDF: directional literals require RDF 1.2'); + const term = DataFactory.literal(value, languageOrDatatype); + if (typeof languageOrDatatype !== 'string') return term; + // RDF 1.1 identity retains tag case; the stock N3 factory lowercases it. + return { termType: 'Literal', value, language: languageOrDatatype, datatype: term.datatype, + equals: (other: any) => other?.termType === 'Literal' && other.value === value && other.language === languageOrDatatype && other.datatype?.value === term.datatype.value }; + }, + }; + const parser = new Parser({ format: selected.format, baseIRI: selected.baseIRI, blankNodePrefix: '', factory }); + // N3 2.13.12 public observers are ahead of @types/n3's named-callback overload. + const observer = { onToken(token: NativeToken) { + if (['<<', '<<(', 'dircode'].includes(token.type)) throw new Error('RDF: RDF 1.2 triple terms/directional literals are unsupported'); + if (selected.format === 'TriG' && token.type === '{' && previous && ['IRI', 'prefixed', 'blank', ']'].includes(previous.type)) { + if (!lastNode) throw new Error('RDF: named graph identity unavailable'); + const name = getTerm(lastNode, selected.blankScope!); + if (name.termType !== 'NamedNode' && name.termType !== 'BlankNode') throw new Error('RDF: invalid named graph role'); + names.set(getRDFTermId(name), name); + } + previous = token; + }, onVersion(version: string) { if (version !== '1.1') throw new Error('RDF: explicit RDF version outside 1.1'); } }; + const quads = (parser.parse as unknown as (source: string, observers: RDFObservers) => Array<{ subject: NativeTerm; predicate: NativeTerm; object: NativeTerm; graph: NativeTerm }>)(source, observer); + const dataset: RDFDataset = { version: '1.1', quads: quads.map((quad) => ({ subject: getTerm(quad.subject, selected.blankScope!), predicate: getTerm(quad.predicate, selected.blankScope!), object: getTerm(quad.object, selected.blankScope!), graph: getTerm(quad.graph, selected.blankScope!) } as RDFQuad)), namedGraphs: [...names.values()] }; + // Core role/term validation remains authoritative after native parsing. + return toRDFDataset(fromRDFDataset(dataset), { nativeCarrier: false }); +} +function getInput(source: string, options: RDFSyntaxOptions) { return { source, options: getOptions(options) }; } +/** Strict maintained-parser grammar, returned exclusively as plain JSON Graph terms. */ +export function fromRDF(source: string, options: RDFSyntaxOptions): Graph { + const dataset = getParsed(source, options), graph = fromRDFDataset(dataset); + graph.data = getCarrierData(graph.data, 'rdf-dataset'); + graph.data = createNativeCarrier(graph.data, FORMAT, getInput(source, options), graph); return graph; +} +function getNativeTerm(term: RDFTerm, blanks: Map): any { + switch (term.termType) { + case 'NamedNode': return DataFactory.namedNode(term.value); + case 'BlankNode': { const id = getRDFTermId(term); if (!blanks.has(id)) blanks.set(id, `b${blanks.size}`); return DataFactory.blankNode(blanks.get(id)!); } + case 'DefaultGraph': return DataFactory.defaultGraph(); + case 'Literal': return { termType: 'Literal', value: term.value, language: term.language, datatype: DataFactory.namedNode(term.datatype.value) }; + } +} +function getSerialized(graph: Graph, options: RDFSyntaxOptions): string { + const selected = getOptions(options), dataset = toRDFDataset(graph, { nativeCarrier: false }), blanks = new Map(); + const writer = new Writer({ format: 'N-Quads' }), named = new Map((dataset.namedGraphs ?? []).map((name) => [getRDFTermId(name), { name, lines: [] as string[] }])); + const defaults: string[] = [], lines: string[] = []; + for (const quad of dataset.quads) { + const subject = getNativeTerm(quad.subject, blanks), predicate = getNativeTerm(quad.predicate, blanks), object = getNativeTerm(quad.object, blanks), nativeGraph = getNativeTerm(quad.graph, blanks); + lines.push(writer.quadToString(subject, predicate, object, nativeGraph)); + const triple = writer.quadToString(subject, predicate, object, DataFactory.defaultGraph()); + if (quad.graph.termType === 'DefaultGraph') defaults.push(triple); else named.get(getRDFTermId(quad.graph))!.lines.push(triple); + } + if (selected.format === 'N-Triples' || selected.format === 'Turtle') { + if (named.size) throw new Error('RDF: triple-only syntax cannot represent named graphs, including empty graphs'); + return defaults.join(''); // N-Triples is a valid Turtle authoring subset. + } + if (selected.format === 'N-Quads') { + if ([...named.values()].some((group) => group.lines.length === 0)) throw new Error('RDF: N-Quads cannot represent empty named graphs; choose TriG'); + return lines.join(''); + } + const groups = [...named.values()].map((group) => { + const term = getNativeTerm(group.name, blanks); + const encoded = writer.quadToString(term, DataFactory.namedNode('urn:stately:rdf:label'), DataFactory.literal(''), DataFactory.defaultGraph()); + // Validated IRI/scoped relabelled blank terms contain no literal spaces. + const label = encoded.slice(0, encoded.indexOf(' ')); + return `${label} {\n${group.lines.join('')}}\n`; + }); + return defaults.join('') + groups.join(''); +} +/** Fresh syntax requires explicit RDF terms; imported source is exact until regeneration. */ +export function toRDF(graph: Graph, options: RDFSyntaxExportOptions): string { + const carrier = options.nativeCarrier === false ? undefined : getNativeCarrier(graph.data, FORMAT); + const input = carrier?.native as ReturnType | undefined; + const selected = getOptions(input?.options.format === options.format ? { ...input.options, ...options } : options); + const current = { ...graph, data: getCarrierData(graph.data, FORMAT) }; + if (carrier) { + if (isJSONEqual(input!.options, selected) && isJSONEqual(current, carrier.baseline)) { getParsed(input!.source, selected); return input!.source; } + if (isJSONEqual(input!.options, selected)) throw new Error('RDF: Graph edits require explicit syntax regeneration'); + } + return getSerialized(current, selected); +} +/** Exact source-only updates; Graph patches intentionally require explicit regeneration. */ +export function createRDFDocumentConverter(options: RDFSyntaxOptions) { + const selected = getOptions(options); + return createGraphDocumentConverter({ format: `rdf:${selected.format}`, codec: { from: (source: string) => fromRDF(source, selected), to: (graph: Graph) => toRDF(graph, { ...selected, nativeCarrier: false }) }, + project: (source: string) => ({ graph: getNativeDocumentGraph(fromRDF(source, selected), FORMAT, getInput(source, selected)), diagnostics: [{ code: 'rdf-syntax-profile', severity: 'info', message: 'RDF 1.1 dataset terms; prefixes/comments/base and concrete spellings remain exact source. Graph patches require explicit regeneration.' }] }) }); +} diff --git a/src/formats/structured-json.ts b/src/formats/structured-json.ts index 6e3432b..c58584a 100644 --- a/src/formats/structured-json.ts +++ b/src/formats/structured-json.ts @@ -161,8 +161,53 @@ export function getValidatedGraph(graph: Graph, format: string): void { } } +/** A generated key must never gain identity from a later explicit key. */ +function getDocumentEdgeIdentityError(context: any, format: string): any { + if (format !== 'graphology' && format !== 'networkx') return undefined; + const before = context.baselineSource, current = context.source; + const baselineRecords = before.edges ?? before.links ?? []; + const currentRecords = current.edges ?? current.links ?? []; + if (isJSONEqual(baselineRecords, currentRecords)) return undefined; + if (format === 'networkx' && !before.multigraph && !current.multigraph) return undefined; + const getValueKey = (value: any): string => JSON.stringify([ + typeof value === 'boolean' ? 'number' : typeof value, + typeof value === 'boolean' ? Number(value) : value, + ]); + const getIdentity = (record: any, source: any): string | undefined => { + if (format === 'graphology') return typeof record.key === 'string' ? record.key : undefined; + if (!source.multigraph || !Object.hasOwn(record, 'key') || record.key === undefined) return undefined; + const pair = [getValueKey(record.source), getValueKey(record.target)]; + if (!source.directed) pair.sort(); + return JSON.stringify([pair, getValueKey(record.key)]); + }; + const getIndex = (records: any[], source: any, graph: Graph) => { + const identities = new Map(); + const counts = new Map(); + records.forEach((record, index) => { + const identity = getIdentity(record, source); + if (graph.edges[index]) identities.set(graph.edges[index].id, identity); + if (identity !== undefined) counts.set(identity, (counts.get(identity) ?? 0) + 1); + }); + return { identities, counts }; + }; + const baseline = getIndex(baselineRecords, before, context.baselineGraph); + const native = getIndex(currentRecords, current, context.sourceGraph); + for (const change of context.changes) { + if (change.entity !== 'edge') continue; + const identity = baseline.identities.get(change.id); + if (identity === undefined || identity !== native.identities.get(change.id) + || baseline.counts.get(identity) !== 1 || native.counts.get(identity) !== 1) { + return { ok: false, diagnostics: [{ code: 'ambiguous-native-identity', severity: 'error', path: change.path, + message: `${format}: generated edge identity is ambiguous after the native edge sequence changed; a unique explicit identity must exist in both sources` }] }; + } + } + return undefined; +} + /** Native document edits use only demonstrated attributes, never private snapshots alone. */ export function getAttributeDocumentPatch(context: any, format: string, toNative: (graph: Graph) => any): any { + const identityError = getDocumentEdgeIdentityError(context, format); + if (identityError) return identityError; 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` }] }; diff --git a/src/formats/support.ts b/src/formats/support.ts index 4e8165f..29116c9 100644 --- a/src/formats/support.ts +++ b/src/formats/support.ts @@ -45,6 +45,78 @@ export interface FormatSupportEntry { */ export const FORMAT_SUPPORT_MATRIX: FormatSupportEntry[] = [ + { + id: "graphson", + importPath: "@statelyai/graph/graphson", + preservation: { graph: "partial", native: "partial", document: "partial" }, + features: { + directed: "full", + undirected: "none", + hierarchy: "none", + ports: "none", + visual: "none", + style: "none", + weight: "partial", + roundTrip: "partial", + }, + notes: [ + "Explicit GraphSON3 typed tinker:graph object profile, backed by TinkerIoRegistryV3. Directed identities and raw typed properties retain Int64/BigInteger/BigDecimal wrappers, multi/meta-properties and typed maps/sets. Fresh Graph data uses documented JSON-string properties and a Graph-only string sidecar; unsupported modes/IDs/labels reject. Source-exact documents and bounded label/property patches are distinct from complete TinkerPop type-system coverage.", + ], + }, + { + id: "dgml", + importPath: "@statelyai/graph/dgml", + preservation: { graph: "partial", native: "partial", document: "partial" }, + features: { + directed: "full", + undirected: "none", + hierarchy: "partial", + ports: "none", + visual: "none", + style: "partial", + weight: "none", + roundTrip: "partial", + }, + notes: [ + "DGML groups/Contains hierarchy and directed links use positional native IDs dgml_link_N; fresh export is strict unless edgeIds:positional explicitly accepts identity loss. Implicit endpoint provenance and signed64 lexical magnitudes are retained. The published Microsoft XSD verifies selected profiles but rejects dynamic custom property attributes; no Windows renderer conformance claim. Documents retain exact source with bounded native edits.", + ], + }, + { + id: "cx2", + importPath: "@statelyai/graph/cx2", + preservation: { graph: "partial", native: "partial", document: "partial" }, + features: { + directed: "full", + undirected: "none", + hierarchy: "none", + ports: "none", + visual: "partial", + style: "none", + weight: "none", + roundTrip: "partial", + }, + notes: [ + "Scoped NDEx CX2 uses safe canonical numeric identities, typed declarations/defaults/aliases, directed multigraphs and complete x/y coordinates. Visual/custom aspects remain opaque. Null typed attributes and late declarations reject projection; actual NDEx3.12 defaults/null omission are characterized. Documents retain exact source and bounded native labels/properties.", + ], + }, + { + id: "rdf", + importPath: "@statelyai/graph/rdf", + preservation: { graph: "partial", native: "partial", document: "partial" }, + features: { + directed: "full", + undirected: "none", + hierarchy: "none", + ports: "none", + visual: "none", + style: "none", + weight: "none", + roundTrip: "partial", + }, + notes: [ + "Explicit RDF1.1 dataset maps subject/object terms to nodes, predicates to directed edges and graph names to edge data; lexical forms, datatypes, language case and scoped blank identity remain intact without coercion/reasoning. DTO core is dependency-free; optional n3 syntax supports N-Triples/N-Quads/Turtle/TriG. JSON-LD/RDF1.2/executable reasoning are deferred. Exact-source documents and strict supported patches do not imply arbitrary Graph encoding.", + ], + }, { id: 'adjacency-matrix', importPath: '@statelyai/graph/adjacency-matrix', preservation: { graph: 'partial', native: 'partial', document: 'none' }, diff --git a/src/formats/tgf/README.md b/src/formats/tgf/README.md index 345a517..c2d50fb 100644 --- a/src/formats/tgf/README.md +++ b/src/formats/tgf/README.md @@ -97,3 +97,12 @@ verified native fields; unsupported edits throw with diagnostics. `.getUpdate(do 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. + + + +TGF edge IDs are ordinal, because edge lines have no explicit identity field. +Strict Graph edge patches require the native edge record sequence to remain +unchanged between baseline and current source, including distinguishing whitespace. +Insertion, removal, reordering or record changes reject with +`ambiguous-native-identity`. Changes outside edge records and stable node-label +patches remain supported; source-only updates retain their exact text. diff --git a/src/formats/xml-document.ts b/src/formats/xml-document.ts index 3958858..46ecbc9 100644 --- a/src/formats/xml-document.ts +++ b/src/formats/xml-document.ts @@ -156,15 +156,11 @@ export function getXMLDocumentProjection(source: string, format: 'graphml' | 'ge 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); +function getXMLEntityScope(elements: XMLElement[], format: 'graphml' | 'gexf') { 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'); + if (rootIndex < 0) return undefined; 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'); + if (graphIndex === undefined) return undefined; const selected = new Set(); if (format === 'graphml') { const pending = [graphIndex]; @@ -192,11 +188,24 @@ export function getXMLDocumentPatch(context: GraphDocumentPatchContext, 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 identities = new Map(); + for (const index of selected) { const entity = elements[index], id = getXMLAttribute(entity, 'id')?.value; if (id === undefined) continue; const key = JSON.stringify([entity.local, id]), entries = identities.get(key) ?? []; entries.push(entity); identities.set(key, entries); } + return { rootIndex, identities }; +} + +/** 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 scope = getXMLEntityScope(elements, format), baseline = getXMLEntityScope(getXMLElements(context.baselineSource), format); + if (!scope) return getUnsupported(context.changes[0], 'Cannot locate the projected native graph'); + const rootIndex = scope.rootIndex; 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 key = JSON.stringify([change.entity, change.id]), matches = scope.identities.get(key) ?? []; + if (matches.length !== 1 || baseline?.identities.get(key)?.length !== 1) return { ok: false, diagnostics: [{ code: 'ambiguous-native-identity', message: 'A unique explicit native entity ID must exist in both baseline and current projected graphs; a generated ID cannot be validated by a newly inserted declaration', path: change.path, severity: 'error' }] }; 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' }] }; } diff --git a/tests/contracts/domain-formats.test.ts b/tests/contracts/domain-formats.test.ts new file mode 100644 index 0000000..583c1ce --- /dev/null +++ b/tests/contracts/domain-formats.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { createGraph } from "../../src/graph"; +import * as graphson from "../../src/formats/graphson"; +import * as cx2 from "../../src/formats/cx2"; +import * as dgml from "../../src/formats/dgml"; +import * as rdf from "../../src/formats/rdf"; +import * as syntax from "../../src/formats/rdf/syntax"; +const contracts: Record = { + fromGraphSON: "Typed source/core/document validation and actual Java oracle", + toGraphSON: + "Directed immutable/repeatable fixture contracts plus Java oracle", + fromCX2: "Numeric identity/declaration/default corpus and NDEx actual reader", + toCX2: "Canonical profile, unsupported state rejection and native edits", + fromDGML: "Category/containment/inferred endpoint corpus and official XSD", + toDGML: "Explicit positional identity option and unsupported state rejection", + getRDFTerm: "Domain value validator: term roles and malformed controls", + getRDFTermId: + "Domain value identity converter: scoped blanks and language/datatype", + getRDFQuadId: "Domain quad identity converter: role order and set semantics", + fromRDFDataset: "RDF1.1 term/dataset corpus and independent isomorphism", + toRDFDataset: "Derived identity profile and arbitrary Graph rejection", + fromRDF: "Required explicit syntax/options and independent RDFLib parsing", + toRDF: "Required explicit syntax/options and native dataset/loss controls", + createRDFDocumentConverter: + "Explicit syntax factory: persisted source and unsupported edits", +}; +describe("domain adapter public contract inventory", () => { + it("all public functions have a specific domain contract", () => { + const names = [graphson, cx2, dgml, rdf, syntax] + .flatMap((module) => + Object.entries(module) + .filter(([, value]) => typeof value === "function") + .map(([name]) => name), + ) + .sort(); + expect(names).toEqual(Object.keys(contracts).sort()); + for (const description of Object.values(contracts)) + expect(description.length).toBeGreaterThan(30); + }); + it.each(["empty", "isolates", "loop", "parallel"] as const)( + "GraphSON %s immutable and repeatable", + (kind) => { + const graph = createGraph({ + nodes: kind === "empty" ? [] : [{ id: "a" }, { id: "b" }], + edges: + kind === "loop" + ? [{ id: "loop", sourceId: "a", targetId: "a" }] + : kind === "parallel" + ? [ + { id: "e1", sourceId: "a", targetId: "b" }, + { id: "e2", sourceId: "a", targetId: "b" }, + ] + : [], + }); + const before = JSON.stringify(graph), + source = graphson.toGraphSON(graph); + const restored = graphson.fromGraphSON(source); + expect(restored.nodes).toEqual(graph.nodes); + expect(restored.edges).toEqual(graph.edges); + expect(graphson.toGraphSON(graph)).toEqual(source); + expect(JSON.stringify(graph)).toBe(before); + }, + ); +}); diff --git a/tests/cx2.test.ts b/tests/cx2.test.ts new file mode 100644 index 0000000..d05f29a --- /dev/null +++ b/tests/cx2.test.ts @@ -0,0 +1,397 @@ +import { expect, it } from 'vitest'; +import { fromCX2, toCX2, cx2DocumentConverter } from '../src/formats/cx2'; +import { createGraph } from '../src/graph'; +const source: any = [ + { CXVersion: '2.0', hasFragments: false }, + { + attributeDeclarations: [ + { + nodes: { + name: { d: 'string', a: 'n' }, + score: { d: 'double', v: 2 }, + active: { d: 'boolean', v: false }, + }, + edges: { name: { d: 'string' }, weight: { d: 'double' } }, + }, + ], + }, + { + nodes: [ + { id: 0, x: 1, y: 2, v: { n: '', score: 3 } }, + { id: 1, x: 3, y: 4, v: { n: '東京' } }, + ], + }, + { + edges: [ + { id: 2, s: 0, t: 1, v: { name: 'Go', weight: 2 } }, + { id: 3, s: 0, t: 1 }, + { id: 4, s: 0, t: 0 }, + ], + }, + { visualProperties: [{ default: { node: { NODE_FILL_COLOR: '#fff' } } }] }, + { vendor: [{ opaque: true }] }, + { status: [{ success: true }] }, +]; +it('resolves aliases/defaults without losing topology/empty labels', () => { + const graph = fromCX2(source); + expect(graph.nodes[0]).toMatchObject({ + id: '0', + label: '', + x: 1, + y: 2, + data: { attributes: { score: 3, active: false } }, + }); + expect(graph.nodes[1].data.attributes!.score).toBe(2); + expect(graph.edges.map((e) => [e.id, e.sourceId, e.targetId])).toEqual([ + ['2', '0', '1'], + ['3', '0', '1'], + ['4', '0', '0'], + ]); + expect(toCX2(JSON.parse(JSON.stringify(graph)))).toEqual(source); +}); +it('patches known labels/typed properties while preserving opaque aspects/field presence', () => { + const doc = cx2DocumentConverter.from(source); + expect(cx2DocumentConverter.to(JSON.parse(JSON.stringify(doc)))).toEqual( + source + ); + doc.graph!.nodes[0].label = 'After'; + doc.graph!.nodes[1].data.attributes!.score = 4; + const output = cx2DocumentConverter.to(doc); + expect(output[4]).toEqual(source[4]); + expect(output[5]).toEqual(source[5]); + expect((output[2] as any).nodes[0].v).toEqual({ n: 'After', score: 3 }); + expect(fromCX2(output).nodes[1].data.attributes!.score).toBe(4); +}); +it('fresh export accepts canonical numeric identities and rejects arbitrary IDs', () => { + const graph = createGraph({ + nodes: [ + { id: '0', label: 'A' }, + { id: '1', label: 'B' }, + ], + edges: [{ id: '2', sourceId: '0', targetId: '1', label: 'Go' }], + }); + const parsed = fromCX2(toCX2(graph)); + expect(parsed.edges[0]).toMatchObject({ + id: '2', + sourceId: '0', + label: 'Go', + }); + graph.nodes[0].id = 'named'; + graph.edges[0].sourceId = 'named'; + expect(() => toCX2(graph, { nativeCarrier: false })).toThrow(/canonical/); +}); +it.each([ + (s: any) => s[2].nodes.push({ id: 0 }), + (s: any) => (s[3].edges[0].t = 99), + (s: any) => (s[2].nodes[0].id = 9007199254740992), + (s: any) => (s[2].nodes[0].v.score = 'heavy'), + (s: any) => (s[2].nodes[0].v.name = 'full alias'), + (s: any) => s.pop(), + (s: any) => delete s[2].nodes[1].y, +])('rejects malformed native inputs', (mutate) => { + const value = JSON.parse(JSON.stringify(source)); + mutate(value); + expect(() => fromCX2(value)).toThrow(); +}); +it('rejects structural/data type edits and retains unsupported source in documents', () => { + const doc = cx2DocumentConverter.from(source); + doc.graph!.nodes[0].data.attributes!.score = 'heavy'; + expect(() => cx2DocumentConverter.to(doc)).toThrow(); + const malformed = JSON.parse(JSON.stringify(source)); + malformed[3].edges[0].t = 99; + const native = cx2DocumentConverter.from(malformed); + expect(native.graph).toBeNull(); + expect(cx2DocumentConverter.to(native)).toEqual(malformed); +}); + +it.each(['hidden', 'sparse-custom'])( + 'rejects arrays that JSON cloning would silently change: %s', + (kind) => { + const value = JSON.parse(JSON.stringify(source)); + if (kind === 'hidden') + Object.defineProperty(value, 'hidden', { + value: value.length, + enumerable: false, + }); + else { + delete value[0]; + Object.defineProperty(value, 'custom', { + value: { CXVersion: '2.0' }, + enumerable: true, + }); + } + expect(() => fromCX2(value)).toThrow(/JSON|array|sparse/); + } +); +it('rejects late fragmented declarations whose defaults could change earlier records', () => { + const value = JSON.parse(JSON.stringify(source)); + value[0].hasFragments = true; + value.splice(3, 0, { + attributeDeclarations: [{ nodes: { late: { d: 'boolean', v: false } } }], + }); + expect(() => fromCX2(value)).toThrow(/late|declaration/); +}); +it('keeps null typed attributes outside the interpreted profile, retaining native documents', () => { + const value = JSON.parse(JSON.stringify(source)); + value[2].nodes[0].v.score = null; + expect(() => fromCX2(value)).toThrow(/numeric/); + const document = cx2DocumentConverter.from(value); + expect(document.graph).toBeNull(); + expect(cx2DocumentConverter.to(document)).toEqual(value); +}); + +it('native label edits win over stale carrier fields and conflicts remain explicit', () => { + const graph = fromCX2(source); + (graph.data as any).__statelyaiNative.native[2].nodes[0].v.n = 'Native'; + expect(fromCX2(toCX2(graph)).nodes[0].label).toBe('Native'); + graph.nodes[0].label = 'Graph'; + expect(() => toCX2(graph)).toThrow(/conflict/); +}); +it('preserves independently edited opaque aspects alongside Graph label changes', () => { + const doc = cx2DocumentConverter.from(source); + doc.source[5].vendor[0].opaque = 'native'; + doc.graph!.nodes[0].label = 'Graph'; + const out = cx2DocumentConverter.to(doc); + expect(out[5].vendor[0].opaque).toBe('native'); + expect(fromCX2(out).nodes[0].label).toBe('Graph'); +}); +it('concatenates supported fragments without allowing late declarations', () => { + const value: any = [ + { CXVersion: '2.0', hasFragments: true }, + { attributeDeclarations: [{ nodes: { name: { d: 'string' } } }] }, + { nodes: [{ id: 0, v: { name: 'A' } }] }, + { edges: [{ id: 5, s: 0, t: 1 }] }, + { nodes: [{ id: 1, v: { name: 'B' } }] }, + { status: [{ success: true }] }, + ]; + expect(fromCX2(value).nodes.map((n) => n.id)).toEqual(['0', '1']); + expect(toCX2(fromCX2(value))).toEqual(value); +}); +it('handles 10k numeric topology and label edits with stable IDs', () => { + const value: any = [ + { CXVersion: '2.0' }, + { attributeDeclarations: [{ nodes: { name: { d: 'string' } } }] }, + { + nodes: Array.from({ length: 10000 }, (_, id) => ({ + id, + v: { name: 'Before' }, + })), + }, + { + edges: Array.from({ length: 9999 }, (_, id) => ({ + id, + s: id, + t: id + 1, + })), + }, + { status: [{ success: true }] }, + ]; + const document = cx2DocumentConverter.from(value); + for (const node of document.graph!.nodes) node.label = `After ${node.id}`; + const graph = fromCX2(cx2DocumentConverter.to(document)); + expect(graph.nodes).toHaveLength(10000); + expect(graph.edges).toHaveLength(9999); + expect(graph.nodes[9999].label).toBe('After 9999'); +}, 30000); + +it.each([ + ['string', '', true], + ['boolean', false, true], + ['integer', -2147483648, true], + ['integer', 2147483648, false], + ['long', Number.MAX_SAFE_INTEGER, true], + ['long', Number.MAX_SAFE_INTEGER + 1, false], + ['double', 0.5, true], + ['double', Infinity, false], + ['list_of_string', ['', '東京'], true], + ['list_of_boolean', [false, true], true], + ['list_of_integer', [0, 2], true], + ['list_of_double', [0.5, 3], true], + ['list_of_long', [Number.MAX_SAFE_INTEGER], true], + ['list_of_string', [1], false], + ['integer', '3', false], + ['string', null, false], + ['unknown', 1, false], +] as Array<[string, any, boolean]>)( + 'validates declared %s values without changing types (%j)', + (type, value, valid) => { + const input: any = [ + { CXVersion: '2.0' }, + { attributeDeclarations: [{ nodes: { value: { d: type } } }] }, + { nodes: [{ id: 1, v: { value } }] }, + { status: [{ success: true }] }, + ]; + if (valid) + expect(fromCX2(input).nodes[0].data.attributes!.value).toEqual(value); + else expect(() => fromCX2(input)).toThrow(); + } +); +it.each([ + (s: any) => (s[0].CXVersion = '1.0'), + (s: any) => (s[0].hasFragments = 'false'), + (s: any) => (s[6].status[0].success = false), + (s: any) => s.splice(3, 0, { nodes: [] }), + (s: any) => (s[1].attributeDeclarations[0].nodes.score.a = 'n'), + (s: any) => (s[1].attributeDeclarations[0].nodes.name.d = 'double'), + (s: any) => (s[1].attributeDeclarations[0].nodes.name.a = 'id'), + (s: any) => + (s[1].attributeDeclarations[0].networkAttributes = { + name: { d: 'string', a: 'n' }, + }), + (s: any) => + (s[1].attributeDeclarations[0].networkAttributes = { + name: { d: 'string', v: 'default' }, + }), + (s: any) => (s[1].attributeDeclarations[0].nodes.id = { d: 'long' }), + (s: any) => s[3].edges.push({ ...s[3].edges[0] }), + (s: any) => (s[2].nodes[0].z = 1), + (s: any) => (s[2].nodes[0].x = null), + (s: any) => + s.splice(1, 0, { metaData: [{ name: 'nodes', elementCount: 999 }] }), + (s: any) => + s.splice(3, 0, { metaData: [{ name: 'nodes', elementCount: 2 }] }), + (s: any) => (s[2].extra = []), +])('rejects invalid declaration/envelope/coordinate state %#', (mutate) => { + const value = JSON.parse(JSON.stringify(source)); + mutate(value); + expect(() => fromCX2(value)).toThrow(); +}); +it('handles numeric defaults encoded as lexical strings and explicit absent/empty/null labels', () => { + const value: any = [ + { CXVersion: '2.0' }, + { + attributeDeclarations: [ + { nodes: { name: { d: 'string' }, score: { d: 'double', v: '0.0' } } }, + ], + }, + { nodes: [{ id: -1 }, { id: 0, v: { name: '' } }] }, + { status: [{ success: true }] }, + ]; + const graph = fromCX2(value); + expect(graph.nodes.map((n) => n.label)).toEqual([null, '']); + expect(graph.nodes[0].data.attributes).toEqual({ score: 0 }); + const doc = cx2DocumentConverter.from(value); + doc.graph!.nodes[1].label = null; + expect(fromCX2(cx2DocumentConverter.to(doc)).nodes[1].label).toBeNull(); +}); +it('supports declared empty lists and rejects ambiguous fresh inferred values/data collisions', () => { + const graph = createGraph({ + nodes: [{ id: '1', label: 'A', data: { attributes: { values: [] } } }], + }); + expect(() => toCX2(graph)).toThrow(/declaration/); + graph.data = { declarations: { nodes: { values: { d: 'list_of_string' } } } }; + expect(fromCX2(toCX2(graph)).nodes[0].data.attributes!.values).toEqual([]); + (graph.nodes[0].data as any).attributes = { name: 'collision' }; + expect(() => toCX2(graph)).toThrow(/collision/); +}); +it('rejects JSON functions/cycles/accessors/classes and preserves prototype-like declared keys', () => { + for (const value of [() => 1, new Date(), undefined, Symbol('x')]) { + const input = JSON.parse(JSON.stringify(source)); + input[5].vendor[0].opaque = value; + expect(() => fromCX2(input)).toThrow(/JSON/); + } + const cycle: any = []; + cycle.push(cycle); + expect(() => fromCX2(cycle)).toThrow(/JSON/); + const input = JSON.parse(JSON.stringify(source)); + Object.defineProperty(input[5].vendor[0], 'opaque', { + get() { + throw new Error('getter must not execute'); + }, + enumerable: true, + }); + expect(() => fromCX2(input)).toThrow(/accessor/); + const safe: any = JSON.parse( + '[{"CXVersion":"2.0"},{"attributeDeclarations":[{"nodes":{"__proto__":{"d":"string"},"constructor":{"d":"string"}}}]},{"nodes":[{"id":0,"v":{"__proto__":"safe","constructor":"data"}}]},{"status":[{"success":true}]}]' + ); + expect( + Object.hasOwn(fromCX2(safe).nodes[0].data.attributes!, '__proto__') + ).toBe(true); + expect(toCX2(fromCX2(safe))).toEqual(safe); +}); +it('rejects unsupported structure/root/schema/coordinate edits and detects same-label conflicts', () => { + for (const edit of [ + (g: any) => g.nodes.pop(), + (g: any) => (g.nodes[0].x = 9), + (g: any) => (g.nodes[0].data = { vendor: 42 }), + (g: any) => (g.data.attributes = { name: 'changed' }), + (g: any) => (g.data.declarations.nodes.score.d = 'integer'), + ]) { + const doc = cx2DocumentConverter.from(source); + edit(doc.graph); + expect(() => cx2DocumentConverter.to(doc)).toThrow(); + } + const doc = cx2DocumentConverter.from(source); + doc.source[2].nodes[0].v.n = 'Native'; + doc.graph!.nodes[0].label = 'Graph'; + expect(() => cx2DocumentConverter.to(doc)).toThrow(/conflict/); +}); + +it('does not silently convert null labels into declared name defaults', () => { + const graph = createGraph({ + data: { + declarations: { nodes: { name: { d: 'string' as const, v: 'default' } } }, + }, + nodes: [{ id: '0', label: null }], + }); + expect(() => toCX2(graph)).toThrow(/default/); +}); + +it.each([ + ['nodes', 'alias', 'string'], + ['nodes', 'represents', 'long'], + ['networkAttributes', 'name', 'integer'], + ['networkAttributes', 'description', 'boolean'], + ['networkAttributes', 'version', 'double'], +])('honors reserved %s.%s native declaration types', (aspect, name, type) => { + const input: any = [ + { CXVersion: '2.0' }, + { attributeDeclarations: [{ [aspect]: { [name]: { d: type } } }] }, + { status: [{ success: true }] }, + ]; + expect(() => fromCX2(input)).toThrow(/requires/); +}); +it('distinguishes ordinary x/y/z attributes from native coordinate fields and retains opaque z', () => { + const input: any = [ + { CXVersion: '2.0' }, + { + attributeDeclarations: [ + { nodes: { name: { d: 'string' }, x: { d: 'double' } } }, + ], + }, + { + nodes: [ + { id: 0, x: 1, y: 2, z: 0, v: { name: 'A', x: 99 } }, + { id: 1, x: 3, y: 4, z: 5, v: { name: 'B', x: 98 } }, + ], + }, + { status: [{ success: true }] }, + ]; + const graph = fromCX2(input); + expect(graph.nodes[0].x).toBe(1); + expect(graph.nodes[0].data.attributes!.x).toBe(99); + const doc = cx2DocumentConverter.from(input); + doc.graph!.nodes[0].label = 'After'; + const output = cx2DocumentConverter.to(doc); + expect(output[2].nodes[0].z).toBe(0); + expect(output[2].nodes[1].z).toBe(5); +}); + +it('never silently drops non-JSON declaration extensions during fresh export', () => { + const graph = createGraph({ + data: { + declarations: { + nodes: { name: { d: 'string' as const, extra: () => 1 } }, + }, + }, + nodes: [{ id: '0', label: 'A' }], + }); + expect(() => toCX2(graph)).toThrow(/JSON/); + const valid = createGraph({ + data: { + declarations: { nodes: { name: { d: 'string' as const, a: undefined } } }, + }, + nodes: [{ id: '0', label: 'A' }], + }); + expect(fromCX2(toCX2(valid)).nodes[0].label).toBe('A'); +}); diff --git a/tests/dgml.test.ts b/tests/dgml.test.ts new file mode 100644 index 0000000..6793ce0 --- /dev/null +++ b/tests/dgml.test.ts @@ -0,0 +1,302 @@ +import { expect, it } from 'vitest'; +import { createGraph } from '../src/graph'; +import { fromDGML, toDGML, dgmlDocumentConverter } from '../src/formats/dgml'; +const ns = 'http://schemas.microsoft.com/vs/2009/dgml'; +const source = ``; +it('maps categories, typed properties, containment and inferred endpoints distinctly', () => { + const graph = fromDGML(source); + expect( + graph.nodes.map((n) => [n.id, n.label, n.parentId, n.data.declared]) + ).toEqual([ + ['g', 'g', null, true], + ['a', '', 'g', true], + ['implicit', 'implicit', null, false], + ]); + expect(graph.nodes[1].data.properties).toEqual({ Count: 2 }); + expect(graph.edges).toHaveLength(2); + expect(toDGML(JSON.parse(JSON.stringify(graph)))).toBe(source); +}); +it('retains comment/namespace/unknown XML on strict document label and property edits', () => { + const xml = source + .replace('', '') + .replace( + 'Category="Thing" Count=', + 'Category="Thing" Custom="opaque" Count=' + ); + const doc = dgmlDocumentConverter.from(xml); + expect(dgmlDocumentConverter.to(JSON.parse(JSON.stringify(doc)))).toBe(xml); + doc.graph!.nodes[1].label = '東京 " &\r\n'; + doc.graph!.nodes[1].data.properties!.Count = 3; + const patched = dgmlDocumentConverter.to(doc); + expect(patched).toContain(''); + expect(patched).toContain('Custom="opaque"'); + expect(fromDGML(patched).nodes[1]).toMatchObject({ + label: '東京 " &\r\n', + data: { properties: { Count: 3 } }, + }); +}); +it('rejects implicit label and structural edits', () => { + const doc = dgmlDocumentConverter.from(source); + doc.graph!.nodes[2].label = 'Changed'; + expect(() => dgmlDocumentConverter.to(doc)).toThrow(/implicit/); + const other = dgmlDocumentConverter.from(source); + other.graph!.edges.pop(); + expect(() => dgmlDocumentConverter.to(other)).toThrow(); +}); +it('fresh output emits valid native declarations without silently losing Graph fields', () => { + const graph = createGraph({ + nodes: [ + { id: 'a', label: 'A' }, + { id: 'b', parentId: 'a', label: 'B' }, + ], + edges: [{ id: 'e', sourceId: 'b', targetId: 'b', label: 'Loop' }], + }); + const parsed = fromDGML(toDGML(graph, { edgeIds: 'positional' })); + expect(parsed.nodes[1].parentId).toBe('a'); + expect(parsed.edges[0].label).toBe('Loop'); + graph.edges[0].mode = 'undirected'; + expect(() => toDGML(graph, { nativeCarrier: false })).toThrow(/directed/); +}); +it.each(['', ''])( + 'rejects malformed/duplicate node declaration %s', + (nodes) => + expect(() => + fromDGML( + `${nodes}` + ) + ).toThrow() +); +it('rejects cycles/multiple containment and invalid property types', () => { + for (const links of [ + '', + '', + ]) + expect(() => + fromDGML( + `${links}` + ) + ).toThrow(/containment/); + expect(() => fromDGML(source.replace('Count="2"', 'Count="heavy"'))).toThrow( + /property/ + ); +}); + +it('requires explicit positional link identity loss, accounting for Contains link indices', () => { + const graph = createGraph({ + nodes: [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B', parentId: 'a' }, + ], + edges: [{ id: 'custom', sourceId: 'a', targetId: 'b' }], + }); + expect(() => toDGML(graph)).toThrow(/positional/); + const projected = fromDGML(toDGML(graph, { edgeIds: 'positional' })); + expect(projected.edges[0].id).toBe('dgml_link_1'); + graph.edges[0].id = 'dgml_link_1'; + expect(fromDGML(toDGML(graph)).edges[0].id).toBe('dgml_link_1'); +}); +it('retains exact signed Int64 lexical values and rejects out-of-range magnitudes', () => { + const xml = source + .replace('Count="2"', 'Count="9223372036854775807"') + .replace('System.Int32', 'System.Int64'); + const graph = fromDGML(xml); + expect(graph.nodes[1].data.properties!.Count).toBe('9223372036854775807'); + const doc = dgmlDocumentConverter.from(xml); + doc.graph!.nodes[1].data.properties!.Count = '-9223372036854775808'; + expect( + fromDGML(dgmlDocumentConverter.to(doc)).nodes[1].data.properties!.Count + ).toBe('-9223372036854775808'); + expect(() => + fromDGML(xml.replace('9223372036854775807', '9223372036854775808')) + ).toThrow(/range/); +}); +it('does not project namespace-shadowed or nested foreign Node/Link lookalikes', () => { + const xml = ``; + expect(fromDGML(xml).nodes.map((n) => n.id)).toEqual(['a']); +}); +it.each([ + { vendor: 42 }, + { attributes: { xmlns: 'bad' } }, + { attributes: { 'bad key': 'value' } }, + { attributes: { 'v:unbound': 'value' } }, +])('rejects unrepresentable fresh root data %j', (data) => { + const graph = createGraph({ data, nodes: [{ id: 'a', label: 'A' }] }); + expect(() => toDGML(graph as any)).toThrow(); +}); +it('recognizes Contains membership supplied as native child Category', () => { + const xml = ``; + const graph = fromDGML(xml); + expect(graph.nodes[1].parentId).toBe('p'); + expect(graph.edges).toEqual([]); +}); + +it('native labels stay authoritative over stale carrier-backed Graph fields', () => { + const graph = fromDGML(source); + (graph.data as any).__statelyaiNative.native = source.replace( + 'Label=""', + 'Label="Native"' + ); + expect(fromDGML(toDGML(graph)).nodes[1].label).toBe('Native'); + graph.nodes[1].label = 'Graph'; + expect(() => toDGML(graph)).toThrow(/conflict/); +}); +it('rejects unrepresentable node/edge data and protected property names on fresh export', () => { + const graph = createGraph({ + nodes: [{ id: 'a', label: 'A', data: { vendor: 42 } }], + }); + expect(() => toDGML(graph as any)).toThrow(/data key/); +}); +it('retains source edits to opaque native fields independently of requested label edits', () => { + const doc = dgmlDocumentConverter.from(source); + doc.source = source.replace( + 'Category="Thing" Count=', + 'Category="Thing" Extra="native" Count=' + ); + doc.graph!.nodes[1].label = 'Graph'; + const out = dgmlDocumentConverter.to(doc); + expect(out).toContain('Extra="native"'); + expect(fromDGML(out).nodes[1].label).toBe('Graph'); +}); +it('handles 10k declared topology and bulk label edits iteratively', () => { + const xml = `${Array.from( + { length: 10000 }, + (_, i) => `` + ).join('')}`; + const document = dgmlDocumentConverter.from(xml); + for (const node of document.graph!.nodes) node.label = `After ${node.id}`; + const out = dgmlDocumentConverter.to(document); + const graph = fromDGML(out); + expect(graph.nodes).toHaveLength(10000); + expect(graph.nodes[9999].label).toBe('After n9999'); +}, 30000); + +it.each([ + ['System.String', '', ''], + ['System.Boolean', 'TRUE', true], + ['System.Boolean', 'false', false], + ['System.Int32', '-2147483648', -2147483648], + ['System.Int64', '-9223372036854775808', '-9223372036854775808'], + ['System.Double', '2.5e2', 250], +] as Array<[string, string, any]>)( + 'maps explicit CLR %s property values exactly (%s)', + (type, value, expected) => { + const xml = source + .replace('Count="2"', `Count="${value}"`) + .replace('System.Int32', type); + expect(fromDGML(xml).nodes[1].data.properties!.Count).toEqual(expected); + } +); +it.each([ + '', + '', + ``, + ``, + ``, + ``, + ``, +])('rejects malformed/scoped XML %#', (xml) => + expect(() => fromDGML(xml)).toThrow() +); +it('allows literal Unicode/empty identities, declared group modes and XML-safe escaping', () => { + const graph = createGraph({ nodes: [{ id: 'valid', label: '' }] }); + graph.nodes[0].id = ''; + const parsed = fromDGML(toDGML(graph)); + expect(parsed.nodes[0].id).toBe(''); + expect(parsed.nodes[0].label).toBe(''); + for (const label of ['bad\u0000', 'bad\ud800']) { + graph.nodes[0].label = label; + expect(() => toDGML(graph)).toThrow(/XML/); + } + graph.nodes[0].id = '東京 & "'; + graph.nodes[0].label = '😀'; + expect(fromDGML(toDGML(graph)).nodes[0].id).toBe(graph.nodes[0].id); +}); +it('rejects unsupported typed property mutations, null labels and metadata-baseline tampering', () => { + for (const edit of [ + (g: any) => (g.nodes[1].data.properties.Count = '2'), + (g: any) => delete g.nodes[1].data.properties.Count, + (g: any) => (g.nodes[1].data.attributes.Custom = 'added'), + (g: any) => (g.nodes[1].label = null), + ]) { + const doc = dgmlDocumentConverter.from(source); + edit(doc.graph); + expect(() => dgmlDocumentConverter.to(doc)).toThrow(); + } + const graph = fromDGML(source); + (graph.data as any).__statelyaiNative.baseline.nodes[0].label = 'Forged'; + expect(() => toDGML(graph)).toThrow(/provenance/); +}); + +it('uses native XML attribute normalization while preserving character references', () => { + const xml = ``; + expect(fromDGML(xml).nodes[0].label).toBe('A B C\nD\rE'); + expect(toDGML(fromDGML(xml))).toBe(xml); +}); +it.each([' ', '�', '�', '�'])( + 'rejects invalid XML label references: %s', + (label) => { + expect(() => + fromDGML( + `` + ) + ).toThrow(); + } +); + +it('does not convert an ordinary edge into containment or overwrite mapped fields with property values', () => { + const graph = createGraph({ + data: { properties: [{ Id: 'Label', DataType: 'System.String' }] }, + nodes: [ + { id: 'a', label: 'A', data: { properties: { Label: 'Hidden' } } }, + { id: 'b', label: 'B' }, + ], + edges: [{ id: 'dgml_link_0', sourceId: 'a', targetId: 'b' }], + }); + expect(() => toDGML(graph)).toThrow(/protected/); + (graph.nodes[0].data as any) = {}; + graph.edges[0].data = { categories: ['Contains'] }; + expect(() => toDGML(graph)).toThrow(/Contains/); +}); +it('XML entity names are case sensitive', () => { + expect(() => + fromDGML( + `` + ) + ).toThrow(/entity/); +}); + +it.each(['insert', 'remove', 'reorder'])( + 'rejects ambiguous positional edge patches after native Link %s', + (operation) => { + const original = ``; + const doc = dgmlDocumentConverter.from(original); + const first = '', + second = ''; + if (operation === 'insert') + doc.source = original.replace( + first, + '' + first + ); + else if (operation === 'remove') doc.source = original.replace(first, ''); + else doc.source = original.replace(first + second, second + first); + doc.graph!.edges[0].label = 'Graph edit'; + expect(() => dgmlDocumentConverter.to(doc)).toThrow( + /Link|positional|ambiguous/ + ); + } +); +it('native Link insertion stays authoritative without edge edits and does not prevent stable node-ID patches', () => { + const original = ``; + const source = original.replace( + '', + '' + ); + const doc = dgmlDocumentConverter.from(original); + doc.source = source; + expect(dgmlDocumentConverter.to(doc)).toBe(source); + doc.graph!.nodes[0].label = 'Node edit'; + const output = dgmlDocumentConverter.to(doc); + expect(output).toContain('Label="Node edit"'); + expect(output).toContain('Label="Same" Marker="inserted"'); + expect(output).toContain('Label="Same" Marker="original"'); +}); diff --git a/tests/document-identity-additional.test.ts b/tests/document-identity-additional.test.ts new file mode 100644 index 0000000..e99c179 --- /dev/null +++ b/tests/document-identity-additional.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../src/graph'; +import { jgfDocumentConverter } from '../src/formats/jgf'; +import { graphologyDocumentConverter } from '../src/formats/graphology'; +import { networkxDocumentConverter } from '../src/formats/networkx'; +import { plantumlDocumentConverter, toPlantUML } from '../src/formats/plantuml'; +import { pajekDocumentConverter, toPajek } from '../src/formats/pajek'; + +type Case = { + name: string; + converter: any; + base: any; + insert(source: any): any; + records(source: any): any[]; +}; +const cases: Case[] = [ + { + name: 'JGF', + converter: jgfDocumentConverter, + base: { + graph: { + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [ + { + source: 'a', + target: 'b', + label: 'Same', + metadata: { Marker: 'original' }, + }, + ], + }, + }, + insert(source) { + source.graph.edges.unshift({ + id: 'e0', + source: 'a', + target: 'b', + label: 'Same', + metadata: { Marker: 'inserted' }, + }); + return source; + }, + records: (source) => source.graph.edges, + }, + { + name: 'Graphology', + converter: graphologyDocumentConverter, + base: { + options: { type: 'directed', multi: true }, + attributes: {}, + nodes: [{ key: 'a' }, { key: 'b' }], + edges: [ + { + source: 'a', + target: 'b', + attributes: { label: 'Same', Marker: 'original' }, + }, + ], + }, + insert(source) { + source.edges.unshift({ + key: 'e0', + source: 'a', + target: 'b', + attributes: { label: 'Same', Marker: 'inserted' }, + }); + return source; + }, + records: (source) => source.edges, + }, + { + name: 'NetworkX', + converter: networkxDocumentConverter, + base: { + directed: true, + multigraph: true, + graph: {}, + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [{ source: 'a', target: 'b', label: 'Same', Marker: 'original' }], + }, + insert(source) { + source.edges.unshift({ + source: 'a', + target: 'b', + key: 0, + label: 'Same', + Marker: 'inserted', + }); + return source; + }, + records: (source) => source.edges, + }, + { + name: 'PlantUML', + converter: plantumlDocumentConverter, + base: '@startuml\ncomponent "A" as a\ncomponent "B" as b\n\' original\na --> b : Same\n@enduml', + insert(source) { + return source.replace( + "' original\na --> b : Same", + "' inserted\na --> b : Same\n' original\na --> b : Same" + ); + }, + records: () => [], + }, + { + name: 'Pajek', + converter: pajekDocumentConverter, + base: '*Vertices 2\n1 "A"\n2 "B"\n*Arcs\n% original\n1 2 1 l "Same"\n', + insert(source) { + return source.replace( + '% original\n1 2 1 l "Same"', + '% inserted\n1 2 1 l "Same"\n% original\n1 2 1 l "Same"' + ); + }, + records: () => [], + }, +]; +function getClone(value: T): T { + return JSON.parse(JSON.stringify(value)); +} +function getChangedSequence( + test: Case, + operation: 'insert' | 'remove' | 'reorder' +) { + let source = getClone(test.base); + if (operation === 'insert') + return { base: source, changed: test.insert(getClone(source)) }; + if (typeof source !== 'string') { + const rows = test.records(source), + second = getClone(rows[0]); + if (test.name === 'JGF') second.metadata.Marker = 'second'; + else if (test.name === 'Graphology') second.attributes.Marker = 'second'; + else second.Marker = 'second'; + rows.push(second); + const changed = getClone(source), + records = test.records(changed); + if (operation === 'remove') records.shift(); + else records.reverse(); + return { base: source, changed }; + } + const first = + test.name === 'PlantUML' + ? "' original\na --> b : Same" + : '% original\n1 2 1 l "Same"'; + const second = + test.name === 'PlantUML' + ? "' second\na --> b : Same" + : '% second\n1 2 1 l "Same"'; + source = source.replace(first, first + '\n' + second); + return { + base: source, + changed: source.replace( + first + '\n' + second, + operation === 'remove' ? second : second + '\n' + first + ), + }; +} + +describe('remaining generated document identity guards', () => { + for (const test of cases) { + it.each(['insert', 'remove', 'reorder'] as const)( + `${test.name}: rejects pending edge edits after %s`, + (operation) => { + const { base, changed } = getChangedSequence(test, operation), + document = test.converter.from(base); + expect(document.graph).not.toBeNull(); + document.source = changed; + document.graph.edges[0].label = 'Graph edit'; + const result = test.converter.getUpdate(document); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'ambiguous-native-identity', + path: expect.stringContaining('/edges/'), + }) + ); + expect(() => test.converter.to(document)).toThrow( + /identity|ambiguous/i + ); + } + ); + it(`${test.name}: keeps changed source authoritative without a Graph edit`, () => { + const document = test.converter.from(test.base); + document.source = test.insert(getClone(test.base)); + expect(test.converter.to(document)).toEqual(document.source); + }); + it(`${test.name}: node-ID edits survive independent native edge insertion`, () => { + const document = test.converter.from(test.base); + document.source = test.insert(getClone(test.base)); + document.graph.nodes[0].label = 'Node edit'; + const output = test.converter.to(document), + projected = test.converter.from(output).graph; + expect( + projected.nodes.find( + (node: any) => node.id === document.graph.nodes[0].id + )?.label + ).toBe('Node edit'); + expect(projected.edges.map((edge: any) => edge.label)).toEqual([ + 'Same', + 'Same', + ]); + }); + it(`${test.name}: unchanged native sequences retain ordinary edge edit support`, () => { + const document = test.converter.from(test.base); + document.graph.edges[0].label = 'Graph edit'; + expect( + test.converter.from(test.converter.to(document)).graph.edges[0].label + ).toBe('Graph edit'); + }); + } +}); + +it.each(cases.slice(0, 3))( + '$name: stable explicit native identities allow insertion/reordering', + (test) => { + const source = getClone(test.base), + original = test.records(source)[0]; + if (test.name === 'JGF') original.id = 'original'; + else if (test.name === 'Graphology') original.key = 'original'; + else original.key = false; + const document = test.converter.from(source); + document.source = test.insert(getClone(source)); + if (test.name === 'NetworkX') document.source.edges[0].key = 1; + document.graph.edges[0].label = 'Graph edit'; + const output = test.converter.to(document), + records = test.records(output); + const label = (record: any) => + test.name === 'Graphology' ? record.attributes.label : record.label; + expect(label(records[0])).toBe('Same'); + expect(label(records[1])).toBe('Graph edit'); + } +); +it.each(cases.slice(0, 3))( + '$name: explicit identities survive native reordering', + (test) => { + const source = getClone(test.base), + first = test.records(source)[0]; + if (test.name === 'JGF') first.id = 'original'; + else if (test.name === 'Graphology') first.key = 'original'; + else first.key = false; + test.insert(source); + if (test.name === 'NetworkX') source.edges[0].key = 1; + const document = test.converter.from(source); + document.source = getClone(source); + test.records(document.source).reverse(); + document.graph.edges[1].label = 'Graph edit'; + const output = test.records(test.converter.to(document)); + const label = (record: any) => + test.name === 'Graphology' ? record.attributes.label : record.label; + expect(output.map(label)).toEqual(['Graph edit', 'Same']); + } +); +it.each(cases.slice(0, 3))( + '$name: removing an explicit key cannot bless generated current identity', + (test) => { + const source = getClone(test.base), + first = test.records(source)[0]; + if (test.name === 'JGF') first.id = 'e0'; + else if (test.name === 'Graphology') first.key = 'e0'; + else first.key = 0; + const document = test.converter.from(source); + delete test.records(document.source)[0][test.name === 'JGF' ? 'id' : 'key']; + document.graph.edges[0].label = 'Graph edit'; + expect(() => test.converter.to(document)).toThrow(/identity|ambiguous/i); + } +); +it.each(cases)('$name: persisted baseline retains identity guards', (test) => { + const document = getClone(test.converter.from(test.base)); + document.source = test.insert(getClone(test.base)); + document.graph.edges[0].label = 'Graph edit'; + expect(() => test.converter.to(document)).toThrow(/identity|ambiguous/i); +}); +it('NetworkX: simple endpoint identity is stable without explicit keys', () => { + const source: any = { + directed: true, + multigraph: false, + graph: {}, + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + edges: [{ source: 'a', target: 'b', label: 'Same' }], + }; + const document = networkxDocumentConverter.from(source); + document.source.edges!.unshift({ source: 'a', target: 'c', label: 'Same' }); + document.graph!.edges[0].label = 'Graph edit'; + expect( + networkxDocumentConverter.to(document).edges!.map((edge) => edge.label) + ).toEqual(['Same', 'Graph edit']); +}); +it('NetworkX: Python-equivalent explicit boolean/numeric keys remain the same identity', () => { + const source: any = { + directed: true, + multigraph: true, + graph: {}, + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [{ source: 'a', target: 'b', key: false, label: 'Same' }], + }; + const document = networkxDocumentConverter.from(source); + document.source.edges![0].key = 0; + document.graph!.edges[0].label = 'Graph edit'; + expect(networkxDocumentConverter.to(document).edges![0]).toMatchObject({ + key: 0, + label: 'Graph edit', + }); +}); +it.each(['PlantUML', 'Pajek'])( + '%s: genuine occurrence-indexed sidecars cannot bless inserted relationships', + (name) => { + const graph = createGraph({ + nodes: [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + ], + edges: [ + { + id: 'original-edge', + sourceId: 'a', + targetId: 'b', + label: 'Same', + data: { Marker: 'original' }, + }, + ], + }); + const converter = + name === 'PlantUML' ? plantumlDocumentConverter : pajekDocumentConverter; + const source = name === 'PlantUML' ? toPlantUML(graph) : toPajek(graph), + document = converter.from(source); + expect(document.graph!.edges[0].id).toBe('original-edge'); + if (name === 'PlantUML') { + const row = source + .split(/\r?\n/) + .find((line) => !line.trimStart().startsWith("'") && /-->/.test(line))!; + document.source = source.replace( + row, + "' inserted\n" + row + "\n' original\n" + row + ); + } else { + const row = source.split(/\r?\n/).find((line) => /^\d+ \d+ /.test(line))!; + document.source = source.replace( + row, + '% inserted\n' + row + '\n% original\n' + row + ); + } + document.graph!.edges[0].label = 'Graph edit'; + expect(() => converter.to(document)).toThrow(/identity|ambiguous/i); + } +); +it('PlantUML: block comments distinguish otherwise identical relationship reordering', () => { + const source = + '@startuml\ncomponent "A" as a\ncomponent "B" as b\n/\' first\nmarker first\n\'/\na --> b : Same\n/\' second\nmarker second\n\'/\na --> b : Same\n@enduml'; + const document = plantumlDocumentConverter.from(source); + document.source = source + .replace('marker first', 'temporary') + .replace('marker second', 'marker first') + .replace('temporary', 'marker second'); + document.graph!.edges[0].label = 'Graph edit'; + expect(() => plantumlDocumentConverter.to(document)).toThrow( + /identity|ambiguous/i + ); +}); diff --git a/tests/document-identity.test.ts b/tests/document-identity.test.ts new file mode 100644 index 0000000..156c459 --- /dev/null +++ b/tests/document-identity.test.ts @@ -0,0 +1,140 @@ +import type { GraphDocumentConverter } from '../src/formats/document'; +import { d3DocumentConverter } from '../src/formats/d3'; +import { cytoscapeDocumentConverter } from '../src/formats/cytoscape'; +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../src/graph'; +import { dotDocumentConverter, toDOT } from '../src/formats/dot'; +import { graphMLDocumentConverter } from '../src/formats/graphml'; +import { gexfDocumentConverter } from '../src/formats/gexf'; +import { gmlDocumentConverter } from '../src/formats/gml'; +import { tgfDocumentConverter } from '../src/formats/tgf'; +import { graphvizJSONDocumentConverter, toGraphvizJSON, type GraphvizJSON } from '../src/formats/graphviz-json'; + +const dot = 'digraph {a;b;a->b[label="Same",Marker="original"];}'; +const tgf = 'a A\nb B\n#\na b Same\n'; +const gv: GraphvizJSON = { name: 'G', directed: true, strict: false, _subgraph_cnt: 0, objects: [{ _gvid: 0, name: 'a' }, { _gvid: 1, name: 'b' }], edges: [{ _gvid: 0, tail: 0, head: 1, label: 'Same', Marker: 'original' }] }; +const xmlCases = [ + { name: 'GraphML', codec: graphMLDocumentConverter, edge: 'Sameoriginal', wrap: (edge: string) => '' + edge + '', getID: (edge: string, id: string) => edge.replace('', wrap: (edge: string) => '' + edge + '', getID: (edge: string, id: string) => edge.replace(' 'graph [ node [ id "a" ] node [ id "b" ] ' + edge + ' ]', getID: (edge: string, id: string) => edge.replace('edge [', `edge [ id "${id}"`) }, +]; +function getAmbiguity(codec: any, baseline: any, current: any) { + const document = codec.from(baseline); document.source = current; document.graph.edges[0].label = 'GraphEdit'; + const result = codec.getUpdate(document); + expect(result.ok).toBe(false); if (!result.ok) expect(result.diagnostics.some((item: any) => item.code === 'ambiguous-native-identity' && item.path?.includes('/edges/'))).toBe(true); + expect(() => codec.to(document)).toThrow(/identity|ambiguous/i); +} +describe('generated edge identities across source edits', () => { + it('DOT rejects an inserted same-endpoint/label edge with different opaque attributes', () => getAmbiguity(dotDocumentConverter, dot, dot.replace('a->b', 'a->b[label="Same",Marker="inserted"];a->b'))); + it('TGF rejects a whitespace-distinguished inserted duplicate', () => getAmbiguity(tgfDocumentConverter, tgf, tgf.replace('a b Same', '\ta\tb\tSame\na b Same'))); + it('Graphviz rejects valid current _gvid ordinals that no longer identify the baseline edge', () => getAmbiguity(graphvizJSONDocumentConverter, gv, { ...gv, edges: [{ ...gv.edges![0], Marker: 'inserted' }, { ...gv.edges![0], _gvid: 1 }] })); + for (const fixture of xmlCases) it(`${fixture.name} rejects a new explicit ID colliding with a baseline-generated ID`, () => { + const baseline = fixture.wrap(fixture.edge), id = fixture.codec.from(baseline).graph!.edges[0].id; + getAmbiguity(fixture.codec, baseline, fixture.wrap(fixture.getID(fixture.edge.replace('original', 'inserted'), id) + fixture.edge)); + }); + it('rejects same-cardinality reordered DOT and Graphviz records', () => { + const second = 'a->b[label="Same",Marker="second"];'; + getAmbiguity(dotDocumentConverter, dot.replace('}', second + '}'), dot.replace('a->b[label="Same",Marker="original"];', second).replace('}', 'a->b[label="Same",Marker="original"];}')); + const first = { ...gv, edges: [gv.edges![0], { ...gv.edges![0], _gvid: 1, Marker: 'second' }] }; + getAmbiguity(graphvizJSONDocumentConverter, first, { ...gv, edges: [{ ...first.edges[1], _gvid: 0 }, { ...first.edges[0], _gvid: 1 }] }); + }); + it('rejects removed/reordered TGF records with otherwise identical mapped values', () => { + const baseline = tgf + '\ta\tb\tSame\n'; + getAmbiguity(tgfDocumentConverter, baseline, tgf.replace('a b Same', '\ta\tb\tSame')); + getAmbiguity(tgfDocumentConverter, baseline, tgf.replace('a b Same', '\ta\tb\tSame\na b Same')); + }); + it('allows source-only insertions and stable node edits despite ambiguous edge sequences', () => { + const cases: Array<[any, any, any]> = [[dotDocumentConverter, dot, dot.replace('a->b', 'a->b[label="Same",Marker="inserted"];a->b')], [tgfDocumentConverter, tgf, tgf + 'a b Same\n'], [graphvizJSONDocumentConverter, gv, { ...gv, edges: [...gv.edges!, { ...gv.edges![0], _gvid: 1 }] }]]; + for (const [codec, baseline, current] of cases) { const document = codec.from(baseline); document.source = current; expect(codec.to(document)).toEqual(current); } + const node = tgfDocumentConverter.from(tgf); node.source = tgf + 'a b Same\n'; node.graph!.nodes[0].label = 'NodeEdit'; expect(tgfDocumentConverter.to(node)).toContain('a NodeEdit'); + }); + it('allows changes outside unchanged unkeyed edge records', () => { + const d = dotDocumentConverter.from(dot); d.source = '// Native comment\n' + dot; d.graph!.edges[0].label = 'GraphEdit'; expect(dotDocumentConverter.to(d)).toContain('label="GraphEdit"'); + const t = tgfDocumentConverter.from(tgf); t.source = tgf.replace('a A', 'a Native'); t.graph!.edges[0].label = 'GraphEdit'; expect(tgfDocumentConverter.to(t)).toContain('a Native'); + const j = graphvizJSONDocumentConverter.from(gv); j.source.vendor = 'Native'; j.graph!.edges[0].label = 'GraphEdit'; expect(graphvizJSONDocumentConverter.to(j).vendor).toBe('Native'); + }); +}); +for (const fixture of xmlCases) describe(`${fixture.name} independently declared native identity`, () => { + it('keeps explicit stable edge edits targeted after insertion/reorder and independent native data edits', () => { + const baseline = fixture.wrap(fixture.getID(fixture.edge, 'original')), document = fixture.codec.from(baseline); + const original = fixture.getID(fixture.edge.replace('Marker="original"', 'Marker="Native"').replace('Marker "original"', 'Marker "Native"').replace('>original<', '>Native<'), 'original'); + document.source = fixture.wrap(fixture.getID(fixture.edge.replace('original', 'inserted'), 'inserted') + original); document.graph!.edges[0].label = 'GraphEdit'; + const output = fixture.codec.to(document); expect(output).toContain('GraphEdit'); expect(output).toContain('inserted'); expect(output).toContain('Native'); + expect(fixture.codec.from(output).graph!.edges.find((edge) => edge.id === 'original')!.label).toBe('GraphEdit'); expect(fixture.codec.from(output).graph!.edges.find((edge) => edge.id === 'inserted')!.label).toBe('Same'); + }); + it('preserves source-only collision edits without attempting a Graph patch', () => { + const baseline = fixture.wrap(fixture.edge), document = fixture.codec.from(baseline), id = document.graph!.edges[0].id; + document.source = fixture.wrap(fixture.getID(fixture.edge.replace('original', 'inserted'), id) + fixture.edge); expect(fixture.codec.to(document)).toBe(document.source); + }); +}); +it('DOT private IDs retain logical edges after foreign insertion', () => { + const baseline = toDOT(createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'original', sourceId: 'a', targetId: 'b', label: 'Same' }] })), document = dotDocumentConverter.from(baseline); + document.source = baseline.replace('a -> b', 'a -> b [label="Same",Marker="inserted"]; a -> b'); document.graph!.edges[0].label = 'GraphEdit'; + const graph = dotDocumentConverter.from(dotDocumentConverter.to(document)).graph!; expect(graph.edges.find((edge) => edge.id === 'original')!.label).toBe('GraphEdit'); expect(graph.edges[0].label).toBe('Same'); +}); +it('Graphviz verified private IDs survive pure _gvid renumbering and retain the original target', () => { + const baseline = toGraphvizJSON(createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'original', sourceId: 'a', targetId: 'b', label: 'Same' }] })), document = graphvizJSONDocumentConverter.from(baseline); + document.source.edges = [{ _gvid: 0, tail: 0, head: 1, label: 'Same', Marker: 'inserted' }, { ...baseline.edges![0], _gvid: 1 }]; document.graph!.edges[0].label = 'GraphEdit'; + const output = graphvizJSONDocumentConverter.to(document); expect(output.edges![0].label).toBe('Same'); expect(output.edges![1].label).toBe('GraphEdit'); +}); +it('does not treat a private header without a verified entity ID as durable identity', () => { + const marker = JSON.stringify({ format: 'stately', version: 1 }).replace(/"/g, '\\"'); + const baseline = dot.replace('Marker="original"', `Marker="original",_stately="${marker}"`); + getAmbiguity(dotDocumentConverter, baseline, baseline.replace('a->b', 'a->b[label="Same",Marker="inserted"];a->b')); +}); +it('GraphML baseline identity cannot come from another graph or namespace', () => { + const fixture = xmlCases[0], baseline = fixture.wrap(fixture.edge).replace('', ''); + getAmbiguity(fixture.codec, baseline, baseline.replace(fixture.edge, fixture.getID(fixture.edge.replace('original', 'inserted'), 'a-b-0') + fixture.edge)); +}); + +const structuredCases: Array<{ name: string; codec: GraphDocumentConverter; getSource(edges: any[]): any; getEdge(marker: string, id?: string): any }> = [ + { name: 'D3', codec: d3DocumentConverter, getSource: (edges: any[]) => ({ nodes: [{ id: 'a' }, { id: 'b' }], links: edges }), getEdge: (marker: string, id?: string) => ({ source: 'a', target: 'b', label: 'Same', Marker: marker, ...(id && { id }) }) }, + { name: 'Cytoscape', codec: cytoscapeDocumentConverter, getSource: (edges: any[]) => ({ elements: { nodes: [{ data: { id: 'a' } }, { data: { id: 'b' } }], edges } }), getEdge: (marker: string, id?: string) => ({ data: { source: 'a', target: 'b', label: 'Same', Marker: marker, ...(id && { id }) } }) }, +]; +for (const fixture of structuredCases) describe(`${fixture.name} declared/generated edge lineage`, () => { + it('rejects newly explicit IDs colliding with a generated baseline edge', () => { + const baseline = fixture.getSource([fixture.getEdge('original')]); getAmbiguity(fixture.codec, baseline, fixture.getSource([fixture.getEdge('inserted', 'e0'), fixture.getEdge('original')])); + }); + it('rejects idless source reordering even when endpoint and label fields are unchanged', () => { + getAmbiguity(fixture.codec, fixture.getSource([fixture.getEdge('original'), fixture.getEdge('second')]), fixture.getSource([fixture.getEdge('second'), fixture.getEdge('original')])); + }); + it('allows unchanged idless records and stable declared IDs across native insertion/data updates', () => { + const unchanged = fixture.codec.from(fixture.getSource([fixture.getEdge('original')])); unchanged.graph!.edges[0].label = 'GraphEdit'; expect(fixture.codec.from(fixture.codec.to(unchanged)).graph!.edges[0].label).toBe('GraphEdit'); + const baseline = fixture.getSource([fixture.getEdge('original', 'original')]), document = fixture.codec.from(baseline); + document.source = fixture.getSource([fixture.getEdge('inserted', 'inserted'), fixture.getEdge('Native', 'original')]); document.graph!.edges[0].label = 'GraphEdit'; + const graph = fixture.codec.from(fixture.codec.to(document)).graph!; expect(graph.edges.find((edge) => edge.id === 'original')!.label).toBe('GraphEdit'); expect(graph.edges.find((edge) => edge.id === 'inserted')!.label).toBe('Same'); + }); + it('retains source-only updates without applying generated edge edits', () => { + const document = fixture.codec.from(fixture.getSource([fixture.getEdge('original')])); document.source = fixture.getSource([fixture.getEdge('inserted', 'e0'), fixture.getEdge('original')]); expect(fixture.codec.to(document)).toEqual(document.source); + }); +}); + +it('rejects stale/duplicate DOT private declarations rather than trusting an earlier marker', () => { + const encoded = JSON.stringify({ format: 'stately', version: 1, id: 'e0' }).replace(/"/g, '\\"'); + const baseline = dot.replace('Marker="original"', `Marker="original",_stately="${encoded}",_stately="invalid"`); + getAmbiguity(dotDocumentConverter, baseline, baseline.replace('a->b', 'a->b[label="Same",Marker="inserted"];a->b')); + const original = dot.replace('Marker="original"', `Marker="original",_stately="${encoded}"`), duplicate = original.replace('}', `a->b[label="Same",_stately="${encoded}"];}`), document = dotDocumentConverter.from(duplicate); + expect(document.graph).toBeNull(); expect(document.diagnostics.some((item) => item.code === 'projection-unavailable')).toBe(true); +}); +it('rejects duplicate or header-only Graphviz private identities', () => { + const baseline = toGraphvizJSON(createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'original', sourceId: 'a', targetId: 'b', label: 'Same' }] })); + const duplicate = graphvizJSONDocumentConverter.from(baseline); duplicate.source.edges = [{ ...baseline.edges![0], _gvid: 0 }, { ...baseline.edges![0], _gvid: 1 }]; duplicate.graph!.edges[0].label = 'GraphEdit'; expect(() => graphvizJSONDocumentConverter.to(duplicate)).toThrow(); + const header: GraphvizJSON = { ...gv, edges: [{ ...gv.edges![0], _stately: JSON.stringify({ kind: '@statelyai/graph', format: 'graphviz-json.edge', version: 2, original: {}, mapped: {} }) }] }; + getAmbiguity(graphvizJSONDocumentConverter, header, { ...header, edges: [{ ...gv.edges![0], Marker: 'inserted' }, { ...header.edges![0], _gvid: 1 }] }); + const conflicting = JSON.parse(JSON.stringify(baseline)) as GraphvizJSON; conflicting.edges![0]._stately_1 = conflicting.edges![0]._stately; + const document = graphvizJSONDocumentConverter.from(conflicting); document.source.edges = [{ ...gv.edges![0], Marker: 'inserted' }, { ...conflicting.edges![0], _gvid: 1 }]; document.graph!.edges[0].label = 'GraphEdit'; expect(() => graphvizJSONDocumentConverter.to(document)).toThrow(); +}); +it('GML private originalId remains stable while repeated invalid carriers cannot authorize a generated ID', () => { + const fixture = xmlCases[2], marker = '{"format":"stately","version":1,"originalId":"original"}'.replace(/"/g, '"'); + const original = fixture.edge.replace('edge [', `edge [ stately "${marker}"`), document = gmlDocumentConverter.from(fixture.wrap(original)); + document.source = fixture.wrap(fixture.getID(fixture.edge.replace('original', 'inserted'), 'inserted') + original); document.graph!.edges[0].label = 'GraphEdit'; expect(gmlDocumentConverter.from(gmlDocumentConverter.to(document)).graph!.edges.find((edge) => edge.id === 'original')!.label).toBe('GraphEdit'); + const invalid = fixture.edge.replace('edge [', 'edge [ stately "{"format":"stately","version":1,"originalId":"e0"}" stately "invalid"'); + const collision = gmlDocumentConverter.from(fixture.wrap(invalid)); collision.source = fixture.wrap(fixture.getID(fixture.edge.replace('original', 'inserted'), 'e0') + invalid); collision.graph!.edges[0].label = 'GraphEdit'; + const result = gmlDocumentConverter.getUpdate(collision); expect(result.ok).toBe(false); if (!result.ok) expect(result.diagnostics.some((item) => item.code === 'invalid-projection' && item.message.includes('Duplicate edge IDs'))).toBe(true); +}); +for (const fixture of xmlCases) it(`${fixture.name} stable node labels remain editable after edge identity collisions`, () => { + const plain = fixture.wrap(fixture.edge), baseline = fixture.name === 'GraphML' ? plain.replace('', 'Before') : plain; + const document = fixture.codec.from(baseline), generated = document.graph!.edges[0].id; + document.source = baseline.replace(fixture.edge, fixture.getID(fixture.edge.replace('original', 'inserted'), generated) + fixture.edge); document.graph!.nodes[0].label = 'NodeEdit'; + const output = fixture.codec.to(document); expect(fixture.codec.from(output).graph!.nodes[0].label).toBe('NodeEdit'); +}); diff --git a/tests/formats/support-matrix.test.ts b/tests/formats/support-matrix.test.ts index b1f4f5a..235563c 100644 --- a/tests/formats/support-matrix.test.ts +++ b/tests/formats/support-matrix.test.ts @@ -66,6 +66,10 @@ describe('format support matrix', () => { expect(ids).toEqual( expect.arrayContaining([ + 'graphson', + 'dgml', + 'cx2', + 'rdf', 'adjacency-list', 'adjacency-matrix', 'matrix-market', @@ -169,7 +173,7 @@ describe('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', + document: ['d2', 'graphson', 'dgml', 'cx2', 'rdf'].includes(entry.id) || entry.id.startsWith('mermaid/') ? 'partial' : 'none', }); expect(entry.features.roundTrip, entry.id).toBe(entry.preservation?.graph); } diff --git a/tests/graphson.test.ts b/tests/graphson.test.ts new file mode 100644 index 0000000..966f4f8 --- /dev/null +++ b/tests/graphson.test.ts @@ -0,0 +1,445 @@ +import { describe, it, expect } from "vitest"; +import { createGraph } from "../src/graph"; +import { + fromGraphSON, + toGraphSON, + graphsonDocumentConverter, + type GraphSONGraph, +} from "../src/formats/graphson"; +import { getNativeDocumentGraph } from "../src/formats/preservation"; +function getTyped(type: string, value: any): any { + return { "@type": type, "@value": value }; +} +function getPersisted(value: T): T { + return JSON.parse(JSON.stringify(value)); +} +function createNative(): GraphSONGraph { + return getTyped("tinker:graph", { + vertices: [ + getTyped("g:Vertex", { + id: getTyped("g:Int32", 1), + label: "person", + properties: { + name: [ + getTyped("g:VertexProperty", { + id: "p1", + label: "name", + value: "Alpha", + properties: { origin: "native" }, + }), + getTyped("g:VertexProperty", { + id: "p2", + label: "name", + value: "Other", + }), + ], + typed: [ + getTyped("g:VertexProperty", { + id: "p3", + label: "typed", + value: getTyped("g:Map", [ + getTyped("g:Int32", 1), + "integer", + "1", + getTyped("g:Set", ["first", "second"]), + ]), + }), + ], + }, + }), + getTyped("g:Vertex", { + id: "1", + label: "person", + vendor: ["retained", null], + }), + getTyped("g:Vertex", { + id: getTyped("g:Int64", "9007199254740993"), + label: "large", + }), + ], + edges: [ + getTyped("g:Edge", { + id: "e", + outV: getTyped("g:Int32", 1), + inV: "1", + label: "knows", + outVLabel: "person", + inVLabel: "person", + properties: { + weight: getTyped("g:Property", { + key: "weight", + value: getTyped("g:Double", 2), + }), + custom: getTyped("g:Property", { + key: "custom", + value: getTyped("vendor:opaque", { untouched: true }), + }), + }, + }), + ], + vendor: "opaque root", + }); +} +describe("explicit typed GraphSON3 tinker:graph", () => { + it("fresh directed full Graph JSON retains data, hierarchy, ports, geometry, labels and presence via explicit convention", () => { + const graph = createGraph({ + id: "whole", + data: [null, "root"], + nodes: [ + { id: "a", label: null, data: 42, ports: [{ name: "p", data: null }] }, + { + id: "__proto__", + parentId: "a", + label: "Target", + data: { + native: "actual user", + "@type": "g:Int64", + "@value": 9007199254740992, + }, + x: 0, + y: 1, + style: { fill: "red" }, + }, + ], + edges: [ + { + id: "e", + sourceId: "a", + targetId: "__proto__", + label: null, + sourcePort: "p", + weight: 0, + data: ["edge"], + }, + { + id: "loop", + sourceId: "a", + targetId: "a", + mode: "directed", + data: null, + }, + ], + }); + const source = toGraphSON(graph); + expect(source["@type"]).toBe("tinker:graph"); + expect(source["@value"].vertices[0]["@value"].label).toBe("vertex"); + expect( + getPersisted( + getNativeDocumentGraph(fromGraphSON(source), "graphson", source), + ), + ).toEqual(getPersisted(graph)); + const doc = getPersisted(graphsonDocumentConverter.from(source)); + expect(graphsonDocumentConverter.to(doc)).toEqual(source); + doc.graph!.nodes[1].label = "Changed"; + expect( + graphsonDocumentConverter.to(doc)["@value"].edges[0]["@value"].inVLabel, + ).toBe("Changed"); + doc.graph!.nodes[1].data = { native: "changed user" }; + expect( + fromGraphSON(graphsonDocumentConverter.to(doc)).nodes[1].data, + ).toEqual({ native: "changed user" }); + }); + it("complete foreign source/property IDs, multi/meta-properties, typed maps/sets and unknown extensions survive JSON persistence", () => { + const source = createNative(), + doc = getPersisted(graphsonDocumentConverter.from(source)); + expect(doc.graph!.nodes).toHaveLength(3); + expect(new Set(doc.graph!.nodes.map((node) => node.id)).size).toBe(3); + expect(doc.graph!.nodes[0].data.properties.name[0]["@value"].id).toBe("p1"); + expect( + doc.graph!.nodes[0].data.properties.name[0]["@value"].properties.origin, + ).toBe("native"); + expect(graphsonDocumentConverter.to(doc)).toEqual(source); + expect(toGraphSON(fromGraphSON(source))).toEqual(source); + doc.graph!.nodes[0].label = "updated"; + const output = graphsonDocumentConverter.to(doc); + expect(output["@value"].vertices[0]["@value"].properties).toEqual( + source["@value"].vertices[0]["@value"].properties, + ); + expect(output["@value"].edges[0]["@value"].outVLabel).toBe("updated"); + expect(output["@value"].edges[0]["@value"].properties!.custom).toEqual( + source["@value"].edges[0]["@value"].properties!.custom, + ); + }); + it("existing property-value edits preserve property identity, multiplicity, metadata and foreign fields", () => { + const source = createNative(), + doc = graphsonDocumentConverter.from(source); + doc.graph!.nodes[0].data.properties.name[1]["@value"].value = "Updated"; + const output = graphsonDocumentConverter.to(doc); + expect( + output["@value"].vertices[0]["@value"].properties!.name[1]["@value"], + ).toEqual({ id: "p2", label: "name", value: "Updated" }); + expect(output["@value"].vertices[0]["@value"].properties!.name[0]).toEqual( + source["@value"].vertices[0]["@value"].properties!.name[0], + ); + doc.graph!.nodes[0].data.properties.name[0]["@value"].properties.origin = + "edit"; + expect(graphsonDocumentConverter.getUpdate(doc).ok).toBe(false); + }); + it("native source edits are authoritative, independent source reorder/additions and Graph labels coexist, conflicts reject", () => { + const source = createNative(), + doc = graphsonDocumentConverter.from(source); + doc.source["@value"].vertices.reverse(); + doc.source["@value"].vertices.push( + getTyped("g:Vertex", { id: "isolated", label: "isolated", extra: true }), + ); + doc.graph!.nodes[0].label = "Graph"; + const output = graphsonDocumentConverter.to(doc); + expect(output["@value"].vertices.map((node) => node["@value"].id)).toEqual( + doc.source["@value"].vertices.map((node) => node["@value"].id), + ); + expect(output["@value"].vertices[2]["@value"].label).toBe("Graph"); + const conflict = graphsonDocumentConverter.from(source); + conflict.source["@value"].vertices[0]["@value"].label = "Native"; + conflict.graph!.nodes[0].label = "Graph"; + expect(graphsonDocumentConverter.getUpdate(conflict).ok).toBe(false); + }); + it("source JSON data edits override snapshot payload; native weight edits retain separate user data", () => { + const source = toGraphSON( + createGraph({ + nodes: [ + { id: "a", data: 1 }, + { id: "b", data: { old: true } }, + ], + edges: [{ id: "e", sourceId: "a", targetId: "b", data: 42, weight: 2 }], + }), + ); + source["@value"].vertices[0]["@value"].properties!.data[0]["@value"].value = + '{"new":true}'; + source["@value"].vertices[1]["@value"].properties!.data[0]["@value"].value = + "3"; + source["@value"].edges[0]["@value"].properties!.weight["@value"].value[ + "@value" + ] = 4; + const graph = fromGraphSON(source); + expect(graph.nodes.map((node) => node.data)).toEqual([{ new: true }, 3]); + expect(graph.edges[0].weight).toBe(4); + expect(graph.edges[0].data).toBe(42); + expect(toGraphSON(graph)).toEqual(source); + }); + it("added unknown native properties survive label patches without replacing original user payload", () => { + const source = toGraphSON( + createGraph({ nodes: [{ id: "a", data: 42, label: "A" }] }), + ); + source["@value"].vertices[0]["@value"].properties!.extra = [ + getTyped("g:VertexProperty", { + id: "extra", + label: "extra", + value: "retained", + }), + ]; + const doc = graphsonDocumentConverter.from(source); + expect(doc.graph!.nodes[0].data.graphData).toBe(42); + doc.graph!.nodes[0].label = "Changed"; + expect( + graphsonDocumentConverter.to(doc)["@value"].vertices[0]["@value"] + .properties!.extra[0]["@value"].value, + ).toBe("retained"); + doc.graph!.nodes[0].data.graphData = 3; + expect(graphsonDocumentConverter.getUpdate(doc).ok).toBe(false); + }); + it.each([ + ["g:Int32", -2147483648], + ["g:Int64", "-9223372036854775808"], + ["gx:BigInteger", "123456789012345678901234567890123456789"], + ["g:UUID", "12345678-1234-1234-1234-123456789ABC"], + ] as const)( + "exact typed ID %s:%s is not coerced through JavaScript", + (type, value) => { + const source = getTyped("tinker:graph", { + vertices: [ + getTyped("g:Vertex", { id: getTyped(type, value), label: "vertex" }), + ], + edges: [], + }); + const graph = fromGraphSON(source); + expect(graph.nodes[0].id).toContain(type); + expect(toGraphSON(graph)).toEqual(source); + }, + ); + it("aliases in runtime JSON objects serialize by value, while cycles/accessors/symbols/sparse arrays reject", () => { + const id = getTyped("g:Int32", 1), + source = getTyped("tinker:graph", { + vertices: [getTyped("g:Vertex", { id, label: "one" })], + edges: [ + getTyped("g:Edge", { id: "loop", outV: id, inV: id, label: "loop" }), + ], + }); + expect(fromGraphSON(source).edges).toHaveLength(1); + const invalid: any[] = []; + const cyclic = createNative(); + (cyclic as any).cycle = cyclic; + invalid.push(cyclic); + const getter = createNative(); + let called = false; + Object.defineProperty(getter, "hidden", { + enumerable: true, + get() { + called = true; + return 1; + }, + }); + invalid.push(getter); + const symbol = createNative(); + (symbol as any)[Symbol("unknown")] = 1; + invalid.push(symbol); + const nonenum = createNative(); + Object.defineProperty(nonenum, "hidden", { value: 1 }); + invalid.push(nonenum); + const sparse = createNative(); + delete sparse["@value"].vertices[0]; + invalid.push(sparse); + const custom = createNative(); + (custom["@value"].vertices as any).extra = true; + invalid.push(custom); + for (const value of invalid) expect(() => fromGraphSON(value)).toThrow(); + expect(called).toBe(false); + }); + it("invalid matching private protocol is ordinary data; metadata opt-out and native-carrier opt-out are explicit", () => { + const source = toGraphSON( + createGraph({ nodes: [{ id: "a", label: "A" }] }), + ); + const marker = JSON.parse(source["@value"].__statelyai); + marker.original.nodes[0].label = 42; + source["@value"].__statelyai = JSON.stringify(marker); + const graph = fromGraphSON(source); + expect(graph.nodes[0].label).toBe("A"); + expect(toGraphSON(graph)).toEqual(source); + const emitted = toGraphSON( + createGraph({ id: "original", nodes: [{ id: "a", label: null }] }), + ); + const native = fromGraphSON(emitted, { metadata: "native" }); + expect(native.nodes[0].label).toBe("vertex"); + expect(native.id).toBe(""); + expect( + toGraphSON(native, { nativeCarrier: false })["@value"].vertices[0][ + "@value" + ].id, + ).toBe(native.nodes[0].id); + }); + it.each(["mode", "weight", "nodes", "data"] as const)( + "unrepresentable Graph edit %s receives a diagnostic rather than stale source", + (field) => { + const source = toGraphSON( + createGraph({ + nodes: [{ id: "a" }, { id: "b" }], + edges: [{ id: "e", sourceId: "a", targetId: "b", weight: 1 }], + }), + ), + doc = graphsonDocumentConverter.from(source); + if (field === "mode") doc.graph!.mode = "undirected"; + else if (field === "weight") doc.graph!.edges[0].weight = 2; + else if (field === "nodes") doc.graph!.nodes.reverse(); + else doc.graph!.data = { root: "changed" }; + expect(graphsonDocumentConverter.getUpdate(doc).ok).toBe(false); + expect(() => graphsonDocumentConverter.to(doc)).toThrow(); + }, + ); + it.each([ + null, + 1, + { "@type": "g:Graph", "@value": {} }, + { vertices: [], edges: [] }, + { + "@type": "tinker:graph", + "@value": { vertices: [], edges: [] }, + bad: undefined, + }, + ])("unsupported profile/non-JSON source rejects %#", (source) => + expect(() => fromGraphSON(source as any)).toThrow(), + ); + it.each(["version", "profile"] as const)( + "unsupported explicit %s rejects on import and export", + (key) => { + const options = + key === "version" ? { version: 2 } : { profile: "adjacency" }; + expect(() => fromGraphSON(createNative(), options as any)).toThrow( + "profile", + ); + expect(() => toGraphSON(createGraph({}), options as any)).toThrow( + "profile", + ); + }, + ); + it.each([ + getTyped("g:Int64", 9007199254740992), + getTyped("g:Int64", "9223372036854775808"), + getTyped("g:Int32", 2147483648), + getTyped("gx:BigInteger", 9007199254740992), + getTyped("g:UUID", "invalid"), + null, + 1, + true, + getTyped("vendor:id", "one"), + ])("unsupported/malformed exact identity rejects %#", (id) => { + const source = getTyped("tinker:graph", { + vertices: [getTyped("g:Vertex", { id, label: "v" })], + edges: [], + }); + expect(() => fromGraphSON(source)).toThrow(); + const doc = graphsonDocumentConverter.from(source); + expect(doc.graph).toBeNull(); + expect(graphsonDocumentConverter.to(getPersisted(doc))).toEqual(source); + }); + it("typed duplicate/dangling/property collisions and malformed collections reject", () => { + const cases: any[] = []; + const duplicate = createNative(); + duplicate["@value"].vertices.push( + getPersisted(duplicate["@value"].vertices[0]), + ); + cases.push(duplicate); + const edge = createNative(); + edge["@value"].edges.push(getPersisted(edge["@value"].edges[0])); + cases.push(edge); + const dangling = createNative(); + dangling["@value"].edges[0]["@value"].inV = "absent"; + cases.push(dangling); + const properties = createNative(); + properties["@value"].vertices[0]["@value"].properties!.name[1][ + "@value" + ].id = "p1"; + cases.push(properties); + const mismatch = createNative(); + mismatch["@value"].vertices[0]["@value"].properties!.name[0][ + "@value" + ].label = "wrong"; + cases.push(mismatch); + const map = createNative(); + map["@value"].vertices[0]["@value"].properties!.typed[0]["@value"].value[ + "@value" + ].pop(); + cases.push(map); + for (const source of cases) expect(() => fromGraphSON(source)).toThrow(); + }); + it("native graph mutation never mutates imported source; opaque unavailable documents replay exact JSON", () => { + const source = createNative(), + before = getPersisted(source), + graph = fromGraphSON(source); + graph.nodes[0].data.properties.name[0]["@value"].value = "graph mutation"; + expect(source).toEqual(before); + const unsupported = getTyped("g:Graph", { untouched: ["opaque", null] }), + doc = graphsonDocumentConverter.from(unsupported); + expect(doc.graph).toBeNull(); + expect(graphsonDocumentConverter.to(getPersisted(doc))).toEqual( + unsupported, + ); + }); + it.each(["undirected", "bidirectional"] as const)( + "fresh %s mode rejects; empty labels and unsafe JSON controls reject", + (mode) => { + expect(() => toGraphSON(createGraph({ mode }))).toThrow("directed"); + expect(() => + toGraphSON(createGraph({ nodes: [{ id: "a", label: "" }] })), + ).toThrow("nonempty"); + expect(() => toGraphSON(createGraph({ data: -0 }))).toThrow( + "signed-zero", + ); + }, + ); +}); + +it('requires authored identities and retains an empty native string ID in its encoded namespace', () => { + const source = getTyped('tinker:graph', {vertices:[getTyped('g:Vertex',{id:'',label:'Empty ID'})],edges:[]}); + const graph = fromGraphSON(source); expect(graph.nodes[0].id).not.toBe(''); expect(toGraphSON(graph)).toEqual(source); + delete source['@value'].vertices[0]['@value'].id; + expect(() => fromGraphSON(source)).toThrow('missing own id'); +}); diff --git a/tests/native/cx2-oracle.py b/tests/native/cx2-oracle.py new file mode 100644 index 0000000..426915f --- /dev/null +++ b/tests/native/cx2-oracle.py @@ -0,0 +1,18 @@ +"""Development-only independent NDEx CX2 reader; bounded by the caller.""" +import json +import sys +from importlib.metadata import version +from ndex2.cx2 import CX2Network + +if version('ndex2') != '3.12.0': + raise RuntimeError('Expected ndex2==3.12.0') +source = json.load(sys.stdin) +network = CX2Network() +network.create_from_raw_cx2(source) +json.dump({'oracle': 'ndex2', 'version': version('ndex2'), + 'nodes': list(network.get_nodes().values()), + 'edges': list(network.get_edges().values()), + 'attributes': network.get_network_attributes(), + 'declarations': network.get_attribute_declarations(), + 'opaque': network.get_opaque_aspects(), + 'status': network.get_status()}, sys.stdout, allow_nan=False) diff --git a/tests/native/cx2-oracle.ts b/tests/native/cx2-oracle.ts new file mode 100644 index 0000000..e32077b --- /dev/null +++ b/tests/native/cx2-oracle.ts @@ -0,0 +1,32 @@ +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import type { CX2JSON } from '../../src/formats/cx2'; +export function getCX2Oracle(source: CX2JSON): { + oracle: string; + version: string; + nodes: any[]; + edges: any[]; + attributes: any; + declarations: any; + opaque: any; + status: any; +} { + const result = spawnSync( + process.env.GRAPH_NATIVE_PYTHON ?? resolve('.venv-native/bin/python'), + [resolve('tests/native/cx2-oracle.py')], + { + input: JSON.stringify(source), + encoding: 'utf8', + timeout: 10000, + maxBuffer: 16 * 1024 * 1024, + } + ); + if (result.error || result.status !== 0) + throw new Error( + `NDEx CX2 oracle failed: ${result.error?.message ?? result.stderr}` + ); + const output = JSON.parse(result.stdout); + if (output.oracle !== 'ndex2' || output.version !== '3.12.0') + throw new Error('NDEx CX2 oracle pin changed'); + return output; +} diff --git a/tests/native/cx2.test.ts b/tests/native/cx2.test.ts new file mode 100644 index 0000000..bb7622b --- /dev/null +++ b/tests/native/cx2.test.ts @@ -0,0 +1,89 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { expect, it } from 'vitest'; +import { + fromCX2, + toCX2, + cx2DocumentConverter, + type CX2JSON, +} from '../../src/formats/cx2'; +import fixture from './fixtures/cx2/profile.json'; +import provenance from './fixtures/cx2/provenance.json'; +import { getCX2Oracle } from './cx2-oracle'; +it('independent maintained NDEx reader preserves selected aliases/defaults/typed topology', () => { + expect( + createHash('sha256') + .update( + readFileSync(new URL('./fixtures/cx2/profile.json', import.meta.url)) + ) + .digest('hex') + ).toBe(provenance.sha256); + const source = fixture as CX2JSON, + native = getCX2Oracle(source), + graph = fromCX2(source); + expect(native.version).toBe('3.12.0'); + expect(native.nodes[0].v).toEqual({ + score: 2.5, + name: '東京 "quoted" \\', + alias: ['A', 'B'], + }); + expect(native.edges.map((e) => [e.id, e.s, e.t])).toEqual([ + [2, 0, 1], + [3, 0, 1], + [4, 1, 1], + ]); + expect(graph.nodes[0].label).toBe(native.nodes[0].v.name); + expect(graph.nodes[0].data.attributes).toEqual({ + score: 2.5, + alias: ['A', 'B'], + }); + expect(getCX2Oracle(toCX2(graph))).toEqual(native); +}); +it('fresh emitted values and strict document label/attribute edits reach the native reader', () => { + const source = fixture as CX2JSON; + const regenerated = toCX2(cx2DocumentConverter.from(source).graph!, { nativeCarrier: false }); + expect(getCX2Oracle(regenerated).nodes[0].v).toEqual( + getCX2Oracle(source).nodes[0].v + ); + const doc = cx2DocumentConverter.from(source); + doc.graph!.nodes[0].label = 'Changed 東京'; + doc.graph!.nodes[0].data.attributes!.score = 8; + const output = cx2DocumentConverter.to(doc), + native = getCX2Oracle(output); + expect(native.nodes[0].v).toMatchObject({ name: 'Changed 東京', score: 8 }); + expect(output[5]).toEqual(source[5]); +}); +it('records NDEx3.12 false/zero-default omission as upstream-negative controls', () => { + const source: CX2JSON = [ + { CXVersion: '2.0' }, + { + attributeDeclarations: [ + { + nodes: { + active: { d: 'boolean', v: false }, + score: { d: 'double', v: 0 }, + }, + }, + ], + }, + { nodes: [{ id: 1 }] }, + { status: [{ success: true }] }, + ]; + expect(getCX2Oracle(source).nodes[0].v).toEqual({}); + expect(fromCX2(source).nodes[0].data.attributes).toEqual({ + active: false, + score: 0, + }); +}); +it('records native-reader null omission without claiming null interpretation', () => { + const source: CX2JSON = [ + { CXVersion: '2.0' }, + { attributeDeclarations: [{ nodes: { score: { d: 'double' } } }] }, + { nodes: [{ id: 1, v: { score: null } }] }, + { status: [{ success: true }] }, + ]; + expect(getCX2Oracle(source).nodes[0].v).toEqual({}); + const document = cx2DocumentConverter.from(source); + expect(document.graph).toBeNull(); + expect(cx2DocumentConverter.to(document)).toEqual(source); +}); diff --git a/tests/native/dgml.test.ts b/tests/native/dgml.test.ts new file mode 100644 index 0000000..499a64d --- /dev/null +++ b/tests/native/dgml.test.ts @@ -0,0 +1,95 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { expect, it } from 'vitest'; +import { validateXML } from 'xmllint-wasm'; +import { + fromDGML, + toDGML, + dgmlDocumentConverter, +} from '../../src/formats/dgml'; +import { createGraph } from '../../src/graph'; +import provenance from './fixtures/dgml/provenance.json'; +const bytes = readFileSync( + new URL('./fixtures/dgml/dgml.xsd', import.meta.url) +); +async function getValidation(source: string) { + expect(createHash('sha256').update(bytes).digest('hex')).toBe( + provenance.sha256 + ); + return validateXML({ + xml: { fileName: 'profile.dgml', contents: source }, + schema: { fileName: 'dgml.xsd', contents: bytes.toString('utf8') }, + modifyArguments: (args) => ['--nonet', ...args], + }); +} +it('official schema accepts native groups/multicategories/implicit endpoints and emitted directed topology', async () => { + const source = + ''; + expect((await getValidation(source)).valid).toBe(true); + const graph = fromDGML(source); + expect(graph.nodes.map((n) => [n.id, n.parentId])).toEqual([ + ['g', null], + ['a', 'g'], + ['implicit', null], + ]); + expect(graph.nodes[1].data.categories).toEqual(['Thing', 'Other']); + const fresh = createGraph({ + nodes: [ + { id: 'a', label: '東京 " \\ &\r\n' }, + { id: 'b', label: '' }, + ], + edges: [ + { id: 'dgml_link_0', sourceId: 'a', targetId: 'b', label: 'Go' }, + { id: 'dgml_link_1', sourceId: 'a', targetId: 'b', label: 'Parallel' }, + { id: 'dgml_link_2', sourceId: 'b', targetId: 'b', label: 'Loop' }, + ], + }); + const emitted = toDGML(fresh); + expect((await getValidation(emitted)).valid).toBe(true); + expect(fromDGML(emitted).nodes[0].label).toBe(fresh.nodes[0].label); + expect(fromDGML(emitted).edges.map((e) => e.id)).toEqual( + fresh.edges.map((e) => e.id) + ); +}); +it('schema validates literal text-span edits with untouched comments and namespaces', async () => { + const source = + ''; + const doc = dgmlDocumentConverter.from(source); + doc.graph!.nodes[0].label = 'After 東京 " &\r\n'; + const patched = dgmlDocumentConverter.to(doc); + expect(patched).toContain(''); + expect((await getValidation(patched)).valid).toBe(true); + expect(fromDGML(patched).nodes[0].label).toBe(doc.graph!.nodes[0].label); +}); +it('characterizes published schema custom-property gap without claiming renderer proof', async () => { + const source = + ''; + const result = await getValidation(source); + expect(result.valid).toBe(false); + expect(result.rawOutput).toContain('Count'); + expect(fromDGML(source).nodes[0].data.properties).toEqual({ Count: 2 }); +}); +it('official schema rejects malformed Group values independently', async () => { + expect( + ( + await getValidation( + '' + ) + ).valid + ).toBe(false); +}); + +it('native DOM XML decoding agrees on attribute whitespace and character references', async () => { + const source = + ''; + expect((await getValidation(source)).valid).toBe(true); + const { JSDOM } = await import('jsdom'); + const { window } = new JSDOM(); + const native = new window.DOMParser().parseFromString(source, 'application/xml'); + expect(native.querySelector('parsererror')).toBeNull(); + expect(fromDGML(source).nodes[0].label).toBe( + native.getElementsByTagName('Node')[0].getAttribute('Label') + ); + expect(fromDGML(source).nodes[0].label).toBe('A B C\nD\rE'); + window.close(); +}); diff --git a/tests/native/fixtures/cx2/profile.json b/tests/native/fixtures/cx2/profile.json new file mode 100644 index 0000000..a81868f --- /dev/null +++ b/tests/native/fixtures/cx2/profile.json @@ -0,0 +1,103 @@ +[ + { + "CXVersion": "2.0", + "hasFragments": false + }, + { + "attributeDeclarations": [ + { + "nodes": { + "name": { + "d": "string", + "a": "n" + }, + "score": { + "d": "double", + "v": 2.5 + }, + "alias": { + "d": "list_of_string" + } + }, + "edges": { + "name": { + "d": "string" + }, + "confidence": { + "d": "double" + } + }, + "networkAttributes": { + "name": { + "d": "string" + } + } + } + ] + }, + { + "networkAttributes": [ + { + "name": "Native fixture" + } + ] + }, + { + "nodes": [ + { + "id": 0, + "v": { + "n": "東京 \"quoted\" \\", + "alias": [ + "A", + "B" + ] + } + }, + { + "id": 1, + "v": { + "n": "", + "score": 4 + } + } + ] + }, + { + "edges": [ + { + "id": 2, + "s": 0, + "t": 1, + "v": { + "name": "Go", + "confidence": 0.7 + } + }, + { + "id": 3, + "s": 0, + "t": 1 + }, + { + "id": 4, + "s": 1, + "t": 1 + } + ] + }, + { + "vendor": [ + { + "opaque": true + } + ] + }, + { + "status": [ + { + "success": true + } + ] + } +] diff --git a/tests/native/fixtures/cx2/provenance.json b/tests/native/fixtures/cx2/provenance.json new file mode 100644 index 0000000..383300b --- /dev/null +++ b/tests/native/fixtures/cx2/provenance.json @@ -0,0 +1,10 @@ +{ + "oracle": "ndex2@3.12.0", + "retrieved": "2026-10-12", + "specification": "https://cytoscape.org/cx/cx2/specification/cytoscape-exchange-format-specification-(version-2)/", + "specificationUpdated": "2025-07-22", + "reader": "https://github.com/ndexbio/ndex2-client", + "fixture": "profile.json", + "sha256": "c70064991117e51c0606eba7b3f3331a592efc9cf992f0b8fe0f14f30472de30", + "provenance": "Task-authored fixture; exact maintained reader assertions cover selected typed attributes/topology. Reader false/zero-default omission is characterized separately." +} diff --git a/tests/native/fixtures/dgml/dgml.xsd b/tests/native/fixtures/dgml/dgml.xsd new file mode 100644 index 0000000..eeb0f7f --- /dev/null +++ b/tests/native/fixtures/dgml/dgml.xsd @@ -0,0 +1,913 @@ + + + + + + + The human readable label on the node + + + + + The visibility of the node. A node can be visible, hidden or collapsed. + + + + + Background color of the object + + + + + Controls size of the label + + + + + Controls the type of font + + + + + Controls the style of font + + + + + Controls the weight of font + + + + + The color of the outline pen + + + + + The thickness of the outline pen + + + + + The dash style of the outline pen, for example the value '2,2' gives a link that has evenly sized dashes + + + + + + + + + Path to an icon for the node + + + + + Shape of the node + + + + + Style for the node + + + + + Controls how the icon and label are aligned + + + + + Controls how the icon and label are aligned + + + + + The minimum width of the node, default is 50 + + + + + The maximum width of the node, this makes it possible to create multiline text labels + + + + + The corner radius on the nodes, default is 15 + + + + + + + + + + + + A DirectedGraph document contains nodes and links, where each link has a source and target node. + Nodes and links can also contain categories and properties that provide more information about them. + + + + + + + The collection of nodes in the graph + + + + + + A node in the graph + + + + + + If you have more than one category on a node the second and subsequent categories are written as child elements. + + + + + + + + + The unique identifier of the node + + + + + The category that further describes the object. You can have multiple categories defined as child elements. + + + + + + A description of the node which will show in the tooltip + + + + + This attribute indicates that the node is to be displayed as a group with all children. The group can be expanded or collapsed. + + + + + A flag indicating whether to layout the icon and label vertically + + + + + A link to an external DGML document + + + + + + + + + + + + + + + + + + If you have more than one category on a link the second and subsequent categories are written as child elements. + + + + + + + + + + The source node of the directed link. If this node is not defined then one will be created on the fly. + + + + + The target node of the directed link. If this node is not defined then one will be created on the fly. + + + + + The category that further describes the object. You can have multiple categories defined as child elements. + + + + + + + + + + + + + + + + + + The unique identifier of the category + + + + + Categories support inheritance so that graph objects can share properties of inherited categories + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The unique identifier of the property + + + + + The property is a reference property + + + + + The human readable label for the property + + + + + The CLR data type of the property + + + + + A human readable description of the property + + + + + The group label for properties when grouped in the property window + + + + + The string format for resolving the full path of a reference property + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The conditional styles + + + + + + A conditional style can apply UI settings to a matching set of nodes or links based on a conditional expression + + + + + + The condition element contains the conditoinal expression + + + + + + The conditional expression will be executed against the target type of the style if must return a boolean. + If it returns true then the setters will apply to the matching graph objects. + The expression can access properties on the graph objects and static methods of the .NET Math, String and Color classes. + For example, the expression 'LinesOfCode > 10' will match all nodes that have a LinesOfCode property with a value greater than 10. + + + + + + + + A setter can set a UI property of the matching graph objects + + + + + The UI property being set + + + + + The value of the property being set. This property is mutually exclusive to the Expression attribute + + + + + + The setter Expression attribute is mutually exclusive to the Value attribute and allows a simple expression + to be calulated for the property being set. For example the expression 'Math.Max(9,Math.Sqrt(LinesOfCode))' + calculates a number based on the LinesOfCode property equal to the square root of that property, and returns + no less than 9. This result could then be assigned to the FontSize property to achieve node sizing based on code size. + + + + + + + + + The target type of the conditional style, this controls what type of objects the conditional expressions will be applied to + + + + + Whether the style is currently enabled (checked in the Graph Legend) + + + + + Label to show on the Legend + + + + + Label to use in the style picker dialogs + + + + + A tooltip to explain what this style represents + + + + + + + + + + The set of special paths used in the DGML document + + + + + + + A special path defines a common location that many graph objects can refer to. + For example you might see something like $(SolutionDir) in the DGML document which will be defined + as the location of the solution from which the DGML data was derrived. + + + + + + The unique id of the common path + + + + + + The default value of the common path if it is not already defined in the hosting process. + Normally the hosting process will define these common paths, then re-wire the data when it is loaded. + For example, $(SolutionDir) will be re-written to the path of the currently loaded solution in Visual Studio. + These common paths help make DGML documents more portable. + + + + + + + + + + + + The title of the graph + + + + + The background color for the entire graph + + + + + A background image for the graph + + + + + The direction for the graph layout + + + + + The type of graph layout + + + + + Whether graph is in butterfly mode or not + + + + + The neighborhood distance + + + + + The zoom level to show graph initially as a floating point number where 1 means 100%, or the word "Fit" to fit the graph to the current window size + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + This category or property is only used for documentation + + + + + Depth in the call stack (distance from the originating node) + + + + + Depth in the call stack (distance from the originating node) + + + + + Generate reverse link code + + + + + Execution sequence of the call from the beginning of the originating node + + + + + Flag which disables enabled error handler in the current procedure and resets it to Nothing. + + + + + Flag which disables enabled exception in the current procedure and resets it to Nothing. + + + + + Last column number on the last line for the statement + + + + + Last line for the statement + + + + + depth in the call stack (distance from the originating node) + + + + + Represents a single frame in the system. This can be a method call or just a control flow frame such as an if statement or loop that is part of the method + + + + + Icon for the category. + + + + + Instance tracking information + + + + + Flag to indicate member is 'Abstract' does not provide a full implementation + + + + + Flag indicating if this delegate or type is anonymous + + + + + Flag indicating that a type node represents an array of some other type + + + + + Flag indicating if the parameter takes a reference to the argument + + + + + Flag to indicate the that the method call is going to another method in this object + + + + + Flag to indicate the method is a constructor + + + + + Flag indicating the statement is a Do loop (which is also either a while or until loop) + + + + + Flag to indicate the member is 'Final' and cannot be derived from + + + + + Flag indicating the statement is a For loop + + + + + Flag indicating the statement is a For Each statement + + + + + Flag to indicate the member is 'Generic' + + + + + Flag to indicate the member is an instantiation of a 'Generic' + + + + + Flag to indicate the method is 'Internal' + + + + + Flag to indicate the method hides another method of the same signature + + + + + Flag indicating if this method defines a new operator + + + + + Flag indicating the parameter is an 'out' parameter + + + + + Flag indicating if the parameter is a 'params' or 'paramarray' definition + + + + + Flag to indicate the scope is Private + + + + + Flag to indicate the scope is Protected + + + + + Flag to indicate the scope is Protected or Internal + + + + + Flag to indicate the frame is a Property Get + + + + + Flag to indicate the frame is a Property Set + + + + + Flag to indicate the member is a prototype + + + + + Flag to indicate the scope is Public + + + + + Flag to indicate the member has a compiler generated name + + + + + Flag to indicate the method is a static member + + + + + Flag indicating the GuardExpression needs to evaluate to True to break out of the loop + + + + + Flag to indicate the method can be overridden + + + + + Flag indicating the statement is a While loop + + + + + Flag used to preserve the data in the existing array when you change the size of only the last dimension. + + + + + The link contains only one instance, ever + + + + + The link contains only one instance, ever + + + + + The source text associated with the graph node + + + + + Starting column number for the statement + + + + + Starting line number for the statement + + + + + Starting line number for the statement + + + + + Starting line number for the statement + + + + + Starting line number for the statement + + + + + + + + + Flag used by group by patterns + + + + + Flag used by group by patterns + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/native/fixtures/dgml/provenance.json b/tests/native/fixtures/dgml/provenance.json new file mode 100644 index 0000000..f0c6392 --- /dev/null +++ b/tests/native/fixtures/dgml/provenance.json @@ -0,0 +1,9 @@ +{ + "entry": "dgml.xsd", + "oracle": "Microsoft DGML XSD through xmllint-wasm@5.3.0", + "retrieved": "2026-10-12", + "source": "https://schemas.microsoft.com/vs/2009/dgml/dgml.xsd", + "documentation": "https://learn.microsoft.com/en-us/visualstudio/modeling/directed-graph-markup-language-dgml-reference?view=visualstudio", + "license": "Official Microsoft-published schema; no license notice embedded. Schema retained unmodified, including CRLF.", + "sha256": "9ea61dbc18534d37eed9c0fe994fd9c171a5928dff2619aa1abc251a287e7b1a" +} diff --git a/tests/native/fixtures/graphson/modern.json b/tests/native/fixtures/graphson/modern.json new file mode 100644 index 0000000..e57c1cb --- /dev/null +++ b/tests/native/fixtures/graphson/modern.json @@ -0,0 +1,441 @@ +{ + "@type": "tinker:graph", + "@value": { + "vertices": [ + { + "@type": "g:Vertex", + "@value": { + "id": { + "@type": "g:Int32", + "@value": 1 + }, + "label": "person", + "properties": { + "name": [ + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 0 + }, + "value": "marko", + "label": "name" + } + } + ], + "age": [ + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 1 + }, + "value": { + "@type": "g:Int32", + "@value": 29 + }, + "label": "age" + } + } + ] + } + } + }, + { + "@type": "g:Vertex", + "@value": { + "id": { + "@type": "g:Int32", + "@value": 2 + }, + "label": "person", + "properties": { + "name": [ + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 2 + }, + "value": "vadas", + "label": "name" + } + } + ], + "age": [ + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 3 + }, + "value": { + "@type": "g:Int32", + "@value": 27 + }, + "label": "age" + } + } + ] + } + } + }, + { + "@type": "g:Vertex", + "@value": { + "id": { + "@type": "g:Int32", + "@value": 3 + }, + "label": "software", + "properties": { + "name": [ + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 4 + }, + "value": "lop", + "label": "name" + } + } + ], + "lang": [ + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 5 + }, + "value": "java", + "label": "lang" + } + } + ] + } + } + }, + { + "@type": "g:Vertex", + "@value": { + "id": { + "@type": "g:Int32", + "@value": 4 + }, + "label": "person", + "properties": { + "name": [ + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 6 + }, + "value": "josh", + "label": "name" + } + } + ], + "age": [ + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 7 + }, + "value": { + "@type": "g:Int32", + "@value": 32 + }, + "label": "age" + } + } + ] + } + } + }, + { + "@type": "g:Vertex", + "@value": { + "id": { + "@type": "g:Int32", + "@value": 5 + }, + "label": "software", + "properties": { + "name": [ + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 8 + }, + "value": "ripple", + "label": "name" + } + } + ], + "lang": [ + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 9 + }, + "value": "java", + "label": "lang" + } + } + ] + } + } + }, + { + "@type": "g:Vertex", + "@value": { + "id": { + "@type": "g:Int32", + "@value": 6 + }, + "label": "person", + "properties": { + "name": [ + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 10 + }, + "value": "peter", + "label": "name" + } + } + ], + "age": [ + { + "@type": "g:VertexProperty", + "@value": { + "id": { + "@type": "g:Int64", + "@value": 11 + }, + "value": { + "@type": "g:Int32", + "@value": 35 + }, + "label": "age" + } + } + ] + } + } + } + ], + "edges": [ + { + "@type": "g:Edge", + "@value": { + "id": { + "@type": "g:Int32", + "@value": 7 + }, + "label": "knows", + "inVLabel": "person", + "outVLabel": "person", + "inV": { + "@type": "g:Int32", + "@value": 2 + }, + "outV": { + "@type": "g:Int32", + "@value": 1 + }, + "properties": { + "weight": { + "@type": "g:Property", + "@value": { + "key": "weight", + "value": { + "@type": "g:Double", + "@value": 0.5 + } + } + } + } + } + }, + { + "@type": "g:Edge", + "@value": { + "id": { + "@type": "g:Int32", + "@value": 8 + }, + "label": "knows", + "inVLabel": "person", + "outVLabel": "person", + "inV": { + "@type": "g:Int32", + "@value": 4 + }, + "outV": { + "@type": "g:Int32", + "@value": 1 + }, + "properties": { + "weight": { + "@type": "g:Property", + "@value": { + "key": "weight", + "value": { + "@type": "g:Double", + "@value": 1 + } + } + } + } + } + }, + { + "@type": "g:Edge", + "@value": { + "id": { + "@type": "g:Int32", + "@value": 9 + }, + "label": "created", + "inVLabel": "software", + "outVLabel": "person", + "inV": { + "@type": "g:Int32", + "@value": 3 + }, + "outV": { + "@type": "g:Int32", + "@value": 1 + }, + "properties": { + "weight": { + "@type": "g:Property", + "@value": { + "key": "weight", + "value": { + "@type": "g:Double", + "@value": 0.4 + } + } + } + } + } + }, + { + "@type": "g:Edge", + "@value": { + "id": { + "@type": "g:Int32", + "@value": 10 + }, + "label": "created", + "inVLabel": "software", + "outVLabel": "person", + "inV": { + "@type": "g:Int32", + "@value": 5 + }, + "outV": { + "@type": "g:Int32", + "@value": 4 + }, + "properties": { + "weight": { + "@type": "g:Property", + "@value": { + "key": "weight", + "value": { + "@type": "g:Double", + "@value": 1 + } + } + } + } + } + }, + { + "@type": "g:Edge", + "@value": { + "id": { + "@type": "g:Int32", + "@value": 11 + }, + "label": "created", + "inVLabel": "software", + "outVLabel": "person", + "inV": { + "@type": "g:Int32", + "@value": 3 + }, + "outV": { + "@type": "g:Int32", + "@value": 4 + }, + "properties": { + "weight": { + "@type": "g:Property", + "@value": { + "key": "weight", + "value": { + "@type": "g:Double", + "@value": 0.4 + } + } + } + } + } + }, + { + "@type": "g:Edge", + "@value": { + "id": { + "@type": "g:Int32", + "@value": 12 + }, + "label": "created", + "inVLabel": "software", + "outVLabel": "person", + "inV": { + "@type": "g:Int32", + "@value": 3 + }, + "outV": { + "@type": "g:Int32", + "@value": 6 + }, + "properties": { + "weight": { + "@type": "g:Property", + "@value": { + "key": "weight", + "value": { + "@type": "g:Double", + "@value": 0.2 + } + } + } + } + } + } + ] + } +} diff --git a/tests/native/fixtures/graphson/provenance.json b/tests/native/fixtures/graphson/provenance.json new file mode 100644 index 0000000..d81adef --- /dev/null +++ b/tests/native/fixtures/graphson/provenance.json @@ -0,0 +1,7 @@ +{ + "producer": "Apache TinkerPop TinkerFactory.createModern() via GraphSONMapper V3_0 +TinkerIoRegistryV3 +GraphSONXModuleV3", + "version": "3.8.2", + "source": "https://github.com/apache/tinkerpop/blob/3.8.2/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/TinkerFactory.java", + "license": "Apache-2.0", + "sha256": "f678fc9e890591c0e94e90e02ea1a2143489f07add58ea54a1c41f9c8eabe96a" +} diff --git a/tests/native/fixtures/rdf/provenance.json b/tests/native/fixtures/rdf/provenance.json new file mode 100644 index 0000000..019d04d --- /dev/null +++ b/tests/native/fixtures/rdf/provenance.json @@ -0,0 +1,29 @@ +{ + "retrieved": "2026-10-12", + "profile": "RDF 1.1 N-Triples/N-Quads/Turtle/TriG", + "parser": "n3 2.13.12", + "oracle": "RDFLib 7.6.0 actual parsers and rdflib.compare.isomorphic", + "sources": [ + "https://www.w3.org/TR/rdf11-concepts/", + "https://www.w3.org/TR/turtle/", + "https://www.w3.org/TR/trig/", + "https://www.w3.org/TR/n-triples/", + "https://www.w3.org/TR/n-quads/", + "https://github.com/rdfjs/N3.js", + "https://rdflib.readthedocs.io/en/stable/apidocs/rdflib.compare/" + ], + "fixtures": [ + { + "file": "scoped.trig", + "origin": "Authored fixture, MIT repository license; parsed independently, not an official W3C test copy", + "sha256": "50d76c60686c70a46752e411da875219ac9a8cbb632fbabea589311b87b2b01f" + } + ], + "limitations": [ + "RDFLib Memory store omits empty named graphs; the actual native RDFSink.newGraph callback is observed before storage.", + "RDFLib store equality folds language tag case; public Graph/ConjunctiveGraph.add events are observed before that collapse; RDFSink.newGraph and Graph/ConjunctiveGraph.add are observed without modifying reader grammar or native term equality. Exact lexical/tag descriptors feed independent graph isomorphism.", + "RDFLib literal normalization disabled to preserve explicit numeric lexical forms. Turtle numeric shorthand may already normalize upstream; author profile emits quoted typed lexical literals.", + "Dataset isomorphism encodes subject/predicate/object/graph roles and graph existence into an RDF graph, sharing native blank identities; RDFLib computes the bijection.", + "RDF 1.2/JSON-LD/N3 entailment are not implemented." + ] +} diff --git a/tests/native/fixtures/rdf/scoped.trig b/tests/native/fixtures/rdf/scoped.trig new file mode 100644 index 0000000..6e01dc3 --- /dev/null +++ b/tests/native/fixtures/rdf/scoped.trig @@ -0,0 +1,9 @@ +# Terms, lexical forms, graph scopes and empty named graphs. +@base . +@prefix e: . +e:empty {} +GRAPH [] {} + {} +e:first { _:shared e:p "01"^^ . } +e:second { _:shared e:p "abc"^^; e:language "hello"@EN . } +{ e:s e:list ("one" "two"); e:quoted "{ fake:graph {} }"; e:unicode "α😀\n\t\r\u0000" . } diff --git a/tests/native/graphson-oracle.ts b/tests/native/graphson-oracle.ts new file mode 100644 index 0000000..71d0a28 --- /dev/null +++ b/tests/native/graphson-oracle.ts @@ -0,0 +1,51 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { resolve, join, delimiter } from "node:path"; +export async function getGraphSONOracle( + source: unknown | "GENERATE_MODERN", +): 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 metadata = JSON.parse( + await readFile(resolve("scripts/native/graphson-tools.json"), "utf8"), + ); + const jars = + process.env.GRAPH_NATIVE_GRAPHSON_LIB ?? + metadata.artifacts + .map((artifact: { filename: string }) => + resolve(".tools-native/graphson/lib", artifact.filename), + ) + .join(delimiter); + const script = resolve("scripts/native/graphson_oracle.groovy"); + return new Promise((resolveResult, reject) => { + const child = execFile( + java, + ["-Djava.awt.headless=true", "-cp", jars, "groovy.ui.GroovyMain", script], + { timeout: 20_000, maxBuffer: 16 * 1024 * 1024 }, + (error, stdout, stderr) => { + if (error) { + reject( + new Error( + `TinkerPop native oracle failed: ${stderr || error.message}`, + ), + ); + return; + } + try { + const result = JSON.parse(stdout.trim()); + if (result.version !== "3.8.2") + throw new Error(`Unexpected TinkerPop ${result.version}`); + resolveResult(result); + } catch (error) { + reject(error); + } + }, + ); + child.stdin!.end( + source === "GENERATE_MODERN" ? source : JSON.stringify(source), + ); + }); +} diff --git a/tests/native/graphson.test.ts b/tests/native/graphson.test.ts new file mode 100644 index 0000000..ca5e29d --- /dev/null +++ b/tests/native/graphson.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect } from "vitest"; +import { readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { createGraph } from "../../src/graph"; +import { + fromGraphSON, + toGraphSON, + graphsonDocumentConverter, + type GraphSONGraph, +} from "../../src/formats/graphson"; +import { getGraphSONOracle } from "./graphson-oracle"; +import { createRequire } from "node:module"; +const gremlin = createRequire(import.meta.url)("gremlin"); +function getTyped(type: string, value: any): any { + return { "@type": type, "@value": value }; +} +describe("Apache TinkerPop3.8.2 GraphSON3 TinkerIoRegistryV3 actual Java reader", () => { + it("official modern graph fixture reads six vertices/edges with native categories, directions and typed property identity", async () => { + const bytes = await readFile( + new URL("./fixtures/graphson/modern.json", import.meta.url), + ), + provenance = JSON.parse( + await readFile( + new URL("./fixtures/graphson/provenance.json", import.meta.url), + "utf8", + ), + ); + expect(createHash("sha256").update(bytes).digest("hex")).toBe( + provenance.sha256, + ); + const source = JSON.parse(bytes.toString()) as GraphSONGraph, + native = await getGraphSONOracle(source); + expect(native.version).toBe("3.8.2"); + expect(native.vertices).toHaveLength(6); + expect(native.edges).toHaveLength(6); + expect(native.edges.find((edge: any) => edge.id === "7")).toMatchObject({ + source: "1", + target: "2", + label: "knows", + }); + expect( + native.vertices + .find((node: any) => node.id === "1") + .properties.find((property: any) => property.key === "name"), + ).toMatchObject({ value: '"marko"', valueClass: "java.lang.String" }); + const doc = JSON.parse( + JSON.stringify(graphsonDocumentConverter.from(source)), + ); + expect(graphsonDocumentConverter.to(doc)).toEqual(source); + doc.graph.nodes[0].label = "updated-category"; + const changed = await getGraphSONOracle(graphsonDocumentConverter.to(doc)); + expect(changed.vertices.find((node: any) => node.id === "1").label).toBe( + "updated-category", + ); + expect(changed.edges).toHaveLength(6); + }); + it("fresh Graph emits actual native directed parallels, loops, isolates, string identities and JSON data convention", async () => { + const graph = createGraph({ + data: [null, "root"], + nodes: [ + { id: "a", label: null, data: { text: "雪", native: true } }, + { id: "b", label: "Target", data: 42 }, + { id: "isolated", label: "Isolated" }, + ], + edges: [ + { id: "e0", sourceId: "a", targetId: "b", weight: 2 }, + { id: "e1", sourceId: "a", targetId: "b", weight: 0 }, + { id: "loop", sourceId: "a", targetId: "a", label: "Loop" }, + ], + }); + const source = toGraphSON(graph), + native = await getGraphSONOracle(source); + expect(native.vertices.map((node: any) => node.id).sort()).toEqual([ + "a", + "b", + "isolated", + ]); + expect( + native.edges + .map((edge: any) => [edge.id, edge.source, edge.target]) + .sort(), + ).toEqual([ + ["e0", "a", "b"], + ["e1", "a", "b"], + ["loop", "a", "a"], + ]); + const blob = native.vertices + .find((node: any) => node.id === "a") + .properties.find((property: any) => property.key === "data"); + expect(JSON.parse(JSON.parse(blob.value))).toEqual(graph.nodes[0].data); + expect(blob.valueClass).toBe("java.lang.String"); + expect( + native.edges + .find((edge: any) => edge.id === "e1") + .properties.find((property: any) => property.key === "weight").value, + ).toContain('"@value":0.0'); + const doc = graphsonDocumentConverter.from(source); + doc.graph!.nodes[0].data = { changed: true }; + const edited = await getGraphSONOracle(graphsonDocumentConverter.to(doc)); + expect( + JSON.parse( + JSON.parse( + edited.vertices + .find((node: any) => node.id === "a") + .properties.find((property: any) => property.key === "data").value, + ), + ), + ).toEqual({ changed: true }); + }); + it("native string/Int32/Int64 IDs remain distinct; unsafe Int64 and BigDecimal retain exact native magnitude", async () => { + const ids = [ + "1", + getTyped("g:Int32", 1), + getTyped("g:Int64", "1"), + getTyped("g:Int64", "9007199254740993"), + ]; + const source = getTyped("tinker:graph", { + vertices: ids.map((id, index) => + getTyped("g:Vertex", { + id, + label: `v${index}`, + ...(index === 3 && { + properties: { + precise: [ + getTyped("g:VertexProperty", { + id: "p", + label: "precise", + value: getTyped("gx:BigDecimal", "1.234567890123456789"), + properties: { origin: "exact" }, + }), + ], + }, + }), + }), + ), + edges: [ + getTyped("g:Edge", { + id: "e", + outV: ids[1], + inV: ids[2], + outVLabel: "v1", + inVLabel: "v2", + label: "typed", + }), + ], + }); + const native = await getGraphSONOracle(source); + expect(native.vertices).toHaveLength(4); + expect( + new Set(native.vertices.map((node: any) => `${node.type}:${node.id}`)) + .size, + ).toBe(4); + const large = native.vertices.find( + (node: any) => node.id === "9007199254740993", + ); + expect(large.type).toBe("java.lang.Long"); + expect(large.properties[0]).toMatchObject({ + valueClass: "java.math.BigDecimal", + meta: [{ key: "origin", value: '"exact"' }], + }); + expect(large.properties[0].value).toContain("1.234567890123456789"); + expect(native.roundTrip).toContain("9007199254740993"); + expect( + new Set(fromGraphSON(source).nodes.map((node) => node.id)).size, + ).toBe(4); + expect(toGraphSON(fromGraphSON(source))).toEqual(source); + }); + it("native typed maps with non-string keys, sets, multi-properties/meta-properties and property IDs are observed", async () => { + const source = getTyped("tinker:graph", { + vertices: [ + getTyped("g:Vertex", { + id: "a", + label: "Person", + properties: { + name: [ + getTyped("g:VertexProperty", { + id: "p1", + label: "name", + value: "Alpha", + properties: { origin: "first" }, + }), + getTyped("g:VertexProperty", { + id: "p2", + label: "name", + value: "Beta", + }), + ], + map: [ + getTyped("g:VertexProperty", { + id: "p3", + label: "map", + value: getTyped("g:Map", [ + getTyped("g:Int32", 1), + "integer", + "1", + getTyped("g:Set", ["x", "y"]), + ]), + }), + ], + }, + }), + ], + edges: [], + }); + const native = await getGraphSONOracle(source); + expect( + native.vertices[0].properties + .filter((property: any) => property.key === "name") + .map((property: any) => property.id) + .sort(), + ).toEqual(["p1", "p2"]); + expect( + native.vertices[0].properties.find( + (property: any) => property.key === "map", + ), + ).toMatchObject({ valueClass: "java.util.LinkedHashMap" }); + const doc = graphsonDocumentConverter.from(source); + doc.graph!.nodes[0].data.properties.name[1]["@value"].value = "Changed"; + const changed = await getGraphSONOracle(graphsonDocumentConverter.to(doc)); + expect( + changed.vertices[0].properties.find( + (property: any) => property.id === "p2", + ).value, + ).toBe('"Changed"'); + expect( + changed.vertices[0].properties.find( + (property: any) => property.id === "p1", + ).meta, + ).toEqual([{ key: "origin", value: '"first"' }]); + }); + it("maintained JavaScript reader independently decodes supported typed graph elements and collections; wholeGraph registry is Java only", () => { + const reader = new gremlin.structure.io.GraphSON3Reader(), + value = reader.read( + getTyped("g:Map", [ + getTyped("g:Int32", 1), + "integer", + "1", + getTyped("g:Set", ["x", "y"]), + ]), + ); + expect(value).toBeInstanceOf(Map); + expect(value.get(1)).toBe("integer"); + expect(value.get("1")).toBeInstanceOf(Set); + expect([...value.get("1")]).toEqual(["x", "y"]); + const vertex = reader.read( + getTyped("g:Vertex", { id: "a", label: "Person" }), + ), + edge = reader.read( + getTyped("g:Edge", { + id: "e", + outV: "a", + inV: "a", + outVLabel: "Person", + inVLabel: "Person", + label: "loop", + }), + ); + expect(vertex.id).toBe("a"); + expect(vertex.label).toBe("Person"); + expect(edge.outV.id).toBe("a"); + expect(edge.inV.id).toBe("a"); + }); +}); diff --git a/tests/native/graphviz.test.ts b/tests/native/graphviz.test.ts index fd04073..9b7be7e 100644 --- a/tests/native/graphviz.test.ts +++ b/tests/native/graphviz.test.ts @@ -1,9 +1,10 @@ +import { graphvizJSONDocumentConverter } from '../../src/formats/graphviz-json'; 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'; +import { fromDOT, toDOT, dotDocumentConverter } 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>; @@ -96,3 +97,16 @@ it('rejects actual native NUL scanning and guards emitted strings without reject 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' }); }); + +it('native parser confirms stable DOT private identities retain the original edge after insertion', () => { + const baseline = toDOT(createGraph({ nodes: [{ id: 'a' }, { id: 'b' }], edges: [{ id: 'original', sourceId: 'a', targetId: 'b', label: 'Same', data: { dot: { attrs: { Marker: 'original' } } } }] })); + const document = dotDocumentConverter.from(baseline); document.source = baseline.replace('a -> b', 'a -> b [label="Same",Marker="inserted"]; a -> b'); document.graph!.edges[0].label = 'GraphEdit'; + const native = getNative(dotDocumentConverter.to(document)); expect(native.edges!.find((edge) => edge.Marker === 'original')!.label).toBe('GraphEdit'); expect(native.edges!.find((edge) => edge.Marker === 'inserted')!.label).toBe('Same'); +}); +it('actual native dot_json regeneration shows _gvid renumbering and is rejected as ambiguous before patching', () => { + const baseline = JSON.parse(engine.layout('digraph {a;b;a->b[label="Same",Marker="original"]}', 'dot_json', 'dot')); + const current = JSON.parse(engine.layout('digraph {a;b;a->b[label="Same",Marker="inserted"];a->b[label="Same",Marker="original"]}', 'dot_json', 'dot')); + expect(baseline.edges[0]).toMatchObject({ _gvid: 0, Marker: 'original' }); expect(current.edges[1]).toMatchObject({ _gvid: 1, Marker: 'original' }); + const document = graphvizJSONDocumentConverter.from(baseline); document.source = current; document.graph!.edges[0].label = 'GraphEdit'; + const result = graphvizJSONDocumentConverter.getUpdate(document); expect(result.ok).toBe(false); if (!result.ok) expect(result.diagnostics[0].code).toBe('ambiguous-native-identity'); +}); diff --git a/tests/native/rdf.test.ts b/tests/native/rdf.test.ts new file mode 100644 index 0000000..1f74517 --- /dev/null +++ b/tests/native/rdf.test.ts @@ -0,0 +1,63 @@ +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { fromRDFDataset, toRDFDataset } from '../../src/formats/rdf'; +import { fromRDF, toRDF, createRDFDocumentConverter, type RDFSyntax } from '../../src/formats/rdf/syntax'; +import { getRDFLibOracle } from './rdflib-oracle'; +const source = readFileSync(new URL('./fixtures/rdf/scoped.trig', import.meta.url), 'utf8'); +describe('actual pinned RDFLib 7.6 independent RDF grammar/dataset proof', () => { + it('pins reader and authored fixture provenance', async () => { + expect((await getRDFLibOracle({ operation: 'version' })).version).toBe('7.6.0'); + const manifest = JSON.parse(readFileSync(new URL('./fixtures/rdf/provenance.json', import.meta.url), 'utf8')); + expect(createHash('sha256').update(source).digest('hex')).toBe(manifest.fixtures[0].sha256); + }); + it('matches actual parser statement/graph events for lexical data, collections, blanks and empty graphs', async () => { + const dataset = toRDFDataset(fromRDF(source, { format: 'TriG' })), native = await getRDFLibOracle({ source, format: 'TriG', expected: dataset }); + expect(native.isomorphic).toBe(true); expect(native.dataset!.namedGraphs).toHaveLength(5); + expect(native.dataset!.quads.some((quad) => quad.object.termType === 'Literal' && quad.object.value === '01')).toBe(true); + expect(native.store!.namedGraphCount).toBe(2); // Memory store omits the three declared empty graphs. + }); + it('certifies fresh TriG output including empty graphs with shared blank identity', async () => { + const dataset = toRDFDataset(fromRDF(source, { format: 'TriG' })); + const output = toRDF(fromRDFDataset(dataset), { format: 'TriG' }); + expect((await getRDFLibOracle({ source: output, format: 'TriG', expected: dataset })).isomorphic).toBe(true); + }); + it.each(['N-Triples', 'N-Quads', 'Turtle', 'TriG'] as RDFSyntax[])('accepts emitted %s, keeping literal Unicode/control escapes and numeric lexical forms', async (format) => { + const graph = fromRDF(' "01"^^, "abc"^^, "α😀\\n\\t\\r\\u0000" .', { format: 'Turtle' }); + const expected = toRDFDataset(graph), output = toRDF(graph, { format, nativeCarrier: false }); + expect((await getRDFLibOracle({ source: output, format, expected })).isomorphic).toBe(true); + }); + it('proves distinct scopes relabel safely while the same blank remains shared across graphs', async () => { + const a = { termType: 'BlankNode' as const, value: 'same', scope: 'a' }, b = { ...a, scope: 'b' }, predicate = { termType: 'NamedNode' as const, value: 'urn:p' }; + const dataset = { version: '1.1' as const, quads: [{ subject: a, predicate, object: b, graph: { termType: 'NamedNode' as const, value: 'urn:g1' } }, { subject: a, predicate, object: a, graph: { termType: 'NamedNode' as const, value: 'urn:g2' } }] }; + const output = toRDF(fromRDFDataset(dataset), { format: 'N-Quads' }); expect(output).toContain('_:b0'); expect(output).toContain('_:b1'); + expect((await getRDFLibOracle({ source: output, format: 'N-Quads', expected: dataset })).isomorphic).toBe(true); + }); + it.each(['N-Triples', 'N-Quads', 'Turtle', 'TriG'] as RDFSyntax[])('observes exact %s tag casing before native store equality and documents its casefold limitation', async (format) => { + const text = ' "same"@EN, "same"@en .', expected = toRDFDataset(fromRDF(text, { format: 'Turtle' })); + const output = toRDF(fromRDFDataset(expected), { format }); + const native = await getRDFLibOracle({ source: output, format, expected }); + expect(native.dataset!.quads).toHaveLength(2); expect(native.store!.quadCount).toBe(1); expect(native.isomorphic).toBe(true); + }); + it('independently accepts regenerated persisted source while explicit loss remains reported', async () => { + const converter = createRDFDocumentConverter({ format: 'TriG' }), document = JSON.parse(JSON.stringify(converter.from(source))); + document.source = source.replace('# Terms', '# Native Terms'); expect(converter.to(document)).toBe(document.source); + const regenerated = converter.getUpdate(document, { mode: 'regenerate' }); expect(regenerated.ok).toBe(true); + if (regenerated.ok) expect((await getRDFLibOracle({ source: regenerated.source, format: 'TriG' })).dataset!.namedGraphs).toHaveLength(5); + }); + it('uses native rejection controls rather than accepting parser self-roundtrips as proof', async () => { + await expect(getRDFLibOracle({ source: ' "predicate" .', format: 'N-Triples' })).rejects.toThrow(); + await expect(getRDFLibOracle({ source: ' "unterminated', format: 'Turtle' })).rejects.toThrow(); + const wrong = toRDFDataset(fromRDF(' "wrong" .', { format: 'N-Triples' })); + expect((await getRDFLibOracle({ source: ' "right" .', format: 'N-Triples', expected: wrong })).isomorphic).toBe(false); + }); +}); + +it('independently accepts an ordinary RDF 1.1 unknown datatype that N3 reserves for RDF 1.2', async () => { + const text = ' "opaque"^^ .'; + const native = await getRDFLibOracle({ source: text, format: 'N-Triples' }); + expect(native.dataset!.quads[0].object).toMatchObject({ value: 'opaque', language: '', datatype: { value: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString' } }); + const emitted = toRDF(fromRDFDataset({ ...native.dataset!, quads: native.dataset!.quads }), { format: 'N-Triples' }); + expect((await getRDFLibOracle({ source: emitted, format: 'N-Triples', expected: native.dataset })).isomorphic).toBe(true); + expect(createRDFDocumentConverter({ format: 'N-Triples' }).from(emitted).graph).toBeNull(); +}); diff --git a/tests/native/rdflib-oracle.ts b/tests/native/rdflib-oracle.ts new file mode 100644 index 0000000..4cdc3f1 --- /dev/null +++ b/tests/native/rdflib-oracle.ts @@ -0,0 +1,19 @@ +import { execFile } from 'node:child_process'; +import { resolve } from 'node:path'; +import type { RDFDataset } from '../../src/formats/rdf'; +import type { RDFSyntax } from '../../src/formats/rdf/syntax'; +export interface RDFLibOracleRequest { operation?: 'version'; source?: string; format?: RDFSyntax; baseIRI?: string; expected?: RDFDataset } +export interface RDFLibOracleResult { oracle: 'rdflib'; version: string; python: string; dataset?: RDFDataset; isomorphic?: boolean; store?: { quadCount: number; namedGraphCount: number; observer: string } } +/** Actual pinned Python parser plus RDFLib isomorphism, with bounded process/output. */ +export async function getRDFLibOracle(request: RDFLibOracleRequest): 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/rdflib_oracle.py')], { timeout: 10_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error) reject(new Error(`RDFLib native oracle failed: ${stderr.trim() || error.message}`)); else resolveResult(stdout); + }); + child.stdin!.end(JSON.stringify(request)); + }); + const parsed = JSON.parse(result) as RDFLibOracleResult; + if (parsed.oracle !== 'rdflib' || parsed.version !== '7.6.0') throw new Error('RDFLib native oracle version mismatch'); + return parsed; +} diff --git a/tests/rdf-syntax.test.ts b/tests/rdf-syntax.test.ts new file mode 100644 index 0000000..3f646a3 --- /dev/null +++ b/tests/rdf-syntax.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { fromRDF, toRDF, createRDFDocumentConverter, type RDFSyntax } from '../src/formats/rdf/syntax'; +import { fromRDFDataset, toRDFDataset, getRDFQuadId, type RDFDataset } from '../src/formats/rdf'; +const options = { format: 'TriG' as const }; +const source = `# KEEP\n@base .\n@prefix e: .\ne:empty {}\nGRAPH [] {}\ne:g1 { _:same e:p "01"^^ . }\ne:g2 { _:same e:p "x"@EN . }\n {}\n{ e:s e:p "{ fake:graph {} }"; e:q ("one" "two"). }\n`; +describe('maintained optional N3 syntax profile', () => { + it('retains empty/prefixed/base/anonymous graph names and exact literal forms', () => { + const dataset = toRDFDataset(fromRDF(source, options)); + expect(dataset.namedGraphs).toHaveLength(5); + expect(dataset.namedGraphs!.map((term) => term.value)).toContain('http://example.org/relative'); + expect(dataset.quads.find((quad) => quad.object.termType === 'Literal' && quad.object.language === 'EN')!.object).toMatchObject({ value: 'x', language: 'EN' }); + expect(dataset.quads.find((quad) => quad.object.termType === 'Literal' && quad.object.value === '01')!.object).toMatchObject({ value: '01' }); + const shared = dataset.quads.filter((quad) => quad.subject.termType === 'BlankNode' && quad.subject.value === 'same'); expect(shared).toHaveLength(2); expect(shared[0].subject).toEqual(shared[1].subject); + expect(dataset.quads.some((quad) => quad.predicate.value.endsWith('#first'))).toBe(true); + }); + it('keeps repeated native parses stable, with future blank labels reserved before anonymous allocation', () => { + const text = '[] . _:rdf_generated_0 _:rdf_generated_0 .'; + const first = fromRDF(text, { format: 'Turtle' }), second = fromRDF(text, { format: 'Turtle' }); + expect(first.nodes.map((node) => node.id)).toEqual(second.nodes.map((node) => node.id)); + const blanks = first.nodes.filter((node) => node.data.rdf.term.termType === 'BlankNode'); expect(blanks).toHaveLength(2); expect(blanks.map((node) => node.data.rdf.term.value).sort()).toEqual(['rdf_generated_0', 'rdf_generated_1']); + expect(fromRDF(text, { format: 'Turtle', blankScope: 'independent' }).nodes[0].id).not.toBe(first.nodes[0].id); + }); + it('does not interpret literal braces/comments/collection nodes as graph declarations', () => { + const text = '# {}\n "{ fake }"; ([ "[] {}"] []) .'; + expect(toRDFDataset(fromRDF(text, options)).namedGraphs).toEqual([]); + }); + it.each(['N-Triples', 'N-Quads', 'Turtle', 'TriG'] as RDFSyntax[])('writes a proven scoped %s author dialect without numeric coercion', (format) => { + const graph = fromRDF(' "abc"^^, "A\\u0000B\\nα😀" .', { format: 'Turtle' }); + const output = toRDF(graph, { format, nativeCarrier: false }), dataset = toRDFDataset(fromRDF(output, { format })); + expect(dataset.quads.map((quad) => quad.object.value)).toEqual(['abc', 'A\u0000B\nα😀']); + }); + it('writes empty graphs only in TriG and relabels colliding scoped blanks distinctly', () => { + const first = { termType: 'BlankNode' as const, value: 'same', scope: 'first' }, second = { ...first, scope: 'second' }; + const dataset: RDFDataset = { version: '1.1', namedGraphs: [{ termType: 'NamedNode', value: 'urn:empty' }], quads: [{ subject: first, predicate: { termType: 'NamedNode', value: 'urn:p' }, object: second, graph: { termType: 'DefaultGraph', value: '' } }] }; + const graph = fromRDFDataset(dataset), output = toRDF(graph, options); expect(output).toContain('_:b0 _:b1'); expect(output).toContain(' {'); + const reparsed = toRDFDataset(fromRDF(output, options)); expect(reparsed.namedGraphs).toEqual(dataset.namedGraphs); expect(reparsed.quads[0].subject).not.toEqual(reparsed.quads[0].object); + for (const format of ['N-Quads', 'N-Triples', 'Turtle'] as RDFSyntax[]) expect(() => toRDF(graph, { format })).toThrow(/empty|named graphs/); + }); + it('retains exact source and source-only edits after JSON persistence', () => { + const converter = createRDFDocumentConverter(options), document = JSON.parse(JSON.stringify(converter.from(source))); + expect(converter.to(document)).toBe(source); document.source = source.replace('# KEEP', '# NATIVE').replace('"01"', '"001"'); expect(converter.to(document)).toBe(document.source); + expect(toRDF(JSON.parse(JSON.stringify(fromRDF(source, options))), { ...options, nativeCarrier: true })).toBe(source); + }); + it('rejects unsupported Graph patches/stale replay and reports explicit regeneration loss', () => { + const converter = createRDFDocumentConverter({ format: 'N-Triples' }), document = converter.from('# KEEP\n "OLD" .\n'); + document.graph!.edges[0].data.rdf.predicate.value = 'urn:changed'; document.graph!.edges[0].label = 'urn:changed'; + const dataset = toRDFDataset(fromRDF(' "OLD" .', { format: 'N-Triples' })); document.graph!.edges[0].id = getRDFQuadId(dataset.quads[0]); + expect(() => converter.to(document)).toThrow(/unsupported/); + const result = converter.getUpdate(document, { mode: 'regenerate' }); expect(result.ok).toBe(true); if (result.ok) { expect(result.source).not.toContain('# KEEP'); expect(result.source).toContain(''); expect(result.diagnostics?.some((item) => item.code.includes('loss'))).toBe(true); } + }); + it('rejects source/Graph field conflicts without overwriting native RDF terms', () => { + const converter = createRDFDocumentConverter({ format: 'N-Triples' }), document = converter.from(' "OLD" .'); + document.source = ' "NATIVE" .'; document.graph!.nodes.find((node) => node.label === 'OLD')!.label = 'GRAPH'; expect(() => converter.to(document)).toThrow(/conflict/); + }); + it.each(['@version "1.2" . "ordinary" .', ' <<( )>> .', ' <<( <<( )>>) >> .', ' "x"@en--ltr .', ' .', ' "bad"@en-abcdefghi .'])('rejects unsupported profiles/invalid source while retaining document text: %s', (text) => { + const selected = text.includes('') ? { format: 'N-Triples' as const } : { format: 'Turtle' as const }; + expect(() => fromRDF(text, selected)).toThrow(); const converter = createRDFDocumentConverter(selected), document = converter.from(text); expect(document.graph).toBeNull(); expect(converter.to(JSON.parse(JSON.stringify(document)))).toBe(text); + }); + it('enforces concrete syntax selection and refuses lossy named-graph triple output', () => { + expect(() => fromRDF(source, { format: 'JSON-LD' } as any)).toThrow(/select/); expect(() => fromRDF(source, { format: 'TriG', version: '1.2' } as any)).toThrow(/1.1/); + expect(() => fromRDF(' { "x" . }', { format: 'Turtle' })).toThrow(); + expect(() => toRDF(fromRDF(' { "x" . }', options), { format: 'N-Triples' })).toThrow(/named graphs/); + }); +}); + +it('records N3 explicit dirLangString datatype parser gap without discarding original source', () => { + const text = ' "opaque"^^ .'; + expect(() => fromRDF(text, { format: 'N-Triples' })).toThrow(/explicit datatype/); + const converter = createRDFDocumentConverter({ format: 'N-Triples' }), document = converter.from(text); + expect(document.graph).toBeNull(); expect(converter.to(document)).toBe(text); +}); +it('defaults export to captured base/scope options while explicit option changes regenerate', () => { + const text = '# KEEP\n

"x" .', graph = fromRDF(text, { format: 'Turtle', baseIRI: 'https://example.org/', blankScope: 'first' }); + expect(toRDF(graph, { format: 'Turtle' })).toBe(text); + expect(toRDF(graph, { format: 'Turtle', baseIRI: 'https://different.org/' })).toContain(''); +}); diff --git a/tests/rdf.test.ts b/tests/rdf.test.ts new file mode 100644 index 0000000..cc09909 --- /dev/null +++ b/tests/rdf.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { createGraph } from '../src/graph'; +import { fromRDFDataset, toRDFDataset, getRDFTermId, getRDFQuadId, getRDFTerm, rdfDatasetDocumentConverter, type RDFDataset, type RDFTerm, type RDFQuad, type RDFNamedNode, type RDFLiteral } from '../src/formats/rdf'; +const iri = (value: string): RDFNamedNode => ({ termType: 'NamedNode', value }); +const literal = (value: string, datatype = 'http://www.w3.org/2001/XMLSchema#string', language = ''): RDFLiteral => ({ termType: 'Literal', value, datatype: iri(datatype), language }); +const quad = (object: RDFQuad['object'], graph: RDFQuad['graph'] = { termType: 'DefaultGraph', value: '' }): RDFQuad => ({ subject: iri('urn:s'), predicate: iri('urn:p'), object, graph }); +const dataset = (quads: RDFQuad[], namedGraphs?: RDFDataset['namedGraphs']): RDFDataset => ({ version: '1.1', quads, ...(namedGraphs && { namedGraphs }) }); +describe('plain RDF 1.1 dataset profile', () => { + it('retains exact DTO metadata, occurrences, empty named graphs and JSON persistence', () => { + const source = { ...dataset([quad(literal('01', 'http://www.w3.org/2001/XMLSchema#integer')), quad(literal('01', 'http://www.w3.org/2001/XMLSchema#integer'))], [iri('urn:empty')]), vendor: { constructor: 'KEEP' } }; + const graph = fromRDFDataset(source); expect(graph.edges).toHaveLength(1); expect(graph.nodes).toHaveLength(2); + expect(toRDFDataset(JSON.parse(JSON.stringify(graph)))).toEqual(source); + expect(graph.nodes.find((node) => node.data.rdf.term.termType === 'Literal')!.data.rdf.term.value).toBe('01'); + expect(graph.data.rdf.namedGraphs).toEqual([iri('urn:empty')]); + expect(rdfDatasetDocumentConverter.to(JSON.parse(JSON.stringify(rdfDatasetDocumentConverter.from(source))))).toEqual(source); + }); + it('keeps lexical numbers/ill-typed literals and RDF 1.1 case-sensitive language identities', () => { + const source = dataset([quad(literal('1', 'http://www.w3.org/2001/XMLSchema#integer')), quad(literal('01', 'http://www.w3.org/2001/XMLSchema#integer')), quad(literal('abc', 'http://www.w3.org/2001/XMLSchema#integer')), quad(literal('x', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#langString', 'EN')), quad(literal('x', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#langString', 'en')), quad(iri('urn:x')), quad(literal('urn:x'))]); + expect(fromRDFDataset(source).edges).toHaveLength(7); + expect(new Set(source.quads.map((entry) => getRDFTermId(entry.object))).size).toBe(7); + }); + it('shares blank nodes across graphs while distinguishing independent scopes', () => { + const first = { termType: 'BlankNode' as const, value: 'same', scope: 'one' }, second = { ...first, scope: 'two' }; + const source = dataset([quad(first, iri('urn:g1')), quad(first, iri('urn:g2')), quad(second)]); + const graph = fromRDFDataset(source); expect(graph.nodes).toHaveLength(3); expect(graph.edges[0].targetId).toBe(graph.edges[1].targetId); expect(graph.edges[2].targetId).not.toBe(graph.edges[0].targetId); + expect(graph.nodes.every((node) => node.parentId === null)).toBe(true); + expect(graph.edges[1].data.rdf.graph).toEqual(iri('urn:g2')); + }); + it('provides collision-safe public IDs and a coherent fresh Graph construction path', () => { + const entry = quad(literal('[]|😀\n\u0000')); + const graph = createGraph({ nodes: [entry.subject, entry.object].map((term) => ({ id: getRDFTermId(term), label: term.value, data: { rdf: { term } } })), edges: [{ id: getRDFQuadId(entry), sourceId: getRDFTermId(entry.subject), targetId: getRDFTermId(entry.object), mode: 'directed', label: entry.predicate.value, data: { rdf: { predicate: entry.predicate, graph: entry.graph } } }], data: { rdf: { version: '1.1', namedGraphs: [] } } }); + expect(toRDFDataset(graph).quads).toEqual([entry]); + expect(getRDFTermId(iri('urn:x'))).not.toBe(getRDFTermId(literal('urn:x'))); + }); + it.each([null, { termType: 'Quad', value: '' }, { termType: 'Variable', value: 'x' }, iri('relative'), iri('urn:bad[bracket]'), iri('http://example.org/%ZZ'), iri('urn:\ud800'), { termType: 'DefaultGraph', value: 'bad' }, { termType: 'BlankNode', value: '', scope: 'x' }, { termType: 'BlankNode', value: 'x', scope: '' }, literal('x', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#langString'), literal('x', 'http://www.w3.org/2001/XMLSchema#string', 'en'), literal('x', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#langString', 'en-abcdefghi'), { ...literal('x'), direction: 'ltr' }])('rejects invalid/version-unsupported terms %j', (term) => { expect(() => getRDFTerm(term as RDFTerm)).toThrow(/RDF/); }); + it('rejects invalid roles, metadata bypass and unsupported version before projection', () => { + for (const entry of [{ ...quad(literal('x')), subject: literal('bad') }, { ...quad(literal('x')), predicate: { termType: 'BlankNode', value: 'p', scope: 's' } }, { ...quad(literal('x')), object: { termType: 'DefaultGraph', value: '' } }, { ...quad(literal('x')), graph: literal('bad') }]) expect(() => fromRDFDataset(dataset([entry as RDFQuad]))).toThrow(/role/); + expect(() => fromRDFDataset({ version: '1.2', quads: [] } as any)).toThrow(/1.1/); + const graph = fromRDFDataset(dataset([quad(literal('x'))])); graph.data.__statelyaiNative.native.quads[0].predicate = literal('bad'); + expect(() => toRDFDataset(graph)).toThrow(/role/); + }); + it('rejects cyclic/20k malformed literal datatypes at bounded depth', () => { + const cycle: any = literal('x'); cycle.datatype = cycle; expect(() => getRDFTerm(cycle)).toThrow(/NamedNode/); + let deep: any = iri('urn:datatype'); for (let index = 0; index < 20000; index++) deep = { ...literal('x'), datatype: deep }; + expect(() => getRDFTerm(deep)).toThrow(/NamedNode/); + }); + it('accepts BCP47 grandfathered/private tags and authority IP literals without value coercion', () => { + for (const tag of ['i-klingon', 'x-private', 'en-Latn-US', 'en-u-ca-gregory']) expect(getRDFTerm(literal('x', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#langString', tag))).toMatchObject({ language: tag }); + for (const value of ['http://[::1]/', 'urn:x', 'http://例え.テスト/α']) expect(getRDFTerm(iri(value))).toEqual(iri(value)); + expect(() => getRDFTerm(iri('http://[bad]/'))).toThrow(/IP literal/); + }); + it('rejects arbitrary Graph-only data, hierarchy, modes and stale display labels', () => { + expect(() => toRDFDataset(createGraph({ nodes: [{ id: 'a', label: 'invent IRI' }] }))).toThrow(); + for (const change of [(graph: any) => { graph.nodes[0].label = 'DISPLAY'; }, (graph: any) => { graph.nodes[0].x = 10; }, (graph: any) => { graph.mode = 'undirected'; }, (graph: any) => { graph.data.user = 42; }, (graph: any) => { graph.nodes[0].parentId = graph.nodes[1].id; }]) { const graph = fromRDFDataset(dataset([quad(literal('x'))])); change(graph); expect(() => toRDFDataset(graph)).toThrow(/unsupported Graph-only/); } + }); + it('handles a sparse 10k-statement graph iteratively', () => { + const source = dataset(Array.from({ length: 10000 }, (_, index) => ({ ...quad(iri(`urn:o:${index}`)), subject: iri(`urn:s:${index}`) }))); + const graph = fromRDFDataset(source); expect(graph.nodes).toHaveLength(20000); expect(graph.edges).toHaveLength(10000); expect(toRDFDataset(graph).quads).toHaveLength(10000); + }, 30000); +}); + +it('rejects non-JSON unknown metadata without silent normalization or role bypass', () => { + for (const vendor of [undefined, () => 1, BigInt(1), NaN, Infinity, new Date(), [,]]) expect(() => fromRDFDataset({ version: '1.1', quads: [], vendor })).toThrow(/JSON/); + const cyclic: any = { version: '1.1', quads: [] }; cyclic.vendor = cyclic; expect(() => fromRDFDataset(cyclic)).toThrow(/cycles/); + const source: any = { version: '1.1', quads: [] }; Object.defineProperty(source, 'vendor', { get: () => 'DATA', enumerable: true }); expect(() => fromRDFDataset(source)).toThrow(/accessors/); +}); + +it('RDF 1.1 accepts rdf:dirLangString as an ordinary unknown datatype without direction fields', () => { + const term = literal('opaque', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString'); + expect(getRDFTerm(term)).toEqual(term); expect(toRDFDataset(fromRDFDataset(dataset([quad(term)]))).quads[0].object).toEqual(term); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 628fd76..970abeb 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -36,6 +36,11 @@ export default defineConfig({ 'src/formats/drawio/index.ts', 'src/formats/pajek/index.ts', 'src/formats/plantuml/index.ts', + 'src/formats/dgml/index.ts', + 'src/formats/cx2/index.ts', + 'src/formats/graphson/index.ts', + 'src/formats/rdf/index.ts', + 'src/formats/rdf/syntax.ts', // Layout — contract + per-engine adapters (optional peers) 'src/layout/index.ts', diff --git a/vitest.config.ts b/vitest.config.ts index fc99c85..d893f3a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,6 +28,11 @@ export default defineConfig({ '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/graphson/index.ts': { lines: 93, branches: 85 }, + 'src/formats/dgml/index.ts': { lines: 91, branches: 84 }, + 'src/formats/cx2/index.ts': { lines: 93, branches: 85 }, + 'src/formats/rdf/index.ts': { lines: 98, branches: 93 }, + 'src/formats/rdf/syntax.ts': { lines: 97, branches: 82 }, '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 }, From 931394d3b0c061d1b165eed219bb692532339f9c Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Tue, 13 Oct 2026 09:12:28 +0200 Subject: [PATCH 2/2] Address domain format review findings --- .../009-reject-ambiguous-source-identities.md | 2 +- .../010-complete-document-identity-guards.md | 4 +-- src/formats/cx2/index.ts | 25 ++++++++++++++----- src/formats/dgml/README.md | 4 +-- src/formats/dgml/index.ts | 15 +++++++++-- src/formats/graphson/index.ts | 12 ++++++++- src/formats/rdf/index.ts | 4 +-- tests/cx2.test.ts | 19 ++++++++++++++ tests/dgml.test.ts | 9 +++++++ tests/graphson.test.ts | 9 +++++++ tests/rdf.test.ts | 1 + 11 files changed, 88 insertions(+), 16 deletions(-) diff --git a/plans/009-reject-ambiguous-source-identities.md b/plans/009-reject-ambiguous-source-identities.md index 0375efc..92d4f3e 100644 --- a/plans/009-reject-ambiguous-source-identities.md +++ b/plans/009-reject-ambiguous-source-identities.md @@ -1,6 +1,6 @@ # Plan 009: Reject wrong-entity document updates after source identity drift -Planned at `8d1b139`, 2026-10-12. P1 bug; M effort; MED risk; HIGH confidence. Depends on DONE003. Dedicated worktree `/Users/davidkpiano/.codex/worktrees/graph/domain-formats`, branch feature/domain-formats. Shared checkout preserved. +Planned at `8d1b139`, 2026-10-12. P1 bug; M effort; MED risk; HIGH confidence. Depends on DONE003. Dedicated `feature/domain-formats` worktree. Shared checkout preserved. ## Why/current state diff --git a/plans/010-complete-document-identity-guards.md b/plans/010-complete-document-identity-guards.md index 60a6423..47fc9dd 100644 --- a/plans/010-complete-document-identity-guards.md +++ b/plans/010-complete-document-identity-guards.md @@ -1,6 +1,6 @@ # Plan 010: Close the remaining verified document identity gaps -Planned at `8d1b139`, 2026-10-12. P1 bug; effort M; risk MED; HIGH confidence. Depends on 009's frozen eight-format helper fixes. Worktree `/Users/davidkpiano/.codex/worktrees/graph/domain-formats`, feature/domain-formats; no shared checkout changes. +Planned at `8d1b139`, 2026-10-12. P1 bug; effort M; risk MED; HIGH confidence. Depends on 009's frozen eight-format helper fixes. Dedicated `feature/domain-formats` worktree; no shared checkout changes. ## Why/current state @@ -12,7 +12,7 @@ Own `src/formats/structured-json.ts` scoped Graphology/NetworkX identity guards; ## Steps -1. Read AGENTS and README skill. Compare `git diff 8d1b139 --` scoped paths to current code; shared preservation already contains 009, adapt to it. Exact dependencies installed. Host default Node changed to22.11; use `env PATH=/Users/davidkpiano/.nvm/versions/node/v24.17.0/bin:$PATH pnpm ...` (verified project pnpm10.28.2), not global environment edits. +1. Read AGENTS and README skill. Compare `git diff 8d1b139 --` scoped paths to current code; shared preservation already contains 009, adapt to it. Exact dependencies installed. Use Node 24.17.0 with project pnpm 10.28.2; do not change the global environment. 2. RED tests using exact audit inputs: - JGF: `{graph:{nodes:[{id:'a'},{id:'b'}],edges:[{source:'a',target:'b',label:'Same',metadata:{Marker:'original'}}]}}`; prepend same ends/label `id:'e0',metadata:{Marker:'inserted'}`. - Graphology: `{options:{type:'directed',multi:true},attributes:{},nodes:[{key:'a'},{key:'b'}],edges:[{source:'a',target:'b',attributes:{label:'Same',Marker:'original'}}]}`; prepend same ends/label `key:'e0',attributes:{label:'Same',Marker:'inserted'}`. diff --git a/src/formats/cx2/index.ts b/src/formats/cx2/index.ts index 9e4758d..4f03ebe 100644 --- a/src/formats/cx2/index.ts +++ b/src/formats/cx2/index.ts @@ -461,12 +461,14 @@ function getInferredType(value: any): CX2AttributeType { if (typeof value === 'number' && Number.isFinite(value)) return Number.isInteger(value) ? 'long' : 'double'; if (Array.isArray(value) && value.length) { - const type = getInferredType(value[0]); - if ( - type.startsWith('list_of_') || - value.some((v) => getInferredType(v) !== type) - ) + const types = new Set(value.map(getInferredType)); + if ([...types].some((type) => type.startsWith('list_of_'))) throw new Error('CX2: homogeneous scalar list required'); + if (types.size === 2 && types.has('long') && types.has('double')) + return 'list_of_double'; + if (types.size !== 1) + throw new Error('CX2: homogeneous scalar list required'); + const type = [...types][0]; return `list_of_${type}` as CX2AttributeType; } throw new Error( @@ -494,15 +496,26 @@ function getFreshDeclarations(graph: CX2Graph): CX2Declarations { ], ] as const) { const defs = declarations[aspect] ?? {}; + const inferred = new Set(); for (const attrs of records) for (const [key, value] of Object.entries(attrs)) { - if (!Object.hasOwn(defs, key)) + if (!Object.hasOwn(defs, key)) { + inferred.add(key); Object.defineProperty(defs, key, { value: { d: getInferredType(value) }, enumerable: true, writable: true, configurable: true, }); + } else if (inferred.has(key)) { + const current = defs[key].d, + next = getInferredType(value); + if ( + (current === 'long' && next === 'double') || + (current === 'list_of_long' && next === 'list_of_double') + ) + defs[key].d = next; + } getTypedValue(value, defs[key].d); } declarations[aspect] = defs; diff --git a/src/formats/dgml/README.md b/src/formats/dgml/README.md index ec409b9..30502ac 100644 --- a/src/formats/dgml/README.md +++ b/src/formats/dgml/README.md @@ -15,8 +15,8 @@ source supplies an explicit declaration. Node/edge data separates category memberships, raw string attributes and declared property values. Supported CLR property types are String, Boolean, Int32, Int64 -(unsafe magnitudes retained as exact lexical strings under their declaration) and Double (finite only). Unsupported declared property -values reject projection. Category/property definitions remain in graph data; +(unsafe magnitudes retained as exact lexical strings under their declaration) and Double (finite only). Missing or unsupported CLR types +remain exact lexical strings; invalid values for supported types reject projection. Category/property definitions remain in graph data; category appearance inheritance, Styles and other XML extensions remain opaque. Native links have no schema-defined stable ID; Graph edge IDs are their original link positions (`dgml_link_N`, counting Contains links). Strict fresh output diff --git a/src/formats/dgml/index.ts b/src/formats/dgml/index.ts index ec7c501..b493b46 100644 --- a/src/formats/dgml/index.ts +++ b/src/formats/dgml/index.ts @@ -130,7 +130,18 @@ function getProperty( value: string, type: string | undefined ): string | number | boolean { - if (type === 'System.String') return value; + if ( + type === undefined || + ![ + 'System.String', + 'System.Boolean', + 'System.Int32', + 'System.Int64', + 'System.Double', + ].includes(type) || + type === 'System.String' + ) + return value; if (type === 'System.Boolean' && /^(true|false)$/i.test(value)) return value.toLowerCase() === 'true'; if (type === 'System.Int32' || type === 'System.Int64') { @@ -152,7 +163,7 @@ function getProperty( Number.isFinite(Number(value)) ) return Number(value); - throw new Error(`DGML: unsupported/invalid property type ${type}`); + throw new Error(`DGML: invalid property value for ${type}`); } function getData( element: XMLElement, diff --git a/src/formats/graphson/index.ts b/src/formats/graphson/index.ts index b484449..09481ed 100644 --- a/src/formats/graphson/index.ts +++ b/src/formats/graphson/index.ts @@ -642,6 +642,8 @@ export function fromGraphSON( ): Graph { const graph = getProjection(source, options).graph; graph.data = createNativeCarrier(graph.data, "graphson", source, graph); + (graph.data as any).__statelyaiNative.baselineNative = + getClonedJSON(source); return graph; } function getProperty(value: any, key: string): GraphSONTypedValue { @@ -960,7 +962,15 @@ export function toGraphSON( ? undefined : getNativeCarrier(graph.data, "graphson"); if (!carrier) return toRawGraphSON(graph, options); - const document = graphsonDocumentConverter.from(carrier.native); + const originalSource = ( + carrier as typeof carrier & { baselineNative?: GraphSONGraph } + ).baselineNative; + if (originalSource === undefined) + throw new Error("GraphSON: missing native provenance baseline"); + const document = graphsonDocumentConverter.from(originalSource); + if (!isJSONEqual(document.baseline.graph, carrier.baseline)) + throw new Error("GraphSON: invalid native provenance baseline"); + document.source = carrier.native; document.graph = { ...getClonedJSON(graph), data: getCarrierData(graph.data, "graphson"), diff --git a/src/formats/rdf/index.ts b/src/formats/rdf/index.ts index 81206bf..0a0cf0d 100644 --- a/src/formats/rdf/index.ts +++ b/src/formats/rdf/index.ts @@ -103,8 +103,8 @@ function getJSONSource(value: unknown): void { if (prototype !== Object.prototype && prototype !== null && !(Array.isArray(current) && prototype === Array.prototype) || Object.getOwnPropertySymbols(current).length) throw new Error('RDF: dataset requires plain JSON objects/arrays'); if (Array.isArray(current) && Object.keys(current).length !== current.length) throw new Error('RDF: sparse/extended arrays are not plain dataset JSON'); active.add(current); pending.push({ value: current, exit: true }); - for (const property of Object.values(Object.getOwnPropertyDescriptors(current))) { - if (property.get || property.set) throw new Error('RDF: dataset JSON cannot contain accessors'); + for (const [name, property] of Object.entries(Object.getOwnPropertyDescriptors(current))) { + if (property.get || property.set || !property.enumerable && !(Array.isArray(current) && name === 'length')) throw new Error('RDF: dataset JSON cannot contain accessors/nonenumerable properties'); if (property.enumerable) pending.push({ value: property.value }); } } diff --git a/tests/cx2.test.ts b/tests/cx2.test.ts index d05f29a..6f60398 100644 --- a/tests/cx2.test.ts +++ b/tests/cx2.test.ts @@ -80,6 +80,25 @@ it('fresh export accepts canonical numeric identities and rejects arbitrary IDs' graph.edges[0].sourceId = 'named'; expect(() => toCX2(graph, { nativeCarrier: false })).toThrow(/canonical/); }); +it('widens inferred mixed numeric scalars and lists regardless of value order', () => { + const graph = createGraph({ + nodes: [ + { + id: '0', + data: { attributes: { score: 1, samples: [1, 1.5] } }, + }, + { id: '1', data: { attributes: { score: 1.5 } } }, + ], + }); + const output: any = toCX2(graph); + const declarations = output[1].attributeDeclarations[0].nodes; + expect(declarations.score.d).toBe('double'); + expect(declarations.samples.d).toBe('list_of_double'); + expect(fromCX2(output).nodes.map((node) => node.data.attributes?.score)).toEqual([ + 1, + 1.5, + ]); +}); it.each([ (s: any) => s[2].nodes.push({ id: 0 }), (s: any) => (s[3].edges[0].t = 99), diff --git a/tests/dgml.test.ts b/tests/dgml.test.ts index 6793ce0..e4609ea 100644 --- a/tests/dgml.test.ts +++ b/tests/dgml.test.ts @@ -186,6 +186,15 @@ it.each([ expect(fromDGML(xml).nodes[1].data.properties!.Count).toEqual(expected); } ); +it('keeps unsupported and untyped declared property values as lexical strings', () => { + const xml = ``; + const graph = fromDGML(xml); + expect(graph.nodes[0].data.properties).toEqual({ + Background: '#fff', + Bounds: '1,2,3,4', + }); + expect(toDGML(graph)).toBe(xml); +}); it.each([ '', '', diff --git a/tests/graphson.test.ts b/tests/graphson.test.ts index 966f4f8..686909d 100644 --- a/tests/graphson.test.ts +++ b/tests/graphson.test.ts @@ -216,6 +216,15 @@ describe("explicit typed GraphSON3 tinker:graph", () => { expect(graph.edges[0].data).toBe(42); expect(toGraphSON(graph)).toEqual(source); }); + it("retains independent native label edits when the Graph projection is unchanged", () => { + const source = createNative(); + const graph = fromGraphSON(source); + (graph.data as any).__statelyaiNative.native["@value"].vertices[0][ + "@value" + ].label = "Native"; + const output = toGraphSON(graph); + expect(output["@value"].vertices[0]["@value"].label).toBe("Native"); + }); it("added unknown native properties survive label patches without replacing original user payload", () => { const source = toGraphSON( createGraph({ nodes: [{ id: "a", data: 42, label: "A" }] }), diff --git a/tests/rdf.test.ts b/tests/rdf.test.ts index cc09909..a618147 100644 --- a/tests/rdf.test.ts +++ b/tests/rdf.test.ts @@ -63,6 +63,7 @@ it('rejects non-JSON unknown metadata without silent normalization or role bypas for (const vendor of [undefined, () => 1, BigInt(1), NaN, Infinity, new Date(), [,]]) expect(() => fromRDFDataset({ version: '1.1', quads: [], vendor })).toThrow(/JSON/); const cyclic: any = { version: '1.1', quads: [] }; cyclic.vendor = cyclic; expect(() => fromRDFDataset(cyclic)).toThrow(/cycles/); const source: any = { version: '1.1', quads: [] }; Object.defineProperty(source, 'vendor', { get: () => 'DATA', enumerable: true }); expect(() => fromRDFDataset(source)).toThrow(/accessors/); + const hidden: any = { version: '1.1', quads: [] }; Object.defineProperty(hidden, 'vendor', { value: 'DATA', enumerable: false }); expect(() => fromRDFDataset(hidden)).toThrow(/nonenumerable/); }); it('RDF 1.1 accepts rdf:dirLangString as an ordinary unknown datatype without direction fields', () => {