From 51741f8a5e9a37b733df5bd4a5397875053d4bde Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 13:05:47 +0100 Subject: [PATCH 01/28] docs(sysprom): record monorepo and web viewer decision (DEC52) DEC52 adopts a pnpm workspace with a browser-safe @sysprom/core and an unpublished Vite viewer on GitHub Pages; CHG50 tracks the conversion; INV33 guards the published sysprom package contract through the split. --- .SysProM.json | 55 ++++++++++++++++++++++++++++++++++++++++++ .SysProM/CHANGES.md | 17 +++++++++++++ .SysProM/DECISIONS.md | 15 ++++++++++++ .SysProM/INVARIANTS.md | 4 +++ 4 files changed, 91 insertions(+) diff --git a/.SysProM.json b/.SysProM.json index 14c8365..c1e53be 100644 --- a/.SysProM.json +++ b/.SysProM.json @@ -4094,6 +4094,51 @@ "src/operations/graph-shared.ts,src/operations/graph.ts,src/json-to-md.ts,src/cli/commands/graph.ts,src/cli/commands/json2md.ts" ], "type": "change" + }, + { + "context": "We want a GitHub Pages-hosted web app for visualising SysProM documents. The single sysprom package cannot be imported by a browser app because the library modules call node:fs, node:path, and node:crypto directly (io.ts, sync.ts, the multi-doc conversions, three speckit ops), and the operations barrel re-exports syncDocumentsOp, dragging node:fs into every barrel import.", + "id": "DEC52", + "name": "Adopt pnpm workspace monorepo with a browser-safe core and a Vite viewer", + "options": [ + { + "description": "Convert to a pnpm workspace; extract a browser-safe @sysprom/core; keep filesystem access in @sysprom/node; preserve the sysprom package via a cli re-export; add an unpublished Vite viewer", + "id": "MONO" + }, + { + "description": "Keep sysprom as-is; build the viewer in a second repo that imports sysprom from npm", + "id": "SEPARATE" + }, + { + "description": "Add the viewer as a subpath of the current single package with node:fs polyfills for the browser", + "id": "SINGLE" + } + ], + "rationale": "A workspace lets the viewer consume core source directly via workspace links with no npm publish lag, and forces the filesystem isolation the library already needs so domain logic is isomorphic behind a storage boundary. A separate repo adds a publish lag for every core change the UI needs. A single-package browser entry needs node:fs and node:path polyfills rather than fixing the boundary. pnpm and Turbo are already in use, so the workspace conversion cost is low; all packages ship together, so unified versioning applies.", + "selected": "MONO", + "type": "decision" + }, + { + "id": "CHG50", + "lifecycle": { + "introduced": true + }, + "name": "Convert to monorepo; extract browser-safe core; add Vite viewer", + "scope": [ + "packages/core", + "packages/node", + "packages/cli", + "packages/mcp", + "packages/web", + "build", + "ci" + ], + "type": "change" + }, + { + "description": "The published sysprom package contract is preserved across the monorepo split: the sysprom, spm, and sysprom-mcp CLI binaries continue to install and run, and every name currently importable from sysprom remains exported with its existing signature. Existing Node consumers see no change.", + "id": "INV33", + "name": "Published Package Contract Stability", + "type": "invariant" } ], "relationships": [ @@ -5266,6 +5311,16 @@ "from": "CHG49", "to": "DEC51", "type": "implements" + }, + { + "from": "CHG50", + "to": "DEC52", + "type": "implements" + }, + { + "from": "DEC52", + "to": "INV33", + "type": "must_preserve" } ] } diff --git a/.SysProM/CHANGES.md b/.SysProM/CHANGES.md index 5ed4420..9cef798 100644 --- a/.SysProM/CHANGES.md +++ b/.SysProM/CHANGES.md @@ -826,3 +826,20 @@ Scope: Scope: - src/operations/graph-shared.ts,src/operations/graph.ts,src/json-to-md.ts,src/cli/commands/graph.ts,src/cli/commands/json2md.ts +### CHG50 — Convert to monorepo; extract browser-safe core; add Vite viewer + +- Implements: [DEC52](./DECISIONS.md#dec52--adopt-pnpm-workspace-monorepo-with-a-browser-safe-core-and-a-vite-viewer) + +Scope: +- packages/core +- packages/node +- packages/cli +- packages/mcp +- packages/web +- build +- ci + +#### Lifecycle + +- [x] introduced + diff --git a/.SysProM/DECISIONS.md b/.SysProM/DECISIONS.md index fe0436f..f84a835 100644 --- a/.SysProM/DECISIONS.md +++ b/.SysProM/DECISIONS.md @@ -1006,3 +1006,18 @@ Chosen: OPT-A Rationale: Anchor-based links provide the best UX for embedded diagrams since clicking navigates directly to the node definition. External reference links serve standalone use cases. +### DEC52 — Adopt pnpm workspace monorepo with a browser-safe core and a Vite viewer + +- Must preserve: [INV33](./INVARIANTS.md#inv33--published-package-contract-stability) + +Context: We want a GitHub Pages-hosted web app for visualising SysProM documents. The single sysprom package cannot be imported by a browser app because the library modules call node:fs, node:path, and node:crypto directly (io.ts, sync.ts, the multi-doc conversions, three speckit ops), and the operations barrel re-exports syncDocumentsOp, dragging node:fs into every barrel import. + +Options: +- MONO: Convert to a pnpm workspace; extract a browser-safe @sysprom/core; keep filesystem access in @sysprom/node; preserve the sysprom package via a cli re-export; add an unpublished Vite viewer +- SEPARATE: Keep sysprom as-is; build the viewer in a second repo that imports sysprom from npm +- SINGLE: Add the viewer as a subpath of the current single package with node:fs polyfills for the browser + +Chosen: MONO + +Rationale: A workspace lets the viewer consume core source directly via workspace links with no npm publish lag, and forces the filesystem isolation the library already needs so domain logic is isomorphic behind a storage boundary. A separate repo adds a publish lag for every core change the UI needs. A single-package browser entry needs node:fs and node:path polyfills rather than fixing the boundary. pnpm and Turbo are already in use, so the workspace conversion cost is low; all packages ship together, so unified versioning applies. + diff --git a/.SysProM/INVARIANTS.md b/.SysProM/INVARIANTS.md index e4a4c07..5aa80ea 100644 --- a/.SysProM/INVARIANTS.md +++ b/.SysProM/INVARIANTS.md @@ -153,6 +153,10 @@ Setting a node to status: retired via updateNode must report all active nodes th When both JSON and Markdown representations of a SysProM document exist, mutations via the CLI must automatically keep them in sync. Users should not need to manually run json2md or md2json after every change. +### INV33 — Published Package Contract Stability + +The published sysprom package contract is preserved across the monorepo split: the sysprom, spm, and sysprom-mcp CLI binaries continue to install and run, and every name currently importable from sysprom remains exported with its existing signature. Existing Node consumers see no change. + ## Principles ### PRIN1 — Separate What From Why From How From c6474b3f263ff8692238016f713a197958a50314 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 13:46:04 +0100 Subject: [PATCH 02/28] build(workspace): add pnpm workspace and @sysprom/core package Establishes a pnpm workspace with packages/* and adds the @sysprom/core package skeleton: package.json (private, browser-safe, workspace dep on zod matching root), tsconfig split (build config emits src only; base config typechecks src+tests with node types), and a shared tsconfig.base.json. Root tsconfig now extends the base and adds a path mapping so the root type-checker resolves @sysprom/core to its source (avoids recursive-type elision mismatches between source and compiled .d.ts). Turbo tasks wire root typecheck/test/build to depend on @sysprom/core#build. --- packages/core/package.json | 40 +++++++++++++++++++++++++++++++ packages/core/tsconfig.build.json | 10 ++++++++ packages/core/tsconfig.json | 9 +++++++ pnpm-workspace.yaml | 3 +++ tsconfig.base.json | 12 ++++++++++ 5 files changed, 74 insertions(+) create mode 100644 packages/core/package.json create mode 100644 packages/core/tsconfig.build.json create mode 100644 packages/core/tsconfig.json create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..f547c3f --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,40 @@ +{ + "name": "@sysprom/core", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./operations/index.js": { + "types": "./dist/operations/index.d.ts", + "import": "./dist/operations/index.js" + }, + "./operations/graph-shared.js": { + "types": "./dist/operations/graph-shared.d.ts", + "import": "./dist/operations/graph-shared.js" + }, + "./speckit/plan.js": { + "types": "./dist/speckit/plan.d.ts", + "import": "./dist/speckit/plan.js" + } + }, + "files": ["dist/**"], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "tsx --test tests/*.test.ts" + }, + "dependencies": { + "zod": "4.3.6" + }, + "devDependencies": { + "@types/node": "25.5.0", + "tsx": "4.21.0", + "typescript": "6.0.2" + } +} diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 0000000..50356b2 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": false + }, + "include": ["src/**/*.ts"], + "exclude": ["tests/**/*.ts"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..d90253f --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..07da0b2 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "." + - "packages/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..aa3c473 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "declaration": true + } +} From 432b81302ee9d93b8d6d2afae9f448a0518348f8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 14:40:53 +0100 Subject: [PATCH 03/28] refactor(core): extract browser-safe pure library into @sysprom/core Moves the pure (no Node filesystem) library logic into packages/core: schema, text, canonical-json, lifecycle-state, endpoint-types, utils, speckit/plan, and all operations except the fs-backed sync and speckit-{import,export,diff,sync} which stay at root. speckit-export remains at root because generate.ts transitively imports node:fs. Conversion split: json-to-md and md-to-json are split so the pure rendering/parsing lives in core (jsonToMarkdownSingle, renderMultiDoc, markdownSingleToJson, parseMultiDoc) and the fs wrappers (jsonToMarkdownMultiDoc, markdownMultiDocToJson, jsonToMarkdown, markdownToJson) stay at root as thin delegates over the pure helpers. Root src/index.ts re-exports * from @sysprom/core plus the fs wrappers, io, sync (syncDocumentsOp, detectChanges), and Spec-Kit entrypoints. The public export surface is unchanged (additions only; no name removed). All root modules referencing moved files now import from @sysprom/core; the root tsconfig adds a path mapping so the type-checker resolves @sysprom/core to its source (avoids recursive-type elision mismatches between source and compiled .d.ts). Eslint config extends the existing src/ rule relaxations and test overrides to packages/core so the moved code keeps its lint posture. Turbo wires root typecheck/test/build to depend on @sysprom/core#build. --- eslint.config.ts | 6 +- package.json | 1 + {src => packages/core/src}/canonical-json.ts | 0 {src => packages/core/src}/endpoint-types.ts | 0 packages/core/src/index.ts | 124 +++ packages/core/src/json-to-md.ts | 878 ++++++++++++++++++ {src => packages/core/src}/lifecycle-state.ts | 0 packages/core/src/md-to-json.ts | 690 ++++++++++++++ .../src}/operations/add-external-reference.ts | 0 .../core/src}/operations/add-node.ts | 0 .../core/src}/operations/add-relationship.ts | 0 .../core/src}/operations/check.ts | 0 .../core/src}/operations/define-operation.ts | 0 .../core/src}/operations/graph-decision.ts | 0 .../core/src}/operations/graph-dependency.ts | 0 .../core/src}/operations/graph-refinement.ts | 0 .../core/src}/operations/graph-shared.ts | 0 .../core/src}/operations/graph.ts | 0 packages/core/src/operations/index.ts | 78 ++ .../src}/operations/infer-completeness.ts | 0 .../core/src}/operations/infer-derived.ts | 0 .../core/src}/operations/infer-impact.ts | 0 .../core/src}/operations/infer-lifecycle.ts | 0 .../core/src}/operations/init-document.ts | 0 .../core/src}/operations/json-to-markdown.ts | 0 .../core/src}/operations/markdown-to-json.ts | 0 .../core/src}/operations/next-id.ts | 0 .../core/src}/operations/node-history.ts | 0 .../core/src}/operations/plan-add-task.ts | 0 .../src}/operations/plan-complete-task.ts | 0 .../core/src}/operations/plan-gate.ts | 0 .../core/src}/operations/plan-init.ts | 0 .../core/src}/operations/plan-progress.ts | 0 .../core/src}/operations/plan-reopen-task.ts | 0 .../core/src}/operations/plan-start-task.ts | 0 .../core/src}/operations/plan-status.ts | 0 .../core/src}/operations/query-node.ts | 0 .../core/src}/operations/query-nodes.ts | 0 .../operations/query-relationship-types.ts | 0 .../src}/operations/query-relationships.ts | 0 .../operations/remove-external-reference.ts | 0 .../core/src}/operations/remove-node.ts | 0 .../src}/operations/remove-relationship.ts | 0 .../core/src}/operations/rename.ts | 0 .../core/src}/operations/search.ts | 0 .../core/src}/operations/state-at.ts | 0 .../core/src}/operations/stats.ts | 0 .../core/src}/operations/timeline.ts | 0 .../core/src}/operations/trace-from-node.ts | 0 .../core/src}/operations/update-metadata.ts | 0 .../core/src}/operations/update-node.ts | 0 .../core/src}/operations/validate.ts | 0 {src => packages/core/src}/schema.ts | 0 {src => packages/core/src}/speckit/plan.ts | 0 {src => packages/core/src}/text.ts | 0 .../core/src}/utils/define-schema.ts | 0 pnpm-lock.yaml | 19 + src/cli/commands/add.ts | 2 +- src/cli/commands/graph.ts | 2 +- src/cli/commands/infer.ts | 8 +- src/cli/commands/json2md.ts | 2 +- src/cli/commands/md2json.ts | 2 +- src/cli/commands/plan.ts | 2 +- src/cli/commands/query.ts | 11 +- src/cli/commands/search.ts | 2 +- src/cli/commands/speckit.ts | 2 +- src/cli/commands/sync.ts | 3 +- src/cli/commands/update.ts | 2 +- src/cli/shared.ts | 2 +- src/generate-schema.ts | 3 +- src/index.ts | 138 +-- src/io.ts | 3 +- src/json-to-md.ts | 873 +---------------- src/mcp/server.ts | 6 +- src/md-to-json.ts | 695 +------------- src/operations/index.ts | 84 +- src/operations/speckit-diff.ts | 3 +- src/operations/speckit-export.ts | 3 +- src/operations/speckit-import.ts | 3 +- src/operations/speckit-sync.ts | 3 +- src/operations/sync.ts | 3 +- src/speckit/generate.ts | 8 +- src/speckit/index.ts | 4 +- src/speckit/parse.ts | 2 +- src/sync.ts | 2 +- tsconfig.json | 15 +- turbo.json | 29 +- 87 files changed, 1962 insertions(+), 1751 deletions(-) rename {src => packages/core/src}/canonical-json.ts (100%) rename {src => packages/core/src}/endpoint-types.ts (100%) create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/json-to-md.ts rename {src => packages/core/src}/lifecycle-state.ts (100%) create mode 100644 packages/core/src/md-to-json.ts rename {src => packages/core/src}/operations/add-external-reference.ts (100%) rename {src => packages/core/src}/operations/add-node.ts (100%) rename {src => packages/core/src}/operations/add-relationship.ts (100%) rename {src => packages/core/src}/operations/check.ts (100%) rename {src => packages/core/src}/operations/define-operation.ts (100%) rename {src => packages/core/src}/operations/graph-decision.ts (100%) rename {src => packages/core/src}/operations/graph-dependency.ts (100%) rename {src => packages/core/src}/operations/graph-refinement.ts (100%) rename {src => packages/core/src}/operations/graph-shared.ts (100%) rename {src => packages/core/src}/operations/graph.ts (100%) create mode 100644 packages/core/src/operations/index.ts rename {src => packages/core/src}/operations/infer-completeness.ts (100%) rename {src => packages/core/src}/operations/infer-derived.ts (100%) rename {src => packages/core/src}/operations/infer-impact.ts (100%) rename {src => packages/core/src}/operations/infer-lifecycle.ts (100%) rename {src => packages/core/src}/operations/init-document.ts (100%) rename {src => packages/core/src}/operations/json-to-markdown.ts (100%) rename {src => packages/core/src}/operations/markdown-to-json.ts (100%) rename {src => packages/core/src}/operations/next-id.ts (100%) rename {src => packages/core/src}/operations/node-history.ts (100%) rename {src => packages/core/src}/operations/plan-add-task.ts (100%) rename {src => packages/core/src}/operations/plan-complete-task.ts (100%) rename {src => packages/core/src}/operations/plan-gate.ts (100%) rename {src => packages/core/src}/operations/plan-init.ts (100%) rename {src => packages/core/src}/operations/plan-progress.ts (100%) rename {src => packages/core/src}/operations/plan-reopen-task.ts (100%) rename {src => packages/core/src}/operations/plan-start-task.ts (100%) rename {src => packages/core/src}/operations/plan-status.ts (100%) rename {src => packages/core/src}/operations/query-node.ts (100%) rename {src => packages/core/src}/operations/query-nodes.ts (100%) rename {src => packages/core/src}/operations/query-relationship-types.ts (100%) rename {src => packages/core/src}/operations/query-relationships.ts (100%) rename {src => packages/core/src}/operations/remove-external-reference.ts (100%) rename {src => packages/core/src}/operations/remove-node.ts (100%) rename {src => packages/core/src}/operations/remove-relationship.ts (100%) rename {src => packages/core/src}/operations/rename.ts (100%) rename {src => packages/core/src}/operations/search.ts (100%) rename {src => packages/core/src}/operations/state-at.ts (100%) rename {src => packages/core/src}/operations/stats.ts (100%) rename {src => packages/core/src}/operations/timeline.ts (100%) rename {src => packages/core/src}/operations/trace-from-node.ts (100%) rename {src => packages/core/src}/operations/update-metadata.ts (100%) rename {src => packages/core/src}/operations/update-node.ts (100%) rename {src => packages/core/src}/operations/validate.ts (100%) rename {src => packages/core/src}/schema.ts (100%) rename {src => packages/core/src}/speckit/plan.ts (100%) rename {src => packages/core/src}/text.ts (100%) rename {src => packages/core/src}/utils/define-schema.ts (100%) diff --git a/eslint.config.ts b/eslint.config.ts index f14535f..9ec8b83 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -256,7 +256,7 @@ export default defineConfig( }, }, { - files: ["src/**/*.ts"], + files: ["src/**/*.ts", "packages/core/src/**/*.ts"], rules: { "@typescript-eslint/no-unnecessary-condition": "off", "sonarjs/cognitive-complexity": "off", @@ -271,7 +271,7 @@ export default defineConfig( }, }, { - files: ["tests/**/*.ts"], + files: ["tests/**/*.ts", "packages/core/tests/**/*.ts"], rules: { "@typescript-eslint/consistent-type-assertions": [ "error", @@ -310,7 +310,7 @@ export default defineConfig( { ignores: [ ".claude/worktrees/**", - "dist/", + "**/dist/", "node_modules/", "docs/", "commitlint.config.ts", diff --git a/package.json b/package.json index 03de331..7c7dd37 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", + "@sysprom/core": "workspace:*", "commander": "14.0.3", "picocolors": "1.1.1", "zod": "4.3.6" diff --git a/src/canonical-json.ts b/packages/core/src/canonical-json.ts similarity index 100% rename from src/canonical-json.ts rename to packages/core/src/canonical-json.ts diff --git a/src/endpoint-types.ts b/packages/core/src/endpoint-types.ts similarity index 100% rename from src/endpoint-types.ts rename to packages/core/src/endpoint-types.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..4d46ba2 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,124 @@ +/** + * `@sysprom/core` — browser-safe SysProM library logic. + * + * Contains the pure (no Node filesystem) subset of SysProM: schema types and + * validators, domain operations, JSON ↔ Markdown conversion (pure helpers + * only), and utilities. The root `sysprom` package re-exports everything here + * and adds the fs-backed wrappers (loadDocument/saveDocument, multi-doc file + * I/O, sync, Spec-Kit interoperability). + * + * This package must not import any `node:*` built-in. Downstream bundlers + * (Vite, esbuild, webpack) can include it with zero Node polyfills. + * @packageDocumentation + */ + +// Schema types and validators +export { + SysProMDocument, + Node, + Relationship, + NodeType, + NodeStatus, + RelationshipType, + ImpactPolarity, + Text, + Option, + Operation, + ExternalReference, + ExternalReferenceRole, + Metadata, + NODE_TYPE_LABELS, + NODE_LABEL_TO_TYPE, + RELATIONSHIP_TYPE_LABELS, + RELATIONSHIP_LABEL_TO_TYPE, + IMPACT_POLARITY_LABELS, + EXTERNAL_REFERENCE_ROLE_LABELS, + EXTERNAL_REFERENCE_LABEL_TO_ROLE, + NODE_STATUSES, + NODE_FILE_MAP, + NODE_ID_PREFIX, + toJSONSchema, +} from "./schema.js"; + +// Operations (single source of truth for domain logic + metadata) +export { + defineOperation, + type OperationDef, + type DefinedOperation, + addNodeOp, + removeNodeOp, + updateNodeOp, + addRelationshipOp, + removeRelationshipOp, + updateMetadataOp, + nextIdOp, + initDocumentOp, + planInitOp, + planAddTaskOp, + planStartTaskOp, + planCompleteTaskOp, + planReopenTaskOp, + planStatusOp, + planProgressOp, + planGateOp, + queryNodesOp, + queryNodeOp, + queryRelationshipsOp, + traceFromNodeOp, + timelineOp, + nodeHistoryOp, + stateAtOp, + validateOp, + statsOp, + searchOp, + checkOp, + graphOp, + renameOp, + jsonToMarkdownOp, + markdownToJsonOp, + inferCompletenessOp, + inferLifecycleOp, + inferImpactOp, + impactSummaryOp, + inferDerivedOp, + type RemoveResult, + type ValidationResult, + type DocumentStats, + type NodeDetail, + type TraceNode, + type TimelineEvent, + type NodeState, + type PlanStatusResult, + type PhaseProgressResult, + type GateResultOutput, + type CompletenessOutput, + type LifecycleOutput, + type ImpactOutput, + type ImpactSummaryOutput, + type DerivedOutput, +} from "./operations/index.js"; + +// Conversion (pure — fs wrappers live in the root `sysprom` package) +export { + jsonToMarkdownSingle, + renderMultiDoc, + type ConvertOptions, +} from "./json-to-md.js"; + +export { markdownSingleToJson, parseMultiDoc } from "./md-to-json.js"; + +// Endpoint types +export { + RELATIONSHIP_ENDPOINT_TYPES, + isValidEndpointPair, +} from "./endpoint-types.js"; + +// Utilities +export { canonicalise, type FormatOptions } from "./canonical-json.js"; +export { + textToString, + textToLines, + textToMarkdown, + markdownToText, +} from "./text.js"; +export { hasLifecycleState, primaryLifecycleState } from "./lifecycle-state.js"; diff --git a/packages/core/src/json-to-md.ts b/packages/core/src/json-to-md.ts new file mode 100644 index 0000000..41384b4 --- /dev/null +++ b/packages/core/src/json-to-md.ts @@ -0,0 +1,878 @@ +import { + type SysProMDocument, + type Node, + type Relationship, + type ExternalReference, + type Text, + NODE_FILE_MAP, + NODE_TYPE_LABELS, + NodeType, + RelationshipType, + RELATIONSHIP_TYPE_LABELS, +} from "./schema.js"; +import { primaryLifecycleState } from "./lifecycle-state.js"; +import { graphOp } from "./operations/graph.js"; +import { graphRefinementOp } from "./operations/graph-refinement.js"; +import { graphDecisionOp } from "./operations/graph-decision.js"; +import { graphDependencyOp } from "./operations/graph-dependency.js"; + +// --------------------------------------------------------------------------- +// Text helpers +// --------------------------------------------------------------------------- + +function renderText(value: Text): string { + return Array.isArray(value) ? value.join("\n") : value; +} + +function renderFrontMatter(fields: Record): string { + const lines = ["---"]; + for (const [key, value] of Object.entries(fields)) { + if (value === undefined) continue; + if (typeof value === "number") { + lines.push(`${key}: ${String(value)}`); + } else { + lines.push(`${key}: ${JSON.stringify(value)}`); + } + } + lines.push("---"); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Node location map (for hyperlinking) +// --------------------------------------------------------------------------- + +/** + * GitHub-compatible heading anchor slug. + * @param text + * @example + * // Convert a heading into a GitHub-style slug + * // slugify('ID — Name') // 'id---name' + */ +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^\w\s-]/g, "") + .replace(/\s/g, "-"); +} +/** Heading anchor for a node: `id--name` slugified from `### ID — Name`. */ +function nodeAnchor(n: Node): string { + return slugify(`${n.id} — ${n.name}`); +} + +interface NodeLocation { + file: string; + anchor: string; +} + +type NodeLocationMap = Map; + +/** Build a map from node ID to its markdown file and heading anchor. */ +function buildNodeLocationMap( + nodes: Node[], + mode: "single-file" | "multi-doc", +): NodeLocationMap { + const map: NodeLocationMap = new Map(); + for (const n of nodes) { + const anchor = nodeAnchor(n); + if (mode === "single-file") { + map.set(n.id, { file: "", anchor }); + } else { + const file = fileForNodeType(n.type); + map.set(n.id, { file, anchor }); + } + } + return map; +} +/** Determine the markdown file a node type belongs to in multi-doc mode. */ +function fileForNodeType(type: string): string { + for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) { + if (types.includes(type)) return `${fileName}.md`; + } + return "README.md"; +} + +/** + * Format a node ID as a markdown hyperlink. + * In single-file mode: `[ID](#anchor)` + * In multi-doc mode: `[ID](./FILE.md#anchor)` + * Falls back to plain ID if the node isn't in the map. + * @param id + * @param nodeMap + * @param currentFile + * @example + */ +function linkNodeId( + id: string, + nodeMap: NodeLocationMap, + currentFile?: string, +): string { + const loc = nodeMap.get(id); + if (!loc) return id; + if (loc.file === "" || loc.file === currentFile) { + return `[${id}](#${loc.anchor})`; + } + return `[${id}](./${loc.file}#${loc.anchor})`; +} + +// --------------------------------------------------------------------------- +// Relationship lookups +// --------------------------------------------------------------------------- + +type RelIndex = Map; + +function indexRelationshipsFrom(rels: Relationship[]): RelIndex { + const idx: RelIndex = new Map(); + for (const r of rels) { + const list = idx.get(r.from); + if (list) list.push(r); + else idx.set(r.from, [r]); + } + return idx; +} + +// --------------------------------------------------------------------------- +// Node rendering +// --------------------------------------------------------------------------- + +// Canonical lifecycle stage orderings from PROT1 (decision), PROT2 (change), PROT3 (node). +// Keys not in any ordering are appended at the end in their original order. +const LIFECYCLE_ORDER: readonly string[] = [ + "proposed", + "accepted", + "active", + "adopted", + "implemented", + "defined", + "introduced", + "in_progress", + "complete", + "consolidated", + "experimental", + "deprecated", + "retired", + "superseded", + "abandoned", + "deferred", +]; + +function renderLifecycle( + lifecycle: Record, +): string[] { + const entries = Object.entries(lifecycle); + entries.sort(([a], [b]) => { + const ai = LIFECYCLE_ORDER.indexOf(a); + const bi = LIFECYCLE_ORDER.indexOf(b); + // Unknown keys sort after known ones, preserving relative order + if (ai === -1 && bi === -1) return 0; + if (ai === -1) return 1; + if (bi === -1) return -1; + return ai - bi; + }); + return entries.map(([state, done]) => { + const checkbox = done ? "x" : " "; + const label = state.replace(/_/g, " "); + if (typeof done === "string") { + return `- [${checkbox}] ${label} (${done})`; + } + return `- [${checkbox}] ${label}`; + }); +} + +function renderNodeRelationships( + nodeId: string, + fromIdx: RelIndex, + nodeMap: NodeLocationMap, + currentFile?: string, +): string[] { + const rels = fromIdx.get(nodeId); + if (!rels || rels.length === 0) return []; + + const grouped = new Map(); + for (const r of rels) { + const list = grouped.get(r.type); + if (list) list.push(r.to); + else grouped.set(r.type, [r.to]); + } + + const lines: string[] = []; + for (const [type, targets] of grouped) { + const label = RelationshipType.is(type) + ? RELATIONSHIP_TYPE_LABELS[type] + : type; + if (targets.length === 1) { + lines.push(`- ${label}: ${linkNodeId(targets[0], nodeMap, currentFile)}`); + } else { + lines.push(`- ${label}:`); + for (const t of targets) { + lines.push(` - ${linkNodeId(t, nodeMap, currentFile)}`); + } + } + } + return lines; +} + +function renderExternalReferences(refs: ExternalReference[]): string[] { + if (refs.length === 0) return []; + const lines = ["", "#### External References", ""]; + for (const ref of refs) { + lines.push(`- ${ref.role}: ${ref.identifier}`); + if (ref.description) { + lines.push(` - ${renderText(ref.description)}`); + } + if (ref.internalised) { + lines.push(` - Internalised: ${renderText(ref.internalised)}`); + } + } + return lines; +} + +function renderNode( + n: Node, + headingLevel: number, + fromIdx: RelIndex, + nodeMap: NodeLocationMap, + currentFile?: string, +): string[] { + const prefix = "#".repeat(headingLevel); + const lines: string[] = []; + + lines.push(`${prefix} ${n.id} — ${n.name}`); + lines.push(""); + + if (n.description) { + lines.push(renderText(n.description)); + lines.push(""); + } + + const rels = renderNodeRelationships(n.id, fromIdx, nodeMap, currentFile); + if (rels.length > 0) { + lines.push(...rels); + lines.push(""); + } + + // Decision fields + if (n.context) { + lines.push(`Context: ${renderText(n.context)}`); + lines.push(""); + } + if (n.options && n.options.length > 0) { + lines.push("Options:"); + for (const o of n.options) { + lines.push(`- ${o.id}: ${renderText(o.description)}`); + } + lines.push(""); + } + if (n.selected) { + lines.push(`Chosen: ${n.selected}`); + lines.push(""); + } + if (n.rationale) { + lines.push(`Rationale: ${renderText(n.rationale)}`); + lines.push(""); + } + + // Change fields + if (n.scope && n.scope.length > 0) { + lines.push("Scope:"); + for (const s of n.scope) { + lines.push(`- ${s}`); + } + lines.push(""); + } + if (n.operations && n.operations.length > 0) { + lines.push("Operations:"); + for (const op of n.operations) { + const parts: string[] = [op.type]; + if (op.target) parts.push(op.target); + if (op.description) parts.push(`— ${renderText(op.description)}`); + lines.push(`- ${parts.join(" ")}`); + } + lines.push(""); + } + // Lifecycle + if (n.lifecycle) { + lines.push(`${"#".repeat(headingLevel + 1)} Lifecycle`); + lines.push(""); + lines.push(...renderLifecycle(n.lifecycle)); + lines.push(""); + } + + // Propagation + if (n.propagation) { + lines.push(`${"#".repeat(headingLevel + 1)} Propagation`); + lines.push(""); + lines.push(...renderLifecycle(n.propagation)); + lines.push(""); + } + + // View includes + if (n.includes && n.includes.length > 0) { + lines.push("Includes:"); + for (const inc of n.includes) { + lines.push(`- ${linkNodeId(inc, nodeMap, currentFile)}`); + } + lines.push(""); + } + + // Inline external references + if (n.external_references && n.external_references.length > 0) { + lines.push(...renderExternalReferences(n.external_references)); + lines.push(""); + } + + // Subsystem note + if (n.subsystem) { + lines.push(`${"#".repeat(headingLevel + 1)} Subsystem`); + lines.push(""); + const subNodes = n.subsystem.nodes; + const subRels = n.subsystem.relationships ?? []; + const subIdx = indexRelationshipsFrom(subRels); + const subMap = buildNodeLocationMap(subNodes, "single-file"); + for (const sub of subNodes) { + lines.push(...renderNode(sub, headingLevel + 2, subIdx, subMap)); + } + } + + return lines; +} + +// --------------------------------------------------------------------------- +// File generators +// --------------------------------------------------------------------------- + +function renderNodesGrouped( + nodes: Node[], + types: string[], + fromIdx: RelIndex, + headingLevel: number, + nodeMap: NodeLocationMap, + currentFile?: string, +): string[] { + const lines: string[] = []; + for (const type of types) { + const matching = nodes.filter((n) => n.type === type); + if (matching.length === 0) continue; + + const label = NodeType.is(type) ? NODE_TYPE_LABELS[type] : type; + lines.push(`${"#".repeat(headingLevel)} ${label}`); + lines.push(""); + + for (const n of matching) { + lines.push( + ...renderNode(n, headingLevel + 1, fromIdx, nodeMap, currentFile), + ); + } + } + return lines; +} + +function generateReadme( + doc: SysProMDocument, + fromIdx: RelIndex, + nodeMap: NodeLocationMap, +): string { + const lines: string[] = []; + const title = doc.metadata?.title ?? "SysProM"; + + lines.push( + renderFrontMatter({ + ...(doc.$schema ? { $schema: doc.$schema } : {}), + title, + doc_type: doc.metadata?.doc_type ?? "sysprom", + scope: doc.metadata?.scope, + status: doc.metadata?.status, + version: doc.metadata?.version, + }), + ); + lines.push(""); + lines.push(`# ${title}`); + lines.push(""); + + // Intent description + const intent = doc.nodes.find((n) => n.type === "intent"); + if (intent?.description) { + lines.push(renderText(intent.description)); + lines.push(""); + } + + // Determine which files will exist based on present node types + const presentFiles: { file: string; label: string; role: string }[] = []; + const fileDescriptions: Record = { + INTENT: { + label: "Understand why this exists", + role: "Enduring purpose, concepts, capabilities", + }, + INVARIANTS: { + label: "Understand what must always hold", + role: "Rules that must hold across all valid states", + }, + STATE: { + label: "Understand what currently exists", + role: "Current structure and active elements", + }, + DECISIONS: { + label: "Understand why things are the way they are", + role: "Choices and rationale", + }, + CHANGES: { + label: "Understand how it has evolved", + role: "Evolution over time", + }, + }; + + for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) { + if (doc.nodes.some((n) => types.includes(n.type))) { + const desc = fileDescriptions[fileName]; + presentFiles.push({ file: fileName, ...desc }); + } + } + + // Navigation — only link to files that exist + if (presentFiles.length > 0) { + lines.push("## Navigation"); + lines.push(""); + for (const { file, label } of presentFiles) { + lines.push(`### ${label}`); + lines.push(`See: [${file}.md](./${file}.md)`); + lines.push(""); + } + + // Document roles table — only include present files + lines.push("## Document Roles"); + lines.push(""); + lines.push("| Document | Role |"); + lines.push("|----------|------|"); + for (const { file, role } of presentFiles) { + lines.push(`| ${file}.md | ${role} |`); + } + lines.push(""); + } + + // Views + const views = doc.nodes.filter((n) => n.type === "view"); + if (views.length > 0) { + lines.push( + ...renderNodesGrouped( + doc.nodes, + ["view"], + fromIdx, + 2, + nodeMap, + "README.md", + ), + ); + } + + // Graph-level external references + if (doc.external_references && doc.external_references.length > 0) { + lines.push("## External References"); + lines.push(""); + for (const ref of doc.external_references) { + const parts = [`- ${ref.role}: ${ref.identifier}`]; + if (ref.node_id) parts.push(` - Node: ${ref.node_id}`); + if (ref.description) parts.push(` - ${renderText(ref.description)}`); + lines.push(...parts); + } + lines.push(""); + } + + return lines.join("\n") + "\n"; +} + +function generateDocFile( + doc: SysProMDocument, + fileName: string, + types: string[], + fromIdx: RelIndex, + nodeMap: NodeLocationMap, +): string { + const lines: string[] = []; + + lines.push( + renderFrontMatter({ + title: fileName.replace(".md", ""), + doc_type: fileName.replace(".md", "").toLowerCase(), + }), + ); + lines.push(""); + lines.push(`# ${fileName.replace(".md", "")}`); + lines.push(""); + lines.push( + ...renderNodesGrouped( + doc.nodes, + types, + fromIdx, + 2, + nodeMap, + `${fileName}.md`, + ), + ); + + return lines.join("\n") + "\n"; +} + +// --------------------------------------------------------------------------- +// Diagram generation for Markdown embedding +// --------------------------------------------------------------------------- + +type DiagramLayout = "LR" | "TD" | "RL" | "BT"; + +interface DiagramOptions { + labelMode?: "friendly" | "compact"; + relationshipLayout?: DiagramLayout; + refinementLayout?: DiagramLayout; + decisionLayout?: DiagramLayout; + dependencyLayout?: DiagramLayout; + clickTargets?: Record; +} + +function generateDiagramsFile( + doc: SysProMDocument, + opts?: DiagramOptions, +): string { + const lines: string[] = []; + + lines.push( + renderFrontMatter({ + title: "Diagrams", + doc_type: "diagrams", + }), + ); + lines.push(""); + lines.push("# Diagrams"); + lines.push(""); + + lines.push("## Relationship Graph"); + lines.push(""); + lines.push("```mermaid"); + lines.push( + graphOp({ + doc, + format: "mermaid", + layout: opts?.relationshipLayout ?? "TD", + labelMode: opts?.labelMode ?? "friendly", + cluster: true, + connectedOnly: false, + clickTargets: opts?.clickTargets, + }), + ); + lines.push("```"); + lines.push(""); + + const refinement = graphRefinementOp({ + doc, + format: "mermaid", + layout: opts?.refinementLayout ?? "TD", + labelMode: opts?.labelMode ?? "friendly", + clickTargets: opts?.clickTargets, + }); + if (refinement.includes("-->")) { + lines.push("## Refinement Chain"); + lines.push(""); + lines.push("```mermaid"); + lines.push(refinement); + lines.push("```"); + lines.push(""); + } + + const decisions = graphDecisionOp({ + doc, + format: "mermaid", + layout: opts?.decisionLayout ?? "TD", + labelMode: opts?.labelMode ?? "friendly", + clickTargets: opts?.clickTargets, + }); + if (decisions.includes("-->")) { + lines.push("## Decision Map"); + lines.push(""); + lines.push("```mermaid"); + lines.push(decisions); + lines.push("```"); + lines.push(""); + } + + const dependencies = graphDependencyOp({ + doc, + format: "mermaid", + layout: opts?.dependencyLayout ?? "LR", + labelMode: opts?.labelMode ?? "friendly", + clickTargets: opts?.clickTargets, + }); + if (dependencies.includes("-->") || dependencies.includes("-.->")) { + lines.push("## Dependency Graph"); + lines.push(""); + lines.push("```mermaid"); + lines.push(dependencies); + lines.push("```"); + lines.push(""); + } + + return lines.join("\n") + "\n"; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Build a click target map from node anchors for use in embedded diagrams. */ +function buildAnchorClickMap( + nodes: Node[], + nodeMap: NodeLocationMap, + currentFile: string, +): Record { + const targets: Record = {}; + for (const node of nodes) { + const loc = nodeMap.get(node.id); + if (!loc) continue; + targets[node.id] = + loc.file === "" || loc.file === currentFile + ? `#${loc.anchor}` + : `./${loc.file}#${loc.anchor}`; + } + return targets; +} + +/** Options for controlling JSON-to-Markdown conversion. */ +export interface ConvertOptions { + form: "single-file" | "multi-doc"; + embedDiagrams?: boolean; + diagramLinks?: boolean; + labelMode?: "friendly" | "compact"; + relationshipLayout?: DiagramLayout; + refinementLayout?: DiagramLayout; + decisionLayout?: DiagramLayout; + dependencyLayout?: DiagramLayout; +} + +interface MarkdownRenderOptions { + embedDiagrams?: boolean; + diagramLinks?: boolean; + labelMode?: "friendly" | "compact"; + relationshipLayout?: DiagramLayout; + refinementLayout?: DiagramLayout; + decisionLayout?: DiagramLayout; + dependencyLayout?: DiagramLayout; +} + +/** + * Convert a SysProM document to a single Markdown string. + * @param doc - The SysProM document to convert. + * @param options + * @param options.embedDiagrams + * @param options.labelMode + * @param options.relationshipLayout + * @param options.refinementLayout + * @param options.decisionLayout + * @param options.dependencyLayout + * @returns The Markdown representation. + * @example + * ```ts + * const md = jsonToMarkdownSingle(doc); + * writeFileSync("output.spm.md", md); + * ``` + */ +export function jsonToMarkdownSingle( + doc: SysProMDocument, + options?: MarkdownRenderOptions, +): string { + const fromIdx = indexRelationshipsFrom(doc.relationships ?? []); + const nodeMap = buildNodeLocationMap(doc.nodes, "single-file"); + const lines: string[] = []; + const title = doc.metadata?.title ?? "SysProM"; + + lines.push( + renderFrontMatter({ + ...(doc.$schema ? { $schema: doc.$schema } : {}), + title, + doc_type: doc.metadata?.doc_type ?? "sysprom", + scope: doc.metadata?.scope, + status: doc.metadata?.status, + version: doc.metadata?.version, + }), + ); + lines.push(""); + lines.push(`# ${title}`); + lines.push(""); + + const allTypes = [ + ...NODE_FILE_MAP.INTENT, + ...NODE_FILE_MAP.INVARIANTS, + ...NODE_FILE_MAP.STATE, + ...NODE_FILE_MAP.DECISIONS, + ...NODE_FILE_MAP.CHANGES, + "view", + "milestone", + ]; + + lines.push(...renderNodesGrouped(doc.nodes, allTypes, fromIdx, 2, nodeMap)); + + // Diagrams section + if ( + options?.embedDiagrams && + doc.relationships && + doc.relationships.length > 0 + ) { + const clickTargets = options?.diagramLinks + ? buildAnchorClickMap(doc.nodes, nodeMap, "") + : undefined; + lines.push("## Diagrams"); + lines.push(""); + lines.push("### Relationship Graph"); + lines.push(""); + lines.push("```mermaid"); + lines.push( + graphOp({ + doc, + format: "mermaid", + layout: options?.relationshipLayout ?? "TD", + labelMode: options?.labelMode ?? "friendly", + cluster: true, + connectedOnly: false, + clickTargets, + }), + ); + lines.push("```"); + lines.push(""); + } + + // Relationships summary + if (doc.relationships && doc.relationships.length > 0) { + lines.push("## Relationships"); + lines.push(""); + lines.push("| From | Type | To |"); + lines.push("|------|------|----|"); + for (const r of doc.relationships) { + lines.push(`| ${r.from} | ${r.type} | ${r.to} |`); + } + lines.push(""); + } + + // External references + if (doc.external_references && doc.external_references.length > 0) { + lines.push("## External References"); + lines.push(""); + for (const ref of doc.external_references) { + lines.push(`- ${ref.role}: ${ref.identifier}`); + if (ref.node_id) lines.push(` - Node: ${ref.node_id}`); + if (ref.description) lines.push(` - ${renderText(ref.description)}`); + } + lines.push(""); + } + + return lines.join("\n") + "\n"; +} + +/** + * Render a SysProM document as a multi-document Markdown file map, without + * touching the filesystem. Returns a map of filename (e.g. "README.md", + * "DECISIONS.md") to file content. Subsystem nodes are rendered recursively + * using keys like `${id}-${slug}.spm.md` or nested folder-style maps flattened + * with `/` separators. Pure: no node:* imports, no disk I/O. + * @param doc - The SysProM document to render. + * @param options - Rendering options (diagrams, label mode, layouts). + * @returns Map of filename to Markdown content. + */ +export function renderMultiDoc( + doc: SysProMDocument, + options?: MarkdownRenderOptions, +): Record { + const files: Record = {}; + + const fromIdx = indexRelationshipsFrom(doc.relationships ?? []); + const nodeMap = buildNodeLocationMap(doc.nodes, "multi-doc"); + + files["README.md"] = generateReadme(doc, fromIdx, nodeMap); + + for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) { + const hasNodes = doc.nodes.some((n) => types.includes(n.type)); + if (!hasNodes) continue; + files[`${fileName}.md`] = generateDocFile( + doc, + fileName, + types, + fromIdx, + nodeMap, + ); + } + + // Diagrams file + if ( + options?.embedDiagrams && + doc.relationships && + doc.relationships.length > 0 + ) { + const clickTargets = options?.diagramLinks + ? buildAnchorClickMap(doc.nodes, nodeMap, "DIAGRAMS.md") + : undefined; + files["DIAGRAMS.md"] = generateDiagramsFile(doc, { + labelMode: options?.labelMode ?? "friendly", + relationshipLayout: options?.relationshipLayout, + refinementLayout: options?.refinementLayout, + decisionLayout: options?.decisionLayout, + dependencyLayout: options?.dependencyLayout, + clickTargets, + }); + } + + // Subsystem folders or single files + const subsystemNodes = doc.nodes.filter((n) => n.subsystem); + + // Count subsystems per type to decide automatic grouping + const typeCounts = new Map(); + for (const n of subsystemNodes) { + typeCounts.set(n.type, (typeCounts.get(n.type) ?? 0) + 1); + } + + for (const n of subsystemNodes) { + const subsystem = n.subsystem; + if (!subsystem) continue; + const subDoc: SysProMDocument = { + ...subsystem, + metadata: { + title: `${n.id} — ${n.name}`, + doc_type: n.type, + scope: n.type, + status: primaryLifecycleState(n), + }, + }; + + // Count how many distinct file types would be produced + const fileCounts = Object.values(NODE_FILE_MAP).filter((types) => + subDoc.nodes.some((sn) => types.includes(sn.type)), + ).length; + + const slug = `${n.id}-${n.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-$/, "")}`; + + // Auto-group when 2+ subsystems share the same type. Uses a folder + // prefix derived from the type label. + const shouldGroup = + (typeCounts.get(n.type) ?? 0) >= 2 && NodeType.is(n.type); + const folderPrefix = shouldGroup + ? `${NODE_TYPE_LABELS[n.type].toLowerCase().replace(/ /g, "-")}/` + : ""; + + if (fileCounts <= 1) { + const singleContent = jsonToMarkdownSingle(subDoc); + const lineCount = singleContent.split("\n").length; + if (lineCount <= 100) { + files[`${folderPrefix}${slug}.spm.md`] = singleContent; + } else { + const subFiles = renderMultiDoc(subDoc); + for (const [subName, subContent] of Object.entries(subFiles)) { + files[`${folderPrefix}${slug}/${subName}`] = subContent; + } + } + } else { + const subFiles = renderMultiDoc(subDoc); + for (const [subName, subContent] of Object.entries(subFiles)) { + files[`${folderPrefix}${slug}/${subName}`] = subContent; + } + } + } + + return files; +} diff --git a/src/lifecycle-state.ts b/packages/core/src/lifecycle-state.ts similarity index 100% rename from src/lifecycle-state.ts rename to packages/core/src/lifecycle-state.ts diff --git a/packages/core/src/md-to-json.ts b/packages/core/src/md-to-json.ts new file mode 100644 index 0000000..30d7aee --- /dev/null +++ b/packages/core/src/md-to-json.ts @@ -0,0 +1,690 @@ +import * as z from "zod"; +import { + type SysProMDocument, + type Node, + type Relationship, + type ExternalReference, + type Text, + NODE_FILE_MAP, + NODE_LABEL_TO_TYPE, + RELATIONSHIP_TYPE_LABELS, + RELATIONSHIP_LABEL_TO_TYPE, + NodeType, + RelationshipType, + ExternalReferenceRole, +} from "./schema.js"; +/** Strip markdown link syntax `[text](url)` → `text`. */ + +/** + * Strip markdown link syntax `[text](url)` → `text`. + * @param s - Markdown text potentially containing links + * @returns Text with markdown links removed + * @example + * // stripMarkdownLink('[Hello](https://example.com)') // 'Hello' + */ +function stripMarkdownLink(s: string): string { + return s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"); +} + +const LABEL_TO_TYPE: Record = Object.fromEntries( + Object.entries(NODE_LABEL_TO_TYPE).map(([k, v]) => [k.toLowerCase(), v]), +); + +const operationType = z.enum(["add", "update", "remove", "link"]); + +function parseNodeType(s: string): NodeType { + const result = NodeType.safeParse(s); + if (!result.success) + throw new Error( + `Unknown node type: "${s}". Valid types: ${NodeType.options.join(", ")}`, + ); + return result.data; +} + +function parseRelType(s: string): RelationshipType { + const result = RelationshipType.safeParse(s); + if (!result.success) + throw new Error( + `Unknown relationship type: "${s}". Valid types: ${RelationshipType.options.join(", ")}`, + ); + return result.data; +} + +function parseExtRefRole(s: string): ExternalReferenceRole { + const result = ExternalReferenceRole.safeParse(s); + if (!result.success) + throw new Error( + `Unknown external reference role: "${s}". Valid roles: ${ExternalReferenceRole.options.join(", ")}`, + ); + return result.data; +} + +// --------------------------------------------------------------------------- +// Text helpers +// --------------------------------------------------------------------------- + +function parseText(raw: string): Text { + const lines = raw.split("\n"); + return lines.length === 1 ? lines[0] : lines; +} + +// --------------------------------------------------------------------------- +// Front matter +// --------------------------------------------------------------------------- + +type FrontMatter = Record; + +/** + * Separate $schema from front matter so it becomes a top-level document key. + * @param front - The front matter object + * @returns An object with extracted schema and remaining metadata + * @example + * ```ts + * const { schema, metadata } = extractSchema({ $schema: "...", foo: "bar" }); + * ``` + */ +function extractSchema(front: FrontMatter): { + schema: string | undefined; + metadata: FrontMatter; +} { + const schema = typeof front.$schema === "string" ? front.$schema : undefined; + const metadata = { ...front }; + delete metadata.$schema; + return { schema, metadata }; +} + +function parseFrontMatter(content: string): { + front: FrontMatter; + body: string; +} { + if (!content.startsWith("---\n")) return { front: {}, body: content }; + const end = content.indexOf("\n---\n", 4); + if (end === -1) return { front: {}, body: content }; + + const yaml = content.slice(4, end); + const front: FrontMatter = {}; + for (const line of yaml.split("\n")) { + const match = /^([\w$]+):\s*(.+)$/.exec(line); + if (!match) continue; + const [, key, raw] = match; + if (raw.startsWith('"') && raw.endsWith('"')) { + front[key] = raw.slice(1, -1); + } else if (/^\d+$/.test(raw)) { + front[key] = Number.parseInt(raw, 10); + } else { + front[key] = raw; + } + } + return { front, body: content.slice(end + 5) }; +} + +// --------------------------------------------------------------------------- +// Markdown section parsing +// --------------------------------------------------------------------------- + +interface Section { + level: number; + heading: string; + body: string; + children: Section[]; +} + +function parseSections(body: string): Section[] { + const lines = body.split("\n"); + const all: Section[] = []; + + // First pass: find all headings and their body text (until next heading of any level) + for (let i = 0; i < lines.length; i++) { + const hMatch = /^(#{1,6})\s+(.+)$/.exec(lines[i]); + if (hMatch) { + const level = hMatch[1].length; + const heading = hMatch[2]; + const bodyLines: string[] = []; + for (let j = i + 1; j < lines.length; j++) { + if (/^#{1,6}\s/.exec(lines[j])) break; + bodyLines.push(lines[j]); + } + all.push({ + level, + heading, + body: bodyLines.join("\n").trim(), + children: [], + }); + } + } + + // Second pass: build tree + const root: Section[] = []; + const stack: Section[] = []; + + for (const section of all) { + while (stack.length > 0 && stack[stack.length - 1].level >= section.level) { + stack.pop(); + } + if (stack.length > 0) { + stack[stack.length - 1].children.push(section); + } else { + root.push(section); + } + stack.push(section); + } + + return root; +} + +// --------------------------------------------------------------------------- +// Node parsing from sections +// --------------------------------------------------------------------------- + +function parseNodeId(heading: string): { id: string; name: string } | null { + const match = /^(\S+)\s+—\s+(.+)$/.exec(heading); + if (!match) return null; + return { id: match[1], name: match[2] }; +} + +function parseLifecycle( + section: Section, +): Record | undefined { + const lifecycle: Record = {}; + let found = false; + for (const line of section.body.split("\n")) { + const m = /^- \[([ x])\] (.+)$/.exec(line); + if (m) { + const isChecked = m[1] === "x"; + const text = m[2]; + + // Check if the text ends with a parenthesised date + const dateMatch = /(.+?)\s*\((\d{4}-\d{2}-\d{2}(?:T[^)]+)?)\)$/.exec( + text, + ); + + const key = dateMatch + ? dateMatch[1].replace(/ /g, "_") + : text.replace(/ /g, "_"); + + // If a date is found, use the date string as the value regardless of checkbox state + if (dateMatch) { + lifecycle[key] = dateMatch[2]; + } else { + // Otherwise, use boolean value + lifecycle[key] = isChecked; + } + + found = true; + } + } + return found ? lifecycle : undefined; +} + +const RELATIONSHIP_LABELS = Object.values(RELATIONSHIP_TYPE_LABELS); + +function isRelationshipLabel(line: string): boolean { + return RELATIONSHIP_LABELS.some((label) => line.startsWith(`- ${label}:`)); +} + +function parseListItems(body: string, prefix: string): string[] { + const items: string[] = []; + let collecting = false; + for (const line of body.split("\n")) { + if (line.startsWith(`${prefix}:`)) { + collecting = true; + const inline = line.slice(prefix.length + 1).trim(); + if (inline) { + items.push(stripMarkdownLink(inline)); + collecting = false; + } + continue; + } + if (collecting && line.startsWith(" - ")) { + items.push(stripMarkdownLink(line.slice(4))); + } else if ( + collecting && + line.startsWith("- ") && + !isRelationshipLabel(line) + ) { + items.push(stripMarkdownLink(line.slice(2))); + } else if (collecting) { + collecting = false; + } + } + return items; +} + +function parseSingleValue(body: string, prefix: string): string | undefined { + const lines = body.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith(`${prefix}: `)) { + const firstLine = lines[i].slice(prefix.length + 2); + const continuationLines = [firstLine]; + for (let j = i + 1; j < lines.length; j++) { + const next = lines[j]; + if (next === "") break; + if (next.startsWith("- ") || next.startsWith("#")) break; + if (/^[A-Z][a-z]+: /.test(next)) break; + continuationLines.push(next); + } + return continuationLines.join("\n"); + } + } + return undefined; +} + +function parseRelationshipsFromBody( + body: string, + nodeId: string, +): Relationship[] { + const rels: Relationship[] = []; + for (const [label, type] of Object.entries(RELATIONSHIP_LABEL_TO_TYPE)) { + const relType = parseRelType(type); + const items = parseListItems(body, `- ${label}`); + if (items.length === 0) { + const val = parseSingleValue(body, `- ${label}`); + if (val) { + rels.push({ from: nodeId, to: stripMarkdownLink(val), type: relType }); + } + } else { + for (const target of items) { + rels.push({ from: nodeId, to: target, type: relType }); + } + } + } + return rels; +} + +function parseNodeFromSection( + section: Section, +): { node: Node; rels: Relationship[] } | null { + const parsed = parseNodeId(section.heading); + if (!parsed) return null; + + const { id, name } = parsed; + const body = section.body; + const node: Node = { id, type: parseNodeType("intent"), name }; // type overwritten by caller + + // Description is the first paragraph(s) before any list or sub-heading content + const descLines: string[] = []; + for (const line of body.split("\n")) { + if ( + line.startsWith("- ") || + line.startsWith("Context:") || + line.startsWith("Options:") || + line.startsWith("Chosen:") || + line.startsWith("Rationale:") || + line.startsWith("Scope:") || + line.startsWith("Operations:") || + line.startsWith("Includes:") || + line === "" + ) { + if (descLines.length > 0) break; + if (line === "") continue; + break; + } + descLines.push(line); + } + if (descLines.length > 0) { + node.description = parseText(descLines.join("\n")); + } + + // Decision fields + const context = parseSingleValue(body, "Context"); + if (context) node.context = parseText(context); + + const chosen = parseSingleValue(body, "Chosen"); + if (chosen) node.selected = chosen; + + const rationale = parseSingleValue(body, "Rationale"); + if (rationale) node.rationale = parseText(rationale); + + // Options + const optionLines = parseListItems(body, "Options"); + if (optionLines.length > 0) { + node.options = optionLines.map((line) => { + const m = /^(\S+):\s+(.+)$/.exec(line); + return m + ? { id: m[1], description: m[2] } + : { id: line, description: line }; + }); + } + + // Change fields + const scopeItems = parseListItems(body, "Scope"); + if (scopeItems.length > 0) node.scope = scopeItems; + + const opLines = parseListItems(body, "Operations"); + if (opLines.length > 0) { + node.operations = opLines.map((line) => { + const parts = line.split(" "); + const rawType = parts[0]; + const parsed = operationType.safeParse(rawType); + if (!parsed.success) { + throw new Error( + `Unknown operation type: "${rawType}". Valid types: ${operationType.options.join(", ")}`, + ); + } + const type = parsed.data; + const rest = parts.slice(1); + const dashIdx = rest.indexOf("—"); + if (dashIdx >= 0) { + return { + type, + target: rest.slice(0, dashIdx).join(" ") || undefined, + description: rest.slice(dashIdx + 1).join(" "), + }; + } + return { type, target: rest.join(" ") || undefined }; + }); + } + + // View includes + const includes = parseListItems(body, "Includes"); + if (includes.length > 0) node.includes = includes; + + // Lifecycle and propagation from child sections + for (const child of section.children) { + if (child.heading === "Lifecycle") { + node.lifecycle = parseLifecycle(child); + } + if (child.heading === "Propagation") { + const parsed = parseLifecycle(child); + // Propagation values are always boolean — coerce any date strings to true. + if (parsed) { + const booleanOnly: Record = {}; + for (const [k, v] of Object.entries(parsed)) { + booleanOnly[k] = !!v; + } + node.propagation = booleanOnly; + } + } + } + + // Relationships + const rels = parseRelationshipsFromBody(body, id); + + return { node, rels }; +} + +// --------------------------------------------------------------------------- +// File-level parsing +// --------------------------------------------------------------------------- + +function findTypeSections(sections: Section[]): Section[] { + // Type sections (## Intent, ## Concepts, etc.) may be at root level + // or nested under a top-level # heading. Flatten to find them. + const result: Section[] = []; + for (const s of sections) { + if (LABEL_TO_TYPE[s.heading.toLowerCase()]) { + result.push(s); + } + for (const child of s.children) { + if (LABEL_TO_TYPE[child.heading.toLowerCase()]) { + result.push(child); + } + } + } + return result; +} + +function parseDocFile( + content: string, + types: string[], +): { nodes: Node[]; rels: Relationship[] } { + const { body } = parseFrontMatter(content); + const sections = parseSections(body); + const typeSections = findTypeSections(sections); + const nodes: Node[] = []; + const rels: Relationship[] = []; + + for (const typeSection of typeSections) { + const type = + LABEL_TO_TYPE[typeSection.heading.toLowerCase()] ?? + types.find((t) => typeSection.heading.toLowerCase() === t); + + for (const child of typeSection.children) { + const result = parseNodeFromSection(child); + if (result) { + result.node.type = parseNodeType(type); + nodes.push(result.node); + rels.push(...result.rels); + } + } + } + return { nodes, rels }; +} + +// --------------------------------------------------------------------------- +// External references from README +// --------------------------------------------------------------------------- + +function parseExternalReferences(body: string): ExternalReference[] { + const refs: ExternalReference[] = []; + const lines = body.split("\n"); + let inSection = false; + + for (let i = 0; i < lines.length; i++) { + if (/^##\s+External References/.exec(lines[i])) { + inSection = true; + continue; + } + if (inSection && /^##\s/.exec(lines[i])) break; + if (inSection && lines[i].startsWith("- ")) { + const m = /^- (\w+): (.+)$/.exec(lines[i]); + if (m) { + const ref: ExternalReference = { + role: parseExtRefRole(m[1]), + identifier: m[2], + }; + // Check for indented sub-items + for ( + let j = i + 1; + j < lines.length && lines[j].startsWith(" - "); + j++ + ) { + const sub = lines[j].slice(4); + if (sub.startsWith("Node: ")) ref.node_id = sub.slice(6); + else ref.description = sub; + i = j; + } + refs.push(ref); + } + } + } + return refs; +} + +// --------------------------------------------------------------------------- +// Relationship table from single file +// --------------------------------------------------------------------------- + +function parseRelationshipTable(body: string): Relationship[] { + const rels: Relationship[] = []; + const lines = body.split("\n"); + let inTable = false; + + for (const line of lines) { + if (line.startsWith("| From |")) { + inTable = true; + continue; + } + if (inTable && line.startsWith("|---")) continue; + if (inTable && line.startsWith("|")) { + const cells = line + .split("|") + .map((c) => c.trim()) + .filter(Boolean); + if (cells.length >= 3) { + rels.push({ + from: cells[0], + to: cells[2], + type: parseRelType(cells[1]), + }); + } + } else if (inTable) { + inTable = false; + } + } + return rels; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Parse a single Markdown file into a SysProM document. + * @param content - The Markdown content to parse. + * @returns The parsed SysProM document. + * @example + * ```ts + * const doc = markdownSingleToJson(readFileSync("doc.spm.md", "utf8")); + * ``` + */ +export function markdownSingleToJson(content: string): SysProMDocument { + const { front, body } = parseFrontMatter(content); + const allTypes = [ + ...NODE_FILE_MAP.INTENT, + ...NODE_FILE_MAP.INVARIANTS, + ...NODE_FILE_MAP.STATE, + ...NODE_FILE_MAP.DECISIONS, + ...NODE_FILE_MAP.CHANGES, + "view", + "milestone", + ]; + + const { nodes, rels } = parseDocFile(content, allTypes); + const tableRels = parseRelationshipTable(body); + const extRefs = parseExternalReferences(body); + + const { schema, metadata: metaFront } = extractSchema(front); + + const doc: SysProMDocument = { + ...(schema ? { $schema: schema } : {}), + metadata: Object.keys(metaFront).length > 0 ? metaFront : undefined, + nodes, + relationships: + [...rels, ...tableRels].length > 0 ? [...rels, ...tableRels] : undefined, + external_references: extRefs.length > 0 ? extRefs : undefined, + }; + + if (metaFront.title && typeof metaFront.title === "string") { + doc.metadata = { ...metaFront }; + } + + return doc; +} + +/** + * Parse a multi-document Markdown file map (filename → content) into a SysProM + * document. This is the pure counterpart to the root-level + * `markdownMultiDocToJson(dir)` fs wrapper: instead of reading the directory, + * it accepts the file map directly. Keys use `/`-separated paths for nested + * subsystem folders and grouping directories, matching `renderMultiDoc`. + * @param files - Map of filename to Markdown content. + * @returns The parsed SysProM document. + */ +export function parseMultiDoc(files: Record): SysProMDocument { + const readmeContent = files["README.md"]; + const { front, body } = parseFrontMatter(readmeContent); + + const nodes: Node[] = []; + const rels: Relationship[] = []; + + // Parse each document file + for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) { + const key = `${fileName}.md`; + if (!(key in files)) continue; + const content = files[key]; + const result = parseDocFile(content, types); + nodes.push(...result.nodes); + rels.push(...result.rels); + } + + // Parse views, milestones, versions from README + const readmeSections = parseSections(body); + const readmeTypeSections = findTypeSections(readmeSections); + for (const typeSection of readmeTypeSections) { + const type = LABEL_TO_TYPE[typeSection.heading.toLowerCase()]; + if (!type) continue; + for (const child of typeSection.children) { + const result = parseNodeFromSection(child); + if (result) { + result.node.type = parseNodeType(type); + nodes.push(result.node); + rels.push(...result.rels); + } + } + } + + // External references from README + const extRefs = parseExternalReferences(body); + + // Subsystem entries: single-file `.spm.md` and folder-style (README.md + + // nested files) including those inside grouping directories. Walk the + // top-level keys, then recurse into grouping folders (keys with `/`). + function collectSubsystems(prefix: string): void { + // Group keys by their immediate child under `prefix`. + const seen = new Set(); + for (const key of Object.keys(files)) { + if (!key.startsWith(prefix)) continue; + const rest = key.slice(prefix.length); + if (rest.includes("/")) { + // Nested path; the first segment is a child entry. + const child = rest.slice(0, rest.indexOf("/")); + if (seen.has(child)) continue; + seen.add(child); + const childPrefix = `${prefix}${child}/`; + // Folder-style subsystem if it contains README.md; otherwise it's + // a grouping directory — recurse into it. + if (`${childPrefix}README.md` in files) { + attachFolderSubsystem(prefix, child, childPrefix); + } else { + collectSubsystems(childPrefix); + } + } else if (rest.endsWith(".spm.md")) { + if (seen.has(rest)) continue; + seen.add(rest); + attachFileSubsystem(prefix, rest); + } + } + } + + function attachFolderSubsystem( + prefix: string, + child: string, + childPrefix: string, + ): void { + const idPrefix = child.split("-")[0]; + const parentNode = nodes.find((n) => n.id === idPrefix); + if (!parentNode) return; + // Gather all files under childPrefix, stripping that prefix so they + // become top-level keys for the recursive parse. + const subFiles: Record = {}; + for (const [key, content] of Object.entries(files)) { + if (key.startsWith(childPrefix)) { + subFiles[key.slice(childPrefix.length)] = content; + } + } + parentNode.subsystem = parseMultiDoc(subFiles); + } + + function attachFileSubsystem(prefix: string, child: string): void { + const idPrefix = child.replace(/\.spm\.md$/, "").split("-")[0]; + const parentNode = nodes.find((n) => n.id === idPrefix); + if (!parentNode) return; + const content = files[`${prefix}${child}`]; + parentNode.subsystem = markdownSingleToJson(content); + } + + collectSubsystems(""); + + const { schema, metadata: metaFront } = extractSchema(front); + + const doc: SysProMDocument = { + ...(schema ? { $schema: schema } : {}), + metadata: Object.keys(metaFront).length > 0 ? metaFront : undefined, + nodes, + relationships: rels.length > 0 ? rels : undefined, + external_references: extRefs.length > 0 ? extRefs : undefined, + }; + + return doc; +} diff --git a/src/operations/add-external-reference.ts b/packages/core/src/operations/add-external-reference.ts similarity index 100% rename from src/operations/add-external-reference.ts rename to packages/core/src/operations/add-external-reference.ts diff --git a/src/operations/add-node.ts b/packages/core/src/operations/add-node.ts similarity index 100% rename from src/operations/add-node.ts rename to packages/core/src/operations/add-node.ts diff --git a/src/operations/add-relationship.ts b/packages/core/src/operations/add-relationship.ts similarity index 100% rename from src/operations/add-relationship.ts rename to packages/core/src/operations/add-relationship.ts diff --git a/src/operations/check.ts b/packages/core/src/operations/check.ts similarity index 100% rename from src/operations/check.ts rename to packages/core/src/operations/check.ts diff --git a/src/operations/define-operation.ts b/packages/core/src/operations/define-operation.ts similarity index 100% rename from src/operations/define-operation.ts rename to packages/core/src/operations/define-operation.ts diff --git a/src/operations/graph-decision.ts b/packages/core/src/operations/graph-decision.ts similarity index 100% rename from src/operations/graph-decision.ts rename to packages/core/src/operations/graph-decision.ts diff --git a/src/operations/graph-dependency.ts b/packages/core/src/operations/graph-dependency.ts similarity index 100% rename from src/operations/graph-dependency.ts rename to packages/core/src/operations/graph-dependency.ts diff --git a/src/operations/graph-refinement.ts b/packages/core/src/operations/graph-refinement.ts similarity index 100% rename from src/operations/graph-refinement.ts rename to packages/core/src/operations/graph-refinement.ts diff --git a/src/operations/graph-shared.ts b/packages/core/src/operations/graph-shared.ts similarity index 100% rename from src/operations/graph-shared.ts rename to packages/core/src/operations/graph-shared.ts diff --git a/src/operations/graph.ts b/packages/core/src/operations/graph.ts similarity index 100% rename from src/operations/graph.ts rename to packages/core/src/operations/graph.ts diff --git a/packages/core/src/operations/index.ts b/packages/core/src/operations/index.ts new file mode 100644 index 0000000..2c469e5 --- /dev/null +++ b/packages/core/src/operations/index.ts @@ -0,0 +1,78 @@ +export { + defineOperation, + type OperationDef, + type DefinedOperation, +} from "./define-operation.js"; + +// Mutation operations +export { addNodeOp } from "./add-node.js"; +export { removeNodeOp, type RemoveResult } from "./remove-node.js"; +export { updateNodeOp } from "./update-node.js"; +export { addRelationshipOp } from "./add-relationship.js"; +export { removeRelationshipOp } from "./remove-relationship.js"; +export { updateMetadataOp } from "./update-metadata.js"; +export { addExternalReferenceOp } from "./add-external-reference.js"; +export { removeExternalReferenceOp } from "./remove-external-reference.js"; +export { nextIdOp } from "./next-id.js"; +export { initDocumentOp } from "./init-document.js"; +export { planInitOp } from "./plan-init.js"; +export { planAddTaskOp } from "./plan-add-task.js"; +export { planStartTaskOp } from "./plan-start-task.js"; +export { planCompleteTaskOp } from "./plan-complete-task.js"; +export { planReopenTaskOp } from "./plan-reopen-task.js"; +export { planStatusOp, type PlanStatusResult } from "./plan-status.js"; +export { planProgressOp, type PhaseProgressResult } from "./plan-progress.js"; +export { planGateOp, type GateResultOutput } from "./plan-gate.js"; + +// Query operations +export { queryNodesOp } from "./query-nodes.js"; +export { queryNodeOp, type NodeDetail } from "./query-node.js"; +export { queryRelationshipsOp } from "./query-relationships.js"; +export { queryRelationshipTypesOp } from "./query-relationship-types.js"; +export { traceFromNodeOp, type TraceNode } from "./trace-from-node.js"; + +// Temporal operations +export { timelineOp, type TimelineEvent } from "./timeline.js"; +export { nodeHistoryOp } from "./node-history.js"; +export { stateAtOp, type NodeState } from "./state-at.js"; + +// Inspection operations +export { validateOp, type ValidationResult } from "./validate.js"; +export { statsOp, type DocumentStats } from "./stats.js"; + +// New API operations (previously CLI-only) +export { searchOp } from "./search.js"; +export { checkOp } from "./check.js"; +export { graphOp } from "./graph.js"; +export { graphRefinementOp } from "./graph-refinement.js"; +export { graphDecisionOp } from "./graph-decision.js"; +export { graphDependencyOp } from "./graph-dependency.js"; +export { renameOp } from "./rename.js"; + +// Conversion operations +export { jsonToMarkdownOp } from "./json-to-markdown.js"; +export { markdownToJsonOp } from "./markdown-to-json.js"; + +// Inference operations +export { + inferCompletenessOp, + type CompletenessResult, + type CompletenessOutput, +} from "./infer-completeness.js"; +export { + inferLifecycleOp, + type LifecycleResult, + type LifecycleOutput, +} from "./infer-lifecycle.js"; +export { + inferImpactOp, + impactSummaryOp, + type ImpactNode, + type ImpactOutput, + type ImpactSummaryOutput, +} from "./infer-impact.js"; +export { + inferDerivedOp, + type DerivedRelationship, + type DerivedOutput, +} from "./infer-derived.js"; diff --git a/src/operations/infer-completeness.ts b/packages/core/src/operations/infer-completeness.ts similarity index 100% rename from src/operations/infer-completeness.ts rename to packages/core/src/operations/infer-completeness.ts diff --git a/src/operations/infer-derived.ts b/packages/core/src/operations/infer-derived.ts similarity index 100% rename from src/operations/infer-derived.ts rename to packages/core/src/operations/infer-derived.ts diff --git a/src/operations/infer-impact.ts b/packages/core/src/operations/infer-impact.ts similarity index 100% rename from src/operations/infer-impact.ts rename to packages/core/src/operations/infer-impact.ts diff --git a/src/operations/infer-lifecycle.ts b/packages/core/src/operations/infer-lifecycle.ts similarity index 100% rename from src/operations/infer-lifecycle.ts rename to packages/core/src/operations/infer-lifecycle.ts diff --git a/src/operations/init-document.ts b/packages/core/src/operations/init-document.ts similarity index 100% rename from src/operations/init-document.ts rename to packages/core/src/operations/init-document.ts diff --git a/src/operations/json-to-markdown.ts b/packages/core/src/operations/json-to-markdown.ts similarity index 100% rename from src/operations/json-to-markdown.ts rename to packages/core/src/operations/json-to-markdown.ts diff --git a/src/operations/markdown-to-json.ts b/packages/core/src/operations/markdown-to-json.ts similarity index 100% rename from src/operations/markdown-to-json.ts rename to packages/core/src/operations/markdown-to-json.ts diff --git a/src/operations/next-id.ts b/packages/core/src/operations/next-id.ts similarity index 100% rename from src/operations/next-id.ts rename to packages/core/src/operations/next-id.ts diff --git a/src/operations/node-history.ts b/packages/core/src/operations/node-history.ts similarity index 100% rename from src/operations/node-history.ts rename to packages/core/src/operations/node-history.ts diff --git a/src/operations/plan-add-task.ts b/packages/core/src/operations/plan-add-task.ts similarity index 100% rename from src/operations/plan-add-task.ts rename to packages/core/src/operations/plan-add-task.ts diff --git a/src/operations/plan-complete-task.ts b/packages/core/src/operations/plan-complete-task.ts similarity index 100% rename from src/operations/plan-complete-task.ts rename to packages/core/src/operations/plan-complete-task.ts diff --git a/src/operations/plan-gate.ts b/packages/core/src/operations/plan-gate.ts similarity index 100% rename from src/operations/plan-gate.ts rename to packages/core/src/operations/plan-gate.ts diff --git a/src/operations/plan-init.ts b/packages/core/src/operations/plan-init.ts similarity index 100% rename from src/operations/plan-init.ts rename to packages/core/src/operations/plan-init.ts diff --git a/src/operations/plan-progress.ts b/packages/core/src/operations/plan-progress.ts similarity index 100% rename from src/operations/plan-progress.ts rename to packages/core/src/operations/plan-progress.ts diff --git a/src/operations/plan-reopen-task.ts b/packages/core/src/operations/plan-reopen-task.ts similarity index 100% rename from src/operations/plan-reopen-task.ts rename to packages/core/src/operations/plan-reopen-task.ts diff --git a/src/operations/plan-start-task.ts b/packages/core/src/operations/plan-start-task.ts similarity index 100% rename from src/operations/plan-start-task.ts rename to packages/core/src/operations/plan-start-task.ts diff --git a/src/operations/plan-status.ts b/packages/core/src/operations/plan-status.ts similarity index 100% rename from src/operations/plan-status.ts rename to packages/core/src/operations/plan-status.ts diff --git a/src/operations/query-node.ts b/packages/core/src/operations/query-node.ts similarity index 100% rename from src/operations/query-node.ts rename to packages/core/src/operations/query-node.ts diff --git a/src/operations/query-nodes.ts b/packages/core/src/operations/query-nodes.ts similarity index 100% rename from src/operations/query-nodes.ts rename to packages/core/src/operations/query-nodes.ts diff --git a/src/operations/query-relationship-types.ts b/packages/core/src/operations/query-relationship-types.ts similarity index 100% rename from src/operations/query-relationship-types.ts rename to packages/core/src/operations/query-relationship-types.ts diff --git a/src/operations/query-relationships.ts b/packages/core/src/operations/query-relationships.ts similarity index 100% rename from src/operations/query-relationships.ts rename to packages/core/src/operations/query-relationships.ts diff --git a/src/operations/remove-external-reference.ts b/packages/core/src/operations/remove-external-reference.ts similarity index 100% rename from src/operations/remove-external-reference.ts rename to packages/core/src/operations/remove-external-reference.ts diff --git a/src/operations/remove-node.ts b/packages/core/src/operations/remove-node.ts similarity index 100% rename from src/operations/remove-node.ts rename to packages/core/src/operations/remove-node.ts diff --git a/src/operations/remove-relationship.ts b/packages/core/src/operations/remove-relationship.ts similarity index 100% rename from src/operations/remove-relationship.ts rename to packages/core/src/operations/remove-relationship.ts diff --git a/src/operations/rename.ts b/packages/core/src/operations/rename.ts similarity index 100% rename from src/operations/rename.ts rename to packages/core/src/operations/rename.ts diff --git a/src/operations/search.ts b/packages/core/src/operations/search.ts similarity index 100% rename from src/operations/search.ts rename to packages/core/src/operations/search.ts diff --git a/src/operations/state-at.ts b/packages/core/src/operations/state-at.ts similarity index 100% rename from src/operations/state-at.ts rename to packages/core/src/operations/state-at.ts diff --git a/src/operations/stats.ts b/packages/core/src/operations/stats.ts similarity index 100% rename from src/operations/stats.ts rename to packages/core/src/operations/stats.ts diff --git a/src/operations/timeline.ts b/packages/core/src/operations/timeline.ts similarity index 100% rename from src/operations/timeline.ts rename to packages/core/src/operations/timeline.ts diff --git a/src/operations/trace-from-node.ts b/packages/core/src/operations/trace-from-node.ts similarity index 100% rename from src/operations/trace-from-node.ts rename to packages/core/src/operations/trace-from-node.ts diff --git a/src/operations/update-metadata.ts b/packages/core/src/operations/update-metadata.ts similarity index 100% rename from src/operations/update-metadata.ts rename to packages/core/src/operations/update-metadata.ts diff --git a/src/operations/update-node.ts b/packages/core/src/operations/update-node.ts similarity index 100% rename from src/operations/update-node.ts rename to packages/core/src/operations/update-node.ts diff --git a/src/operations/validate.ts b/packages/core/src/operations/validate.ts similarity index 100% rename from src/operations/validate.ts rename to packages/core/src/operations/validate.ts diff --git a/src/schema.ts b/packages/core/src/schema.ts similarity index 100% rename from src/schema.ts rename to packages/core/src/schema.ts diff --git a/src/speckit/plan.ts b/packages/core/src/speckit/plan.ts similarity index 100% rename from src/speckit/plan.ts rename to packages/core/src/speckit/plan.ts diff --git a/src/text.ts b/packages/core/src/text.ts similarity index 100% rename from src/text.ts rename to packages/core/src/text.ts diff --git a/src/utils/define-schema.ts b/packages/core/src/utils/define-schema.ts similarity index 100% rename from src/utils/define-schema.ts rename to packages/core/src/utils/define-schema.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d18f72..773b948 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0(zod@4.3.6) + '@sysprom/core': + specifier: workspace:* + version: link:packages/core commander: specifier: 14.0.3 version: 14.0.3 @@ -115,6 +118,22 @@ importers: specifier: 8.58.0 version: 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + packages/core: + dependencies: + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: 25.5.0 + version: 25.5.0 + tsx: + specifier: 4.21.0 + version: 4.21.0 + typescript: + specifier: 6.0.2 + version: 6.0.2 + packages: '@actions/core@3.0.0': diff --git a/src/cli/commands/add.ts b/src/cli/commands/add.ts index b1e07bf..6624c68 100644 --- a/src/cli/commands/add.ts +++ b/src/cli/commands/add.ts @@ -1,6 +1,6 @@ import * as z from "zod"; import type { CommandDef } from "../define-command.js"; -import { NodeType, NodeStatus, type Node } from "../../schema.js"; +import { NodeType, NodeStatus, type Node } from "@sysprom/core"; import { addNodeOp, nextIdOp } from "../../operations/index.js"; import { mutationOpts, loadDoc, persistDoc } from "../shared.js"; diff --git a/src/cli/commands/graph.ts b/src/cli/commands/graph.ts index c1facf5..b37023c 100644 --- a/src/cli/commands/graph.ts +++ b/src/cli/commands/graph.ts @@ -2,7 +2,7 @@ import * as z from "zod"; import type { CommandDef } from "../define-command.js"; import { graphOp } from "../../operations/index.js"; -import { buildExternalRefClickMap } from "../../operations/graph-shared.js"; +import { buildExternalRefClickMap } from "@sysprom/core/operations/graph-shared.js"; import { noArgs, readOpts, loadDoc } from "../shared.js"; const optsSchema = readOpts.extend({ diff --git a/src/cli/commands/infer.ts b/src/cli/commands/infer.ts index 479506f..4509edb 100644 --- a/src/cli/commands/infer.ts +++ b/src/cli/commands/infer.ts @@ -7,11 +7,11 @@ import { inferLifecycleOp, inferImpactOp, inferDerivedOp, + type CompletenessResult, + type LifecycleResult, + type ImpactNode, + type DerivedRelationship, } from "../../operations/index.js"; -import type { CompletenessResult } from "../../operations/infer-completeness.js"; -import type { LifecycleResult } from "../../operations/infer-lifecycle.js"; -import type { ImpactNode } from "../../operations/infer-impact.js"; -import type { DerivedRelationship } from "../../operations/infer-derived.js"; // --------------------------------------------------------------------------- // Presentation helpers diff --git a/src/cli/commands/json2md.ts b/src/cli/commands/json2md.ts index 57d2c2c..78fcd08 100644 --- a/src/cli/commands/json2md.ts +++ b/src/cli/commands/json2md.ts @@ -3,7 +3,7 @@ import * as z from "zod"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import type { CommandDef } from "../define-command.js"; -import { SysProMDocument } from "../../schema.js"; +import { SysProMDocument } from "@sysprom/core"; import { jsonToMarkdown } from "../../json-to-md.js"; import { jsonToMarkdownOp } from "../../operations/index.js"; diff --git a/src/cli/commands/md2json.ts b/src/cli/commands/md2json.ts index def6d93..86bfa40 100644 --- a/src/cli/commands/md2json.ts +++ b/src/cli/commands/md2json.ts @@ -3,7 +3,7 @@ import { writeFileSync } from "node:fs"; import { resolve } from "node:path"; import type { CommandDef } from "../define-command.js"; import { markdownToJson } from "../../md-to-json.js"; -import { canonicalise } from "../../canonical-json.js"; +import { canonicalise } from "@sysprom/core"; import { markdownToJsonOp } from "../../operations/index.js"; const optsSchema = z diff --git a/src/cli/commands/plan.ts b/src/cli/commands/plan.ts index f9613e5..3abb7c9 100644 --- a/src/cli/commands/plan.ts +++ b/src/cli/commands/plan.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs"; import type { CommandDef } from "../define-command.js"; import { saveDocument } from "../../io.js"; import { loadDoc, mutationOpts, persistDoc, noArgs } from "../shared.js"; -import type { SysProMDocument } from "../../schema.js"; +import type { SysProMDocument } from "@sysprom/core"; import { planInitOp, planAddTaskOp, diff --git a/src/cli/commands/query.ts b/src/cli/commands/query.ts index cccfbd1..d57189a 100644 --- a/src/cli/commands/query.ts +++ b/src/cli/commands/query.ts @@ -1,7 +1,13 @@ import pc from "picocolors"; import * as z from "zod"; import type { CommandDef } from "../define-command.js"; -import { textToString } from "../../text.js"; +import { + textToString, + NodeType, + NodeStatus, + type Node, + primaryLifecycleState, +} from "@sysprom/core"; import { readOpts, loadDoc } from "../shared.js"; import { queryNodesOp, @@ -13,9 +19,6 @@ import { nodeHistoryOp, stateAtOp, } from "../../operations/index.js"; -import { NodeType, NodeStatus } from "../../schema.js"; -import type { Node } from "../../schema.js"; -import { primaryLifecycleState } from "../../lifecycle-state.js"; // --------------------------------------------------------------------------- // Presentation helpers diff --git a/src/cli/commands/search.ts b/src/cli/commands/search.ts index 45d59ca..6b3cfde 100644 --- a/src/cli/commands/search.ts +++ b/src/cli/commands/search.ts @@ -2,7 +2,7 @@ import * as z from "zod"; import type { CommandDef } from "../define-command.js"; import { searchOp } from "../../operations/index.js"; import { readOpts, loadDoc } from "../shared.js"; -import { textToString } from "../../text.js"; +import { textToString } from "@sysprom/core"; const argsSchema = z.object({ term: z.string().describe("Search term"), diff --git a/src/cli/commands/speckit.ts b/src/cli/commands/speckit.ts index f176ed6..7cfceb3 100644 --- a/src/cli/commands/speckit.ts +++ b/src/cli/commands/speckit.ts @@ -3,7 +3,7 @@ import { resolve, dirname } from "node:path"; import { existsSync } from "node:fs"; import type { CommandDef } from "../define-command.js"; import { loadDocument, saveDocument } from "../../io.js"; -import type { SysProMDocument, Node } from "../../schema.js"; +import type { SysProMDocument, Node } from "@sysprom/core"; import { parseSpecKitFeature } from "../../speckit/parse.js"; import { generateSpecKitProject } from "../../speckit/generate.js"; import { detectSpecKitProject } from "../../speckit/project.js"; diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 210e655..aeb2631 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -19,8 +19,7 @@ import { jsonToMarkdownSingle, jsonToMarkdownMultiDoc, } from "../../json-to-md.js"; -import { canonicalise } from "../../canonical-json.js"; -import { SysProMDocument } from "../../schema.js"; +import { canonicalise, SysProMDocument } from "@sysprom/core"; interface SyncCommandInput { jsonPath: string; diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts index aaca3d0..250c4fc 100644 --- a/src/cli/commands/update.ts +++ b/src/cli/commands/update.ts @@ -4,7 +4,7 @@ import { RelationshipType, NodeStatus, ExternalReferenceRole, -} from "../../schema.js"; +} from "@sysprom/core"; import { updateNodeOp, addRelationshipOp, diff --git a/src/cli/shared.ts b/src/cli/shared.ts index c8ba8cc..6cb7601 100644 --- a/src/cli/shared.ts +++ b/src/cli/shared.ts @@ -3,7 +3,7 @@ import { readdirSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; import { loadDocument, saveDocument, type Format } from "../io.js"; import { jsonToMarkdownMultiDoc } from "../json-to-md.js"; -import type { SysProMDocument } from "../schema.js"; +import type { SysProMDocument } from "@sysprom/core"; // --------------------------------------------------------------------------- // Reusable CLI schemas — shared across all commands diff --git a/src/generate-schema.ts b/src/generate-schema.ts index 0cb9604..435cfef 100644 --- a/src/generate-schema.ts +++ b/src/generate-schema.ts @@ -1,8 +1,7 @@ import { writeFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { canonicalise } from "./canonical-json.js"; -import { toJSONSchema } from "./schema.js"; +import { canonicalise, toJSONSchema } from "@sysprom/core"; const schema = toJSONSchema(); const outPath = resolve( diff --git a/src/index.ts b/src/index.ts index 6ba7853..346ed5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,129 +3,37 @@ * * A recursive, decision-driven model for recording where every part of a * system came from, what decisions shaped it, and how it reached its current form. + * + * The pure, browser-safe library logic lives in `@sysprom/core` and is + * re-exported here. This root `sysprom` package additionally provides the + * Node-bound pieces: filesystem I/O (`loadDocument`/`saveDocument`), the + * multi-doc conversion wrappers that read/write directories, document sync, + * and Spec-Kit interoperability (which reads/writes spec files). * @packageDocumentation */ -// Schema types and validators -export { - SysProMDocument, - Node, - Relationship, - NodeType, - NodeStatus, - RelationshipType, - ImpactPolarity, - Text, - Option, - Operation, - ExternalReference, - ExternalReferenceRole, - Metadata, - NODE_TYPE_LABELS, - NODE_LABEL_TO_TYPE, - RELATIONSHIP_TYPE_LABELS, - RELATIONSHIP_LABEL_TO_TYPE, - IMPACT_POLARITY_LABELS, - EXTERNAL_REFERENCE_ROLE_LABELS, - EXTERNAL_REFERENCE_LABEL_TO_ROLE, - NODE_STATUSES, - NODE_FILE_MAP, - NODE_ID_PREFIX, - toJSONSchema, -} from "./schema.js"; +// Re-export the entire pure core so `import { ... } from "sysprom"` keeps +// working unchanged for every symbol provided today. +export * from "@sysprom/core"; -// Operations (single source of truth for domain logic + metadata) -export { - defineOperation, - type OperationDef, - type DefinedOperation, - addNodeOp, - removeNodeOp, - updateNodeOp, - addRelationshipOp, - removeRelationshipOp, - updateMetadataOp, - nextIdOp, - initDocumentOp, - planInitOp, - planAddTaskOp, - planStartTaskOp, - planCompleteTaskOp, - planReopenTaskOp, - planStatusOp, - planProgressOp, - planGateOp, - queryNodesOp, - queryNodeOp, - queryRelationshipsOp, - traceFromNodeOp, - timelineOp, - nodeHistoryOp, - stateAtOp, - validateOp, - statsOp, - searchOp, - checkOp, - graphOp, - renameOp, - jsonToMarkdownOp, - markdownToJsonOp, - speckitImportOp, - speckitExportOp, - speckitSyncOp, - speckitDiffOp, - inferCompletenessOp, - inferLifecycleOp, - inferImpactOp, - impactSummaryOp, - inferDerivedOp, - type RemoveResult, - type ValidationResult, - type DocumentStats, - type NodeDetail, - type TraceNode, - type TimelineEvent, - type NodeState, - type PlanStatusResult, - type PhaseProgressResult, - type GateResultOutput, - type SyncResult, - type DiffResult, - type CompletenessOutput, - type LifecycleOutput, - type ImpactOutput, - type ImpactSummaryOutput, - type DerivedOutput, -} from "./operations/index.js"; +// Conversion — fs wrappers (the pure renderMultiDoc/parseMultiDoc live in core) +export { jsonToMarkdownMultiDoc, jsonToMarkdown } from "./json-to-md.js"; -// Conversion -export { - jsonToMarkdownSingle, - jsonToMarkdownMultiDoc, - jsonToMarkdown, - type ConvertOptions, -} from "./json-to-md.js"; +export { markdownMultiDocToJson, markdownToJson } from "./md-to-json.js"; +// Synchronisation (fs-backed) export { - markdownSingleToJson, - markdownMultiDocToJson, - markdownToJson, -} from "./md-to-json.js"; + syncDocumentsOp, + type BidirectionalSyncResult, + type ConflictStrategy, +} from "./operations/sync.js"; +export { detectChanges, type DetectionResult } from "./sync.js"; -// Validation -export { - RELATIONSHIP_ENDPOINT_TYPES, - isValidEndpointPair, -} from "./endpoint-types.js"; - -// Utilities -export { canonicalise, type FormatOptions } from "./canonical-json.js"; -export { - textToString, - textToLines, - textToMarkdown, - markdownToText, -} from "./text.js"; +// Spec-Kit interoperability operations (fs-backed) +export { speckitImportOp } from "./operations/speckit-import.js"; +export { speckitExportOp } from "./operations/speckit-export.js"; +export { speckitSyncOp, type SyncResult } from "./operations/speckit-sync.js"; +export { speckitDiffOp, type DiffResult } from "./operations/speckit-diff.js"; // IO export { diff --git a/src/io.ts b/src/io.ts index 42c7bca..bc139c9 100644 --- a/src/io.ts +++ b/src/io.ts @@ -1,9 +1,8 @@ import { readFileSync, writeFileSync, statSync } from "node:fs"; import { resolve } from "node:path"; -import { SysProMDocument } from "./schema.js"; +import { SysProMDocument, canonicalise } from "@sysprom/core"; import { markdownSingleToJson, markdownMultiDocToJson } from "./md-to-json.js"; import { jsonToMarkdownSingle, jsonToMarkdownMultiDoc } from "./json-to-md.js"; -import { canonicalise } from "./canonical-json.js"; /** Supported serialisation formats for SysProM documents. */ export type Format = "json" | "single-md" | "multi-md"; diff --git a/src/json-to-md.ts b/src/json-to-md.ts index 51c61db..489d019 100644 --- a/src/json-to-md.ts +++ b/src/json-to-md.ts @@ -1,653 +1,18 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { type SysProMDocument } from "@sysprom/core"; import { - type SysProMDocument, - type Node, - type Relationship, - type ExternalReference, - type Text, - NODE_FILE_MAP, - NODE_TYPE_LABELS, - NodeType, - RelationshipType, - RELATIONSHIP_TYPE_LABELS, -} from "./schema.js"; -import { primaryLifecycleState } from "./lifecycle-state.js"; -import { graphOp } from "./operations/graph.js"; -import { graphRefinementOp } from "./operations/graph-refinement.js"; -import { graphDecisionOp } from "./operations/graph-decision.js"; -import { graphDependencyOp } from "./operations/graph-dependency.js"; + jsonToMarkdownSingle, + renderMultiDoc, + type ConvertOptions, +} from "@sysprom/core"; -// --------------------------------------------------------------------------- -// Text helpers -// --------------------------------------------------------------------------- - -function renderText(value: Text): string { - return Array.isArray(value) ? value.join("\n") : value; -} - -function renderFrontMatter(fields: Record): string { - const lines = ["---"]; - for (const [key, value] of Object.entries(fields)) { - if (value === undefined) continue; - if (typeof value === "number") { - lines.push(`${key}: ${String(value)}`); - } else { - lines.push(`${key}: ${JSON.stringify(value)}`); - } - } - lines.push("---"); - return lines.join("\n"); -} - -// --------------------------------------------------------------------------- -// Node location map (for hyperlinking) -// --------------------------------------------------------------------------- - -/** - * GitHub-compatible heading anchor slug. - * @param text - * @example - * // Convert a heading into a GitHub-style slug - * // slugify('ID — Name') // 'id---name' - */ -function slugify(text: string): string { - return text - .toLowerCase() - .replace(/[^\w\s-]/g, "") - .replace(/\s/g, "-"); -} -/** Heading anchor for a node: `id--name` slugified from `### ID — Name`. */ -function nodeAnchor(n: Node): string { - return slugify(`${n.id} — ${n.name}`); -} - -interface NodeLocation { - file: string; - anchor: string; -} - -type NodeLocationMap = Map; - -/** Build a map from node ID to its markdown file and heading anchor. */ -function buildNodeLocationMap( - nodes: Node[], - mode: "single-file" | "multi-doc", -): NodeLocationMap { - const map: NodeLocationMap = new Map(); - for (const n of nodes) { - const anchor = nodeAnchor(n); - if (mode === "single-file") { - map.set(n.id, { file: "", anchor }); - } else { - const file = fileForNodeType(n.type); - map.set(n.id, { file, anchor }); - } - } - return map; -} -/** Determine the markdown file a node type belongs to in multi-doc mode. */ -function fileForNodeType(type: string): string { - for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) { - if (types.includes(type)) return `${fileName}.md`; - } - return "README.md"; -} - -/** - * Format a node ID as a markdown hyperlink. - * In single-file mode: `[ID](#anchor)` - * In multi-doc mode: `[ID](./FILE.md#anchor)` - * Falls back to plain ID if the node isn't in the map. - * @param id - * @param nodeMap - * @param currentFile - * @example - */ -function linkNodeId( - id: string, - nodeMap: NodeLocationMap, - currentFile?: string, -): string { - const loc = nodeMap.get(id); - if (!loc) return id; - if (loc.file === "" || loc.file === currentFile) { - return `[${id}](#${loc.anchor})`; - } - return `[${id}](./${loc.file}#${loc.anchor})`; -} - -// --------------------------------------------------------------------------- -// Relationship lookups -// --------------------------------------------------------------------------- - -type RelIndex = Map; - -function indexRelationshipsFrom(rels: Relationship[]): RelIndex { - const idx: RelIndex = new Map(); - for (const r of rels) { - const list = idx.get(r.from); - if (list) list.push(r); - else idx.set(r.from, [r]); - } - return idx; -} - -// --------------------------------------------------------------------------- -// Node rendering -// --------------------------------------------------------------------------- - -// Canonical lifecycle stage orderings from PROT1 (decision), PROT2 (change), PROT3 (node). -// Keys not in any ordering are appended at the end in their original order. -const LIFECYCLE_ORDER: readonly string[] = [ - "proposed", - "accepted", - "active", - "adopted", - "implemented", - "defined", - "introduced", - "in_progress", - "complete", - "consolidated", - "experimental", - "deprecated", - "retired", - "superseded", - "abandoned", - "deferred", -]; - -function renderLifecycle( - lifecycle: Record, -): string[] { - const entries = Object.entries(lifecycle); - entries.sort(([a], [b]) => { - const ai = LIFECYCLE_ORDER.indexOf(a); - const bi = LIFECYCLE_ORDER.indexOf(b); - // Unknown keys sort after known ones, preserving relative order - if (ai === -1 && bi === -1) return 0; - if (ai === -1) return 1; - if (bi === -1) return -1; - return ai - bi; - }); - return entries.map(([state, done]) => { - const checkbox = done ? "x" : " "; - const label = state.replace(/_/g, " "); - if (typeof done === "string") { - return `- [${checkbox}] ${label} (${done})`; - } - return `- [${checkbox}] ${label}`; - }); -} - -function renderNodeRelationships( - nodeId: string, - fromIdx: RelIndex, - nodeMap: NodeLocationMap, - currentFile?: string, -): string[] { - const rels = fromIdx.get(nodeId); - if (!rels || rels.length === 0) return []; - - const grouped = new Map(); - for (const r of rels) { - const list = grouped.get(r.type); - if (list) list.push(r.to); - else grouped.set(r.type, [r.to]); - } - - const lines: string[] = []; - for (const [type, targets] of grouped) { - const label = RelationshipType.is(type) - ? RELATIONSHIP_TYPE_LABELS[type] - : type; - if (targets.length === 1) { - lines.push(`- ${label}: ${linkNodeId(targets[0], nodeMap, currentFile)}`); - } else { - lines.push(`- ${label}:`); - for (const t of targets) { - lines.push(` - ${linkNodeId(t, nodeMap, currentFile)}`); - } - } - } - return lines; -} - -function renderExternalReferences(refs: ExternalReference[]): string[] { - if (refs.length === 0) return []; - const lines = ["", "#### External References", ""]; - for (const ref of refs) { - lines.push(`- ${ref.role}: ${ref.identifier}`); - if (ref.description) { - lines.push(` - ${renderText(ref.description)}`); - } - if (ref.internalised) { - lines.push(` - Internalised: ${renderText(ref.internalised)}`); - } - } - return lines; -} - -function renderNode( - n: Node, - headingLevel: number, - fromIdx: RelIndex, - nodeMap: NodeLocationMap, - currentFile?: string, -): string[] { - const prefix = "#".repeat(headingLevel); - const lines: string[] = []; - - lines.push(`${prefix} ${n.id} — ${n.name}`); - lines.push(""); - - if (n.description) { - lines.push(renderText(n.description)); - lines.push(""); - } - - const rels = renderNodeRelationships(n.id, fromIdx, nodeMap, currentFile); - if (rels.length > 0) { - lines.push(...rels); - lines.push(""); - } - - // Decision fields - if (n.context) { - lines.push(`Context: ${renderText(n.context)}`); - lines.push(""); - } - if (n.options && n.options.length > 0) { - lines.push("Options:"); - for (const o of n.options) { - lines.push(`- ${o.id}: ${renderText(o.description)}`); - } - lines.push(""); - } - if (n.selected) { - lines.push(`Chosen: ${n.selected}`); - lines.push(""); - } - if (n.rationale) { - lines.push(`Rationale: ${renderText(n.rationale)}`); - lines.push(""); - } - - // Change fields - if (n.scope && n.scope.length > 0) { - lines.push("Scope:"); - for (const s of n.scope) { - lines.push(`- ${s}`); - } - lines.push(""); - } - if (n.operations && n.operations.length > 0) { - lines.push("Operations:"); - for (const op of n.operations) { - const parts: string[] = [op.type]; - if (op.target) parts.push(op.target); - if (op.description) parts.push(`— ${renderText(op.description)}`); - lines.push(`- ${parts.join(" ")}`); - } - lines.push(""); - } - // Lifecycle - if (n.lifecycle) { - lines.push(`${"#".repeat(headingLevel + 1)} Lifecycle`); - lines.push(""); - lines.push(...renderLifecycle(n.lifecycle)); - lines.push(""); - } - - // Propagation - if (n.propagation) { - lines.push(`${"#".repeat(headingLevel + 1)} Propagation`); - lines.push(""); - lines.push(...renderLifecycle(n.propagation)); - lines.push(""); - } - - // View includes - if (n.includes && n.includes.length > 0) { - lines.push("Includes:"); - for (const inc of n.includes) { - lines.push(`- ${linkNodeId(inc, nodeMap, currentFile)}`); - } - lines.push(""); - } - - // Inline external references - if (n.external_references && n.external_references.length > 0) { - lines.push(...renderExternalReferences(n.external_references)); - lines.push(""); - } - - // Subsystem note - if (n.subsystem) { - lines.push(`${"#".repeat(headingLevel + 1)} Subsystem`); - lines.push(""); - const subNodes = n.subsystem.nodes; - const subRels = n.subsystem.relationships ?? []; - const subIdx = indexRelationshipsFrom(subRels); - const subMap = buildNodeLocationMap(subNodes, "single-file"); - for (const sub of subNodes) { - lines.push(...renderNode(sub, headingLevel + 2, subIdx, subMap)); - } - } - - return lines; -} - -// --------------------------------------------------------------------------- -// File generators -// --------------------------------------------------------------------------- - -function renderNodesGrouped( - nodes: Node[], - types: string[], - fromIdx: RelIndex, - headingLevel: number, - nodeMap: NodeLocationMap, - currentFile?: string, -): string[] { - const lines: string[] = []; - for (const type of types) { - const matching = nodes.filter((n) => n.type === type); - if (matching.length === 0) continue; - - const label = NodeType.is(type) ? NODE_TYPE_LABELS[type] : type; - lines.push(`${"#".repeat(headingLevel)} ${label}`); - lines.push(""); - - for (const n of matching) { - lines.push( - ...renderNode(n, headingLevel + 1, fromIdx, nodeMap, currentFile), - ); - } - } - return lines; -} - -function generateReadme( - doc: SysProMDocument, - fromIdx: RelIndex, - nodeMap: NodeLocationMap, -): string { - const lines: string[] = []; - const title = doc.metadata?.title ?? "SysProM"; - - lines.push( - renderFrontMatter({ - ...(doc.$schema ? { $schema: doc.$schema } : {}), - title, - doc_type: doc.metadata?.doc_type ?? "sysprom", - scope: doc.metadata?.scope, - status: doc.metadata?.status, - version: doc.metadata?.version, - }), - ); - lines.push(""); - lines.push(`# ${title}`); - lines.push(""); - - // Intent description - const intent = doc.nodes.find((n) => n.type === "intent"); - if (intent?.description) { - lines.push(renderText(intent.description)); - lines.push(""); - } - - // Determine which files will exist based on present node types - const presentFiles: { file: string; label: string; role: string }[] = []; - const fileDescriptions: Record = { - INTENT: { - label: "Understand why this exists", - role: "Enduring purpose, concepts, capabilities", - }, - INVARIANTS: { - label: "Understand what must always hold", - role: "Rules that must hold across all valid states", - }, - STATE: { - label: "Understand what currently exists", - role: "Current structure and active elements", - }, - DECISIONS: { - label: "Understand why things are the way they are", - role: "Choices and rationale", - }, - CHANGES: { - label: "Understand how it has evolved", - role: "Evolution over time", - }, - }; - - for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) { - if (doc.nodes.some((n) => types.includes(n.type))) { - const desc = fileDescriptions[fileName]; - presentFiles.push({ file: fileName, ...desc }); - } - } - - // Navigation — only link to files that exist - if (presentFiles.length > 0) { - lines.push("## Navigation"); - lines.push(""); - for (const { file, label } of presentFiles) { - lines.push(`### ${label}`); - lines.push(`See: [${file}.md](./${file}.md)`); - lines.push(""); - } - - // Document roles table — only include present files - lines.push("## Document Roles"); - lines.push(""); - lines.push("| Document | Role |"); - lines.push("|----------|------|"); - for (const { file, role } of presentFiles) { - lines.push(`| ${file}.md | ${role} |`); - } - lines.push(""); - } - - // Views - const views = doc.nodes.filter((n) => n.type === "view"); - if (views.length > 0) { - lines.push( - ...renderNodesGrouped( - doc.nodes, - ["view"], - fromIdx, - 2, - nodeMap, - "README.md", - ), - ); - } - - // Graph-level external references - if (doc.external_references && doc.external_references.length > 0) { - lines.push("## External References"); - lines.push(""); - for (const ref of doc.external_references) { - const parts = [`- ${ref.role}: ${ref.identifier}`]; - if (ref.node_id) parts.push(` - Node: ${ref.node_id}`); - if (ref.description) parts.push(` - ${renderText(ref.description)}`); - lines.push(...parts); - } - lines.push(""); - } - - return lines.join("\n") + "\n"; -} - -function generateDocFile( - doc: SysProMDocument, - fileName: string, - types: string[], - fromIdx: RelIndex, - nodeMap: NodeLocationMap, -): string { - const lines: string[] = []; - - lines.push( - renderFrontMatter({ - title: fileName.replace(".md", ""), - doc_type: fileName.replace(".md", "").toLowerCase(), - }), - ); - lines.push(""); - lines.push(`# ${fileName.replace(".md", "")}`); - lines.push(""); - lines.push( - ...renderNodesGrouped( - doc.nodes, - types, - fromIdx, - 2, - nodeMap, - `${fileName}.md`, - ), - ); - - return lines.join("\n") + "\n"; -} - -// --------------------------------------------------------------------------- -// Diagram generation for Markdown embedding -// --------------------------------------------------------------------------- +export type { ConvertOptions }; +export { jsonToMarkdownSingle }; type DiagramLayout = "LR" | "TD" | "RL" | "BT"; -interface DiagramOptions { - labelMode?: "friendly" | "compact"; - relationshipLayout?: DiagramLayout; - refinementLayout?: DiagramLayout; - decisionLayout?: DiagramLayout; - dependencyLayout?: DiagramLayout; - clickTargets?: Record; -} - -function generateDiagramsFile( - doc: SysProMDocument, - opts?: DiagramOptions, -): string { - const lines: string[] = []; - - lines.push( - renderFrontMatter({ - title: "Diagrams", - doc_type: "diagrams", - }), - ); - lines.push(""); - lines.push("# Diagrams"); - lines.push(""); - - lines.push("## Relationship Graph"); - lines.push(""); - lines.push("```mermaid"); - lines.push( - graphOp({ - doc, - format: "mermaid", - layout: opts?.relationshipLayout ?? "TD", - labelMode: opts?.labelMode ?? "friendly", - cluster: true, - connectedOnly: false, - clickTargets: opts?.clickTargets, - }), - ); - lines.push("```"); - lines.push(""); - - const refinement = graphRefinementOp({ - doc, - format: "mermaid", - layout: opts?.refinementLayout ?? "TD", - labelMode: opts?.labelMode ?? "friendly", - clickTargets: opts?.clickTargets, - }); - if (refinement.includes("-->")) { - lines.push("## Refinement Chain"); - lines.push(""); - lines.push("```mermaid"); - lines.push(refinement); - lines.push("```"); - lines.push(""); - } - - const decisions = graphDecisionOp({ - doc, - format: "mermaid", - layout: opts?.decisionLayout ?? "TD", - labelMode: opts?.labelMode ?? "friendly", - clickTargets: opts?.clickTargets, - }); - if (decisions.includes("-->")) { - lines.push("## Decision Map"); - lines.push(""); - lines.push("```mermaid"); - lines.push(decisions); - lines.push("```"); - lines.push(""); - } - - const dependencies = graphDependencyOp({ - doc, - format: "mermaid", - layout: opts?.dependencyLayout ?? "LR", - labelMode: opts?.labelMode ?? "friendly", - clickTargets: opts?.clickTargets, - }); - if (dependencies.includes("-->") || dependencies.includes("-.->")) { - lines.push("## Dependency Graph"); - lines.push(""); - lines.push("```mermaid"); - lines.push(dependencies); - lines.push("```"); - lines.push(""); - } - - return lines.join("\n") + "\n"; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** Build a click target map from node anchors for use in embedded diagrams. */ -function buildAnchorClickMap( - nodes: Node[], - nodeMap: NodeLocationMap, - currentFile: string, -): Record { - const targets: Record = {}; - for (const node of nodes) { - const loc = nodeMap.get(node.id); - if (!loc) continue; - targets[node.id] = - loc.file === "" || loc.file === currentFile - ? `#${loc.anchor}` - : `./${loc.file}#${loc.anchor}`; - } - return targets; -} - -/** Options for controlling JSON-to-Markdown conversion. */ -export interface ConvertOptions { - form: "single-file" | "multi-doc"; - embedDiagrams?: boolean; - diagramLinks?: boolean; - labelMode?: "friendly" | "compact"; - relationshipLayout?: DiagramLayout; - refinementLayout?: DiagramLayout; - decisionLayout?: DiagramLayout; - dependencyLayout?: DiagramLayout; -} - -interface MarkdownRenderOptions { +interface MultiDocOptions { embedDiagrams?: boolean; diagramLinks?: boolean; labelMode?: "friendly" | "compact"; @@ -658,124 +23,13 @@ interface MarkdownRenderOptions { } /** - * Convert a SysProM document to a single Markdown string. - * @param doc - The SysProM document to convert. - * @param options - * @param options.embedDiagrams - * @param options.labelMode - * @param options.relationshipLayout - * @param options.refinementLayout - * @param options.decisionLayout - * @param options.dependencyLayout - * @returns The Markdown representation. - * @example - * ```ts - * const md = jsonToMarkdownSingle(doc); - * writeFileSync("output.spm.md", md); - * ``` - */ -export function jsonToMarkdownSingle( - doc: SysProMDocument, - options?: MarkdownRenderOptions, -): string { - const fromIdx = indexRelationshipsFrom(doc.relationships ?? []); - const nodeMap = buildNodeLocationMap(doc.nodes, "single-file"); - const lines: string[] = []; - const title = doc.metadata?.title ?? "SysProM"; - - lines.push( - renderFrontMatter({ - ...(doc.$schema ? { $schema: doc.$schema } : {}), - title, - doc_type: doc.metadata?.doc_type ?? "sysprom", - scope: doc.metadata?.scope, - status: doc.metadata?.status, - version: doc.metadata?.version, - }), - ); - lines.push(""); - lines.push(`# ${title}`); - lines.push(""); - - const allTypes = [ - ...NODE_FILE_MAP.INTENT, - ...NODE_FILE_MAP.INVARIANTS, - ...NODE_FILE_MAP.STATE, - ...NODE_FILE_MAP.DECISIONS, - ...NODE_FILE_MAP.CHANGES, - "view", - "milestone", - ]; - - lines.push(...renderNodesGrouped(doc.nodes, allTypes, fromIdx, 2, nodeMap)); - - // Diagrams section - if ( - options?.embedDiagrams && - doc.relationships && - doc.relationships.length > 0 - ) { - const clickTargets = options?.diagramLinks - ? buildAnchorClickMap(doc.nodes, nodeMap, "") - : undefined; - lines.push("## Diagrams"); - lines.push(""); - lines.push("### Relationship Graph"); - lines.push(""); - lines.push("```mermaid"); - lines.push( - graphOp({ - doc, - format: "mermaid", - layout: options?.relationshipLayout ?? "TD", - labelMode: options?.labelMode ?? "friendly", - cluster: true, - connectedOnly: false, - clickTargets, - }), - ); - lines.push("```"); - lines.push(""); - } - - // Relationships summary - if (doc.relationships && doc.relationships.length > 0) { - lines.push("## Relationships"); - lines.push(""); - lines.push("| From | Type | To |"); - lines.push("|------|------|----|"); - for (const r of doc.relationships) { - lines.push(`| ${r.from} | ${r.type} | ${r.to} |`); - } - lines.push(""); - } - - // External references - if (doc.external_references && doc.external_references.length > 0) { - lines.push("## External References"); - lines.push(""); - for (const ref of doc.external_references) { - lines.push(`- ${ref.role}: ${ref.identifier}`); - if (ref.node_id) lines.push(` - Node: ${ref.node_id}`); - if (ref.description) lines.push(` - ${renderText(ref.description)}`); - } - lines.push(""); - } - - return lines.join("\n") + "\n"; -} - -/** - * Convert a SysProM document to a multi-document Markdown folder. + * Convert a SysProM document to a multi-document Markdown folder. Thin fs + * wrapper over the pure `renderMultiDoc` from `@sysprom/core`: builds the file + * map in memory, then creates the output directory and writes each entry. + * Behaviour is identical to the previous inline implementation. * @param doc - The SysProM document to convert. * @param outDir - Output directory path. - * @param options - * @param options.embedDiagrams - * @param options.labelMode - * @param options.relationshipLayout - * @param options.refinementLayout - * @param options.decisionLayout - * @param options.dependencyLayout + * @param options - Rendering options (diagrams, label mode, layouts). * @example * ```ts * jsonToMarkdownMultiDoc(doc, "./SysProM"); @@ -784,102 +38,19 @@ export function jsonToMarkdownSingle( export function jsonToMarkdownMultiDoc( doc: SysProMDocument, outDir: string, - options?: MarkdownRenderOptions, + options?: MultiDocOptions, ): void { mkdirSync(outDir, { recursive: true }); - const fromIdx = indexRelationshipsFrom(doc.relationships ?? []); - const nodeMap = buildNodeLocationMap(doc.nodes, "multi-doc"); - - writeFileSync( - join(outDir, "README.md"), - generateReadme(doc, fromIdx, nodeMap), - ); - - for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) { - const hasNodes = doc.nodes.some((n) => types.includes(n.type)); - if (!hasNodes) continue; - writeFileSync( - join(outDir, `${fileName}.md`), - generateDocFile(doc, fileName, types, fromIdx, nodeMap), - ); - } - - // Diagrams file - if ( - options?.embedDiagrams && - doc.relationships && - doc.relationships.length > 0 - ) { - const clickTargets = options?.diagramLinks - ? buildAnchorClickMap(doc.nodes, nodeMap, "DIAGRAMS.md") - : undefined; - writeFileSync( - join(outDir, "DIAGRAMS.md"), - generateDiagramsFile(doc, { - labelMode: options?.labelMode ?? "friendly", - relationshipLayout: options?.relationshipLayout, - refinementLayout: options?.refinementLayout, - decisionLayout: options?.decisionLayout, - dependencyLayout: options?.dependencyLayout, - clickTargets, - }), - ); - } - - // Subsystem folders or single files - const subsystemNodes = doc.nodes.filter((n) => n.subsystem); - - // Count subsystems per type to decide automatic grouping - const typeCounts = new Map(); - for (const n of subsystemNodes) { - typeCounts.set(n.type, (typeCounts.get(n.type) ?? 0) + 1); - } - - for (const n of subsystemNodes) { - const subsystem = n.subsystem; - if (!subsystem) continue; - const subDoc: SysProMDocument = { - ...subsystem, - metadata: { - title: `${n.id} — ${n.name}`, - doc_type: n.type, - scope: n.type, - status: primaryLifecycleState(n), - }, - }; - - // Count how many distinct file types would be produced - const fileCounts = Object.values(NODE_FILE_MAP).filter((types) => - subDoc.nodes.some((sn) => types.includes(sn.type)), - ).length; - - const slug = `${n.id}-${n.name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/-$/, "")}`; - - // Auto-group when 2+ subsystems share the same type - const shouldGroup = - (typeCounts.get(n.type) ?? 0) >= 2 && NodeType.is(n.type); - const parentDir = shouldGroup - ? join(outDir, NODE_TYPE_LABELS[n.type].toLowerCase().replace(/ /g, "-")) - : outDir; - if (shouldGroup) { - mkdirSync(parentDir, { recursive: true }); - } - - if (fileCounts <= 1) { - const singleContent = jsonToMarkdownSingle(subDoc); - const lineCount = singleContent.split("\n").length; - if (lineCount <= 100) { - writeFileSync(join(parentDir, `${slug}.spm.md`), singleContent); - } else { - jsonToMarkdownMultiDoc(subDoc, join(parentDir, slug)); - } - } else { - jsonToMarkdownMultiDoc(subDoc, join(parentDir, slug)); + const files = renderMultiDoc(doc, options); + for (const [relPath, content] of Object.entries(files)) { + const fullPath = join(outDir, ...relPath.split("/")); + // Ensure parent directories exist for nested subsystem/grouping paths. + const parent = fullPath.slice(0, fullPath.lastIndexOf("/")); + if (parent && parent !== outDir) { + mkdirSync(parent, { recursive: true }); } + writeFileSync(fullPath, content); } } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9702e3e..355a20f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -4,8 +4,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import * as z from "zod"; import { loadDocument, saveDocument } from "../io.js"; -import { NodeType, RelationshipType, SysProMDocument } from "../schema.js"; import { + NodeType, + RelationshipType, + SysProMDocument, validateOp, statsOp, queryNodesOp, @@ -23,7 +25,7 @@ import { inferImpactOp, impactSummaryOp, inferDerivedOp, -} from "../operations/index.js"; +} from "@sysprom/core"; /** * Wrap an error with a descriptive prefix and attach the original as cause. diff --git a/src/md-to-json.ts b/src/md-to-json.ts index 42ec768..08134fe 100644 --- a/src/md-to-json.ts +++ b/src/md-to-json.ts @@ -1,581 +1,19 @@ -import * as z from "zod"; -import { readFileSync, existsSync, readdirSync, statSync } from "node:fs"; -import { join, basename } from "node:path"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; import { type SysProMDocument, - type Node, - type Relationship, - type ExternalReference, - type Text, - NODE_FILE_MAP, - NODE_LABEL_TO_TYPE, - RELATIONSHIP_TYPE_LABELS, - RELATIONSHIP_LABEL_TO_TYPE, - NodeType, - RelationshipType, - ExternalReferenceRole, -} from "./schema.js"; -/** Strip markdown link syntax `[text](url)` → `text`. */ + markdownSingleToJson, + parseMultiDoc, +} from "@sysprom/core"; -/** - * Strip markdown link syntax `[text](url)` → `text`. - * @param s - Markdown text potentially containing links - * @returns Text with markdown links removed - * @example - * // stripMarkdownLink('[Hello](https://example.com)') // 'Hello' - */ -function stripMarkdownLink(s: string): string { - return s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"); -} - -const LABEL_TO_TYPE: Record = Object.fromEntries( - Object.entries(NODE_LABEL_TO_TYPE).map(([k, v]) => [k.toLowerCase(), v]), -); - -const operationType = z.enum(["add", "update", "remove", "link"]); - -function parseNodeType(s: string): NodeType { - const result = NodeType.safeParse(s); - if (!result.success) - throw new Error( - `Unknown node type: "${s}". Valid types: ${NodeType.options.join(", ")}`, - ); - return result.data; -} - -function parseRelType(s: string): RelationshipType { - const result = RelationshipType.safeParse(s); - if (!result.success) - throw new Error( - `Unknown relationship type: "${s}". Valid types: ${RelationshipType.options.join(", ")}`, - ); - return result.data; -} - -function parseExtRefRole(s: string): ExternalReferenceRole { - const result = ExternalReferenceRole.safeParse(s); - if (!result.success) - throw new Error( - `Unknown external reference role: "${s}". Valid roles: ${ExternalReferenceRole.options.join(", ")}`, - ); - return result.data; -} - -// --------------------------------------------------------------------------- -// Text helpers -// --------------------------------------------------------------------------- - -function parseText(raw: string): Text { - const lines = raw.split("\n"); - return lines.length === 1 ? lines[0] : lines; -} - -// --------------------------------------------------------------------------- -// Front matter -// --------------------------------------------------------------------------- - -type FrontMatter = Record; - -/** - * Separate $schema from front matter so it becomes a top-level document key. - * @param front - The front matter object - * @returns An object with extracted schema and remaining metadata - * @example - * ```ts - * const { schema, metadata } = extractSchema({ $schema: "...", foo: "bar" }); - * ``` - */ -function extractSchema(front: FrontMatter): { - schema: string | undefined; - metadata: FrontMatter; -} { - const schema = typeof front.$schema === "string" ? front.$schema : undefined; - const metadata = { ...front }; - delete metadata.$schema; - return { schema, metadata }; -} - -function parseFrontMatter(content: string): { - front: FrontMatter; - body: string; -} { - if (!content.startsWith("---\n")) return { front: {}, body: content }; - const end = content.indexOf("\n---\n", 4); - if (end === -1) return { front: {}, body: content }; - - const yaml = content.slice(4, end); - const front: FrontMatter = {}; - for (const line of yaml.split("\n")) { - const match = /^([\w$]+):\s*(.+)$/.exec(line); - if (!match) continue; - const [, key, raw] = match; - if (raw.startsWith('"') && raw.endsWith('"')) { - front[key] = raw.slice(1, -1); - } else if (/^\d+$/.test(raw)) { - front[key] = Number.parseInt(raw, 10); - } else { - front[key] = raw; - } - } - return { front, body: content.slice(end + 5) }; -} - -// --------------------------------------------------------------------------- -// Markdown section parsing -// --------------------------------------------------------------------------- - -interface Section { - level: number; - heading: string; - body: string; - children: Section[]; -} - -function parseSections(body: string): Section[] { - const lines = body.split("\n"); - const all: Section[] = []; - - // First pass: find all headings and their body text (until next heading of any level) - for (let i = 0; i < lines.length; i++) { - const hMatch = /^(#{1,6})\s+(.+)$/.exec(lines[i]); - if (hMatch) { - const level = hMatch[1].length; - const heading = hMatch[2]; - const bodyLines: string[] = []; - for (let j = i + 1; j < lines.length; j++) { - if (/^#{1,6}\s/.exec(lines[j])) break; - bodyLines.push(lines[j]); - } - all.push({ - level, - heading, - body: bodyLines.join("\n").trim(), - children: [], - }); - } - } - - // Second pass: build tree - const root: Section[] = []; - const stack: Section[] = []; - - for (const section of all) { - while (stack.length > 0 && stack[stack.length - 1].level >= section.level) { - stack.pop(); - } - if (stack.length > 0) { - stack[stack.length - 1].children.push(section); - } else { - root.push(section); - } - stack.push(section); - } - - return root; -} - -// --------------------------------------------------------------------------- -// Node parsing from sections -// --------------------------------------------------------------------------- - -function parseNodeId(heading: string): { id: string; name: string } | null { - const match = /^(\S+)\s+—\s+(.+)$/.exec(heading); - if (!match) return null; - return { id: match[1], name: match[2] }; -} - -function parseLifecycle( - section: Section, -): Record | undefined { - const lifecycle: Record = {}; - let found = false; - for (const line of section.body.split("\n")) { - const m = /^- \[([ x])\] (.+)$/.exec(line); - if (m) { - const isChecked = m[1] === "x"; - const text = m[2]; - - // Check if the text ends with a parenthesised date - const dateMatch = /(.+?)\s*\((\d{4}-\d{2}-\d{2}(?:T[^)]+)?)\)$/.exec( - text, - ); - - const key = dateMatch - ? dateMatch[1].replace(/ /g, "_") - : text.replace(/ /g, "_"); - - // If a date is found, use the date string as the value regardless of checkbox state - if (dateMatch) { - lifecycle[key] = dateMatch[2]; - } else { - // Otherwise, use boolean value - lifecycle[key] = isChecked; - } - - found = true; - } - } - return found ? lifecycle : undefined; -} - -const RELATIONSHIP_LABELS = Object.values(RELATIONSHIP_TYPE_LABELS); - -function isRelationshipLabel(line: string): boolean { - return RELATIONSHIP_LABELS.some((label) => line.startsWith(`- ${label}:`)); -} - -function parseListItems(body: string, prefix: string): string[] { - const items: string[] = []; - let collecting = false; - for (const line of body.split("\n")) { - if (line.startsWith(`${prefix}:`)) { - collecting = true; - const inline = line.slice(prefix.length + 1).trim(); - if (inline) { - items.push(stripMarkdownLink(inline)); - collecting = false; - } - continue; - } - if (collecting && line.startsWith(" - ")) { - items.push(stripMarkdownLink(line.slice(4))); - } else if ( - collecting && - line.startsWith("- ") && - !isRelationshipLabel(line) - ) { - items.push(stripMarkdownLink(line.slice(2))); - } else if (collecting) { - collecting = false; - } - } - return items; -} - -function parseSingleValue(body: string, prefix: string): string | undefined { - const lines = body.split("\n"); - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith(`${prefix}: `)) { - const firstLine = lines[i].slice(prefix.length + 2); - const continuationLines = [firstLine]; - for (let j = i + 1; j < lines.length; j++) { - const next = lines[j]; - if (next === "") break; - if (next.startsWith("- ") || next.startsWith("#")) break; - if (/^[A-Z][a-z]+: /.test(next)) break; - continuationLines.push(next); - } - return continuationLines.join("\n"); - } - } - return undefined; -} - -function parseRelationshipsFromBody( - body: string, - nodeId: string, -): Relationship[] { - const rels: Relationship[] = []; - for (const [label, type] of Object.entries(RELATIONSHIP_LABEL_TO_TYPE)) { - const relType = parseRelType(type); - const items = parseListItems(body, `- ${label}`); - if (items.length === 0) { - const val = parseSingleValue(body, `- ${label}`); - if (val) { - rels.push({ from: nodeId, to: stripMarkdownLink(val), type: relType }); - } - } else { - for (const target of items) { - rels.push({ from: nodeId, to: target, type: relType }); - } - } - } - return rels; -} - -function parseNodeFromSection( - section: Section, -): { node: Node; rels: Relationship[] } | null { - const parsed = parseNodeId(section.heading); - if (!parsed) return null; - - const { id, name } = parsed; - const body = section.body; - const node: Node = { id, type: parseNodeType("intent"), name }; // type overwritten by caller - - // Description is the first paragraph(s) before any list or sub-heading content - const descLines: string[] = []; - for (const line of body.split("\n")) { - if ( - line.startsWith("- ") || - line.startsWith("Context:") || - line.startsWith("Options:") || - line.startsWith("Chosen:") || - line.startsWith("Rationale:") || - line.startsWith("Scope:") || - line.startsWith("Operations:") || - line.startsWith("Includes:") || - line === "" - ) { - if (descLines.length > 0) break; - if (line === "") continue; - break; - } - descLines.push(line); - } - if (descLines.length > 0) { - node.description = parseText(descLines.join("\n")); - } - - // Decision fields - const context = parseSingleValue(body, "Context"); - if (context) node.context = parseText(context); - - const chosen = parseSingleValue(body, "Chosen"); - if (chosen) node.selected = chosen; - - const rationale = parseSingleValue(body, "Rationale"); - if (rationale) node.rationale = parseText(rationale); - - // Options - const optionLines = parseListItems(body, "Options"); - if (optionLines.length > 0) { - node.options = optionLines.map((line) => { - const m = /^(\S+):\s+(.+)$/.exec(line); - return m - ? { id: m[1], description: m[2] } - : { id: line, description: line }; - }); - } - - // Change fields - const scopeItems = parseListItems(body, "Scope"); - if (scopeItems.length > 0) node.scope = scopeItems; - - const opLines = parseListItems(body, "Operations"); - if (opLines.length > 0) { - node.operations = opLines.map((line) => { - const parts = line.split(" "); - const rawType = parts[0]; - const parsed = operationType.safeParse(rawType); - if (!parsed.success) { - throw new Error( - `Unknown operation type: "${rawType}". Valid types: ${operationType.options.join(", ")}`, - ); - } - const type = parsed.data; - const rest = parts.slice(1); - const dashIdx = rest.indexOf("—"); - if (dashIdx >= 0) { - return { - type, - target: rest.slice(0, dashIdx).join(" ") || undefined, - description: rest.slice(dashIdx + 1).join(" "), - }; - } - return { type, target: rest.join(" ") || undefined }; - }); - } - - // View includes - const includes = parseListItems(body, "Includes"); - if (includes.length > 0) node.includes = includes; - - // Lifecycle and propagation from child sections - for (const child of section.children) { - if (child.heading === "Lifecycle") { - node.lifecycle = parseLifecycle(child); - } - if (child.heading === "Propagation") { - const parsed = parseLifecycle(child); - // Propagation values are always boolean — coerce any date strings to true. - if (parsed) { - const booleanOnly: Record = {}; - for (const [k, v] of Object.entries(parsed)) { - booleanOnly[k] = !!v; - } - node.propagation = booleanOnly; - } - } - } - - // Relationships - const rels = parseRelationshipsFromBody(body, id); - - return { node, rels }; -} - -// --------------------------------------------------------------------------- -// File-level parsing -// --------------------------------------------------------------------------- - -function findTypeSections(sections: Section[]): Section[] { - // Type sections (## Intent, ## Concepts, etc.) may be at root level - // or nested under a top-level # heading. Flatten to find them. - const result: Section[] = []; - for (const s of sections) { - if (LABEL_TO_TYPE[s.heading.toLowerCase()]) { - result.push(s); - } - for (const child of s.children) { - if (LABEL_TO_TYPE[child.heading.toLowerCase()]) { - result.push(child); - } - } - } - return result; -} - -function parseDocFile( - content: string, - types: string[], -): { nodes: Node[]; rels: Relationship[] } { - const { body } = parseFrontMatter(content); - const sections = parseSections(body); - const typeSections = findTypeSections(sections); - const nodes: Node[] = []; - const rels: Relationship[] = []; - - for (const typeSection of typeSections) { - const type = - LABEL_TO_TYPE[typeSection.heading.toLowerCase()] ?? - types.find((t) => typeSection.heading.toLowerCase() === t); - - for (const child of typeSection.children) { - const result = parseNodeFromSection(child); - if (result) { - result.node.type = parseNodeType(type); - nodes.push(result.node); - rels.push(...result.rels); - } - } - } - return { nodes, rels }; -} - -// --------------------------------------------------------------------------- -// External references from README -// --------------------------------------------------------------------------- - -function parseExternalReferences(body: string): ExternalReference[] { - const refs: ExternalReference[] = []; - const lines = body.split("\n"); - let inSection = false; - - for (let i = 0; i < lines.length; i++) { - if (/^##\s+External References/.exec(lines[i])) { - inSection = true; - continue; - } - if (inSection && /^##\s/.exec(lines[i])) break; - if (inSection && lines[i].startsWith("- ")) { - const m = /^- (\w+): (.+)$/.exec(lines[i]); - if (m) { - const ref: ExternalReference = { - role: parseExtRefRole(m[1]), - identifier: m[2], - }; - // Check for indented sub-items - for ( - let j = i + 1; - j < lines.length && lines[j].startsWith(" - "); - j++ - ) { - const sub = lines[j].slice(4); - if (sub.startsWith("Node: ")) ref.node_id = sub.slice(6); - else ref.description = sub; - i = j; - } - refs.push(ref); - } - } - } - return refs; -} - -// --------------------------------------------------------------------------- -// Relationship table from single file -// --------------------------------------------------------------------------- - -function parseRelationshipTable(body: string): Relationship[] { - const rels: Relationship[] = []; - const lines = body.split("\n"); - let inTable = false; - - for (const line of lines) { - if (line.startsWith("| From |")) { - inTable = true; - continue; - } - if (inTable && line.startsWith("|---")) continue; - if (inTable && line.startsWith("|")) { - const cells = line - .split("|") - .map((c) => c.trim()) - .filter(Boolean); - if (cells.length >= 3) { - rels.push({ - from: cells[0], - to: cells[2], - type: parseRelType(cells[1]), - }); - } - } else if (inTable) { - inTable = false; - } - } - return rels; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** - * Parse a single Markdown file into a SysProM document. - * @param content - The Markdown content to parse. - * @returns The parsed SysProM document. - * @example - * ```ts - * const doc = markdownSingleToJson(readFileSync("doc.spm.md", "utf8")); - * ``` - */ -export function markdownSingleToJson(content: string): SysProMDocument { - const { front, body } = parseFrontMatter(content); - const allTypes = [ - ...NODE_FILE_MAP.INTENT, - ...NODE_FILE_MAP.INVARIANTS, - ...NODE_FILE_MAP.STATE, - ...NODE_FILE_MAP.DECISIONS, - ...NODE_FILE_MAP.CHANGES, - "view", - "milestone", - ]; - - const { nodes, rels } = parseDocFile(content, allTypes); - const tableRels = parseRelationshipTable(body); - const extRefs = parseExternalReferences(body); - - const { schema, metadata: metaFront } = extractSchema(front); - - const doc: SysProMDocument = { - ...(schema ? { $schema: schema } : {}), - metadata: Object.keys(metaFront).length > 0 ? metaFront : undefined, - nodes, - relationships: - [...rels, ...tableRels].length > 0 ? [...rels, ...tableRels] : undefined, - external_references: extRefs.length > 0 ? extRefs : undefined, - }; - - if (metaFront.title && typeof metaFront.title === "string") { - doc.metadata = { ...metaFront }; - } - - return doc; -} +export { markdownSingleToJson }; /** - * Parse a multi-document Markdown folder into a SysProM document. + * Parse a multi-document Markdown folder into a SysProM document. Thin fs + * wrapper over the pure `parseMultiDoc` from `@sysprom/core`: reads the + * directory into a filename → content map (flattening subsystem and grouping + * subdirectories with `/`-separated keys), then delegates to `parseMultiDoc`. + * Behaviour is identical to the previous inline implementation. * @param dir - Path to the directory containing Markdown files. * @returns The parsed SysProM document. * @example @@ -584,91 +22,50 @@ export function markdownSingleToJson(content: string): SysProMDocument { * ``` */ export function markdownMultiDocToJson(dir: string): SysProMDocument { - const readmeContent = readFileSync(join(dir, "README.md"), "utf8"); - const { front, body } = parseFrontMatter(readmeContent); - - const nodes: Node[] = []; - const rels: Relationship[] = []; - - // Parse each document file - for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) { - const filePath = join(dir, `${fileName}.md`); - if (!existsSync(filePath)) continue; - const content = readFileSync(filePath, "utf8"); - const result = parseDocFile(content, types); - nodes.push(...result.nodes); - rels.push(...result.rels); - } - - // Parse views, milestones, versions from README - const readmeSections = parseSections(body); - const readmeTypeSections = findTypeSections(readmeSections); - for (const typeSection of readmeTypeSections) { - const type = LABEL_TO_TYPE[typeSection.heading.toLowerCase()]; - if (!type) continue; - for (const child of typeSection.children) { - const result = parseNodeFromSection(child); - if (result) { - result.node.type = parseNodeType(type); - nodes.push(result.node); - rels.push(...result.rels); - } - } - } + const files = readMultiDocFiles(dir, ""); + return parseMultiDoc(files); +} - // External references from README - const extRefs = parseExternalReferences(body); +/** + * Recursively read a multi-document directory into a flat filename → content + * map. Top-level files use bare names (e.g. "README.md", "INTENT.md"); files + * inside subsystem folders and grouping directories are prefixed with their + * containing folder path joined by `/`, matching the layout produced by + * `renderMultiDoc` / `jsonToMarkdownMultiDoc`. Both `.spm.md` single-file + * subsystems and folder-style subsystems (with their own README.md and + * document files) are collected. + */ +function readMultiDocFiles( + dir: string, + prefix: string, +): Record { + const files: Record = {}; - // Subsystem folders and .spm.md files (including inside grouping directories) - function scanForSubsystems(scanDir: string): void { - for (const entry of readdirSync(scanDir)) { - const entryPath = join(scanDir, entry); + for (const entry of readdirSync(dir)) { + const entryPath = join(dir, entry); + const st = statSync(entryPath); + const key = prefix ? `${prefix}${entry}` : entry; - if ( - statSync(entryPath).isDirectory() && - existsSync(join(entryPath, "README.md")) - ) { - // Folder-based subsystem - const idPrefix = entry.split("-")[0]; - const parentNode = nodes.find((n) => n.id === idPrefix); - if (parentNode) { - parentNode.subsystem = markdownMultiDocToJson(entryPath); - } - } else if (entry.endsWith(".spm.md")) { - // Single-file subsystem - const fileIdPrefix = basename(entry, ".spm.md").split("-")[0]; - const parentNode = nodes.find((n) => n.id === fileIdPrefix); - if (parentNode) { - parentNode.subsystem = markdownSingleToJson( - readFileSync(entryPath, "utf8"), - ); - } - } else if ( - statSync(entryPath).isDirectory() && - !existsSync(join(entryPath, "README.md")) - ) { - // Grouping directory (no README = not a subsystem, just organisational) - scanForSubsystems(entryPath); - } + if (st.isDirectory()) { + // Subsystem folder (has README.md) or grouping folder (no + // README.md). Either way, recurse and prefix all child keys with + // `${entry}/`. parseMultiDoc distinguishes subsystem vs grouping + // by whether a README.md appears under that prefix. + Object.assign(files, readMultiDocFiles(entryPath, `${key}/`)); + } else if (entry.endsWith(".md")) { + // Top-level document files (README.md, INTENT.md, ...) and nested + // document files inside subsystem/grouping folders. Single-file + // subsystems (`.spm.md`) are also captured here. + files[key] = readFileSync(entryPath, "utf8"); } } - scanForSubsystems(dir); - - const { schema, metadata: metaFront } = extractSchema(front); - - const doc: SysProMDocument = { - ...(schema ? { $schema: schema } : {}), - metadata: Object.keys(metaFront).length > 0 ? metaFront : undefined, - nodes, - relationships: rels.length > 0 ? rels : undefined, - external_references: extRefs.length > 0 ? extRefs : undefined, - }; - return doc; + return files; } /** - * Parse Markdown into a SysProM document, auto-detecting single-file or multi-doc format. + * Parse Markdown into a SysProM document, auto-detecting single-file or + * multi-doc format. * @param input - File path or directory path to parse. * @returns The parsed SysProM document. * @example diff --git a/src/operations/index.ts b/src/operations/index.ts index b25b88c..24804b0 100644 --- a/src/operations/index.ts +++ b/src/operations/index.ts @@ -1,57 +1,9 @@ -export { - defineOperation, - type OperationDef, - type DefinedOperation, -} from "./define-operation.js"; - -// Mutation operations -export { addNodeOp } from "./add-node.js"; -export { removeNodeOp, type RemoveResult } from "./remove-node.js"; -export { updateNodeOp } from "./update-node.js"; -export { addRelationshipOp } from "./add-relationship.js"; -export { removeRelationshipOp } from "./remove-relationship.js"; -export { updateMetadataOp } from "./update-metadata.js"; -export { addExternalReferenceOp } from "./add-external-reference.js"; -export { removeExternalReferenceOp } from "./remove-external-reference.js"; -export { nextIdOp } from "./next-id.js"; -export { initDocumentOp } from "./init-document.js"; -export { planInitOp } from "./plan-init.js"; -export { planAddTaskOp } from "./plan-add-task.js"; -export { planStartTaskOp } from "./plan-start-task.js"; -export { planCompleteTaskOp } from "./plan-complete-task.js"; -export { planReopenTaskOp } from "./plan-reopen-task.js"; -export { planStatusOp, type PlanStatusResult } from "./plan-status.js"; -export { planProgressOp, type PhaseProgressResult } from "./plan-progress.js"; -export { planGateOp, type GateResultOutput } from "./plan-gate.js"; - -// Query operations -export { queryNodesOp } from "./query-nodes.js"; -export { queryNodeOp, type NodeDetail } from "./query-node.js"; -export { queryRelationshipsOp } from "./query-relationships.js"; -export { queryRelationshipTypesOp } from "./query-relationship-types.js"; -export { traceFromNodeOp, type TraceNode } from "./trace-from-node.js"; - -// Temporal operations -export { timelineOp, type TimelineEvent } from "./timeline.js"; -export { nodeHistoryOp } from "./node-history.js"; -export { stateAtOp, type NodeState } from "./state-at.js"; - -// Inspection operations -export { validateOp, type ValidationResult } from "./validate.js"; -export { statsOp, type DocumentStats } from "./stats.js"; - -// New API operations (previously CLI-only) -export { searchOp } from "./search.js"; -export { checkOp } from "./check.js"; -export { graphOp } from "./graph.js"; -export { graphRefinementOp } from "./graph-refinement.js"; -export { graphDecisionOp } from "./graph-decision.js"; -export { graphDependencyOp } from "./graph-dependency.js"; -export { renameOp } from "./rename.js"; - -// Conversion operations -export { jsonToMarkdownOp } from "./json-to-markdown.js"; -export { markdownToJsonOp } from "./markdown-to-json.js"; +/** + * Operations barrel. The pure operations live in `@sysprom/core` and are + * re-exported here so existing callers (CLI, MCP) keep resolving. The + * fs-backed operations (sync, Spec-Kit) remain in this directory. + */ +export * from "@sysprom/core/operations/index.js"; // Synchronisation operations export { @@ -65,27 +17,3 @@ export { speckitImportOp } from "./speckit-import.js"; export { speckitExportOp } from "./speckit-export.js"; export { speckitSyncOp, type SyncResult } from "./speckit-sync.js"; export { speckitDiffOp, type DiffResult } from "./speckit-diff.js"; - -// Inference operations -export { - inferCompletenessOp, - type CompletenessResult, - type CompletenessOutput, -} from "./infer-completeness.js"; -export { - inferLifecycleOp, - type LifecycleResult, - type LifecycleOutput, -} from "./infer-lifecycle.js"; -export { - inferImpactOp, - impactSummaryOp, - type ImpactNode, - type ImpactOutput, - type ImpactSummaryOutput, -} from "./infer-impact.js"; -export { - inferDerivedOp, - type DerivedRelationship, - type DerivedOutput, -} from "./infer-derived.js"; diff --git a/src/operations/speckit-diff.ts b/src/operations/speckit-diff.ts index 60a5d06..e644a21 100644 --- a/src/operations/speckit-diff.ts +++ b/src/operations/speckit-diff.ts @@ -1,7 +1,6 @@ import * as z from "zod"; import { dirname } from "node:path"; -import { defineOperation } from "./define-operation.js"; -import { SysProMDocument, Node } from "../schema.js"; +import { defineOperation, SysProMDocument, Node } from "@sysprom/core"; import { parseSpecKitFeature } from "../speckit/parse.js"; import { detectSpecKitProject } from "../speckit/project.js"; diff --git a/src/operations/speckit-export.ts b/src/operations/speckit-export.ts index 34286d5..331b410 100644 --- a/src/operations/speckit-export.ts +++ b/src/operations/speckit-export.ts @@ -1,6 +1,5 @@ import * as z from "zod"; -import { defineOperation } from "./define-operation.js"; -import { SysProMDocument } from "../schema.js"; +import { defineOperation, SysProMDocument } from "@sysprom/core"; import { generateSpecKitProject } from "../speckit/generate.js"; /** Export a SysProM document to Spec-Kit format, writing specification files to the output directory. Only nodes matching the given ID prefix are exported. */ diff --git a/src/operations/speckit-import.ts b/src/operations/speckit-import.ts index d616e31..f023722 100644 --- a/src/operations/speckit-import.ts +++ b/src/operations/speckit-import.ts @@ -1,7 +1,6 @@ import * as z from "zod"; import { dirname } from "node:path"; -import { defineOperation } from "./define-operation.js"; -import { SysProMDocument } from "../schema.js"; +import { defineOperation, SysProMDocument } from "@sysprom/core"; import { parseSpecKitFeature } from "../speckit/parse.js"; import { detectSpecKitProject } from "../speckit/project.js"; diff --git a/src/operations/speckit-sync.ts b/src/operations/speckit-sync.ts index b235074..e2eacdf 100644 --- a/src/operations/speckit-sync.ts +++ b/src/operations/speckit-sync.ts @@ -1,7 +1,6 @@ import * as z from "zod"; import { dirname } from "node:path"; -import { defineOperation } from "./define-operation.js"; -import { SysProMDocument, Node } from "../schema.js"; +import { defineOperation, SysProMDocument, Node } from "@sysprom/core"; import { parseSpecKitFeature } from "../speckit/parse.js"; import { generateSpecKitProject } from "../speckit/generate.js"; import { detectSpecKitProject } from "../speckit/project.js"; diff --git a/src/operations/sync.ts b/src/operations/sync.ts index 938faba..410e365 100644 --- a/src/operations/sync.ts +++ b/src/operations/sync.ts @@ -1,6 +1,5 @@ import * as z from "zod"; -import { defineOperation } from "./define-operation.js"; -import { SysProMDocument } from "../schema.js"; +import { defineOperation, SysProMDocument } from "@sysprom/core"; /** * Conflict resolution strategy when both documents have diverged. diff --git a/src/speckit/generate.ts b/src/speckit/generate.ts index 78e0146..7ea5c64 100644 --- a/src/speckit/generate.ts +++ b/src/speckit/generate.ts @@ -1,11 +1,13 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import type { SysProMDocument, Node, Relationship } from "../schema.js"; -import { textToString } from "../text.js"; import { + type SysProMDocument, + type Node, + type Relationship, + textToString, hasLifecycleState, primaryLifecycleState, -} from "../lifecycle-state.js"; +} from "@sysprom/core"; // ============================================================================ // Helper functions diff --git a/src/speckit/index.ts b/src/speckit/index.ts index e01c2b5..bc08b0b 100644 --- a/src/speckit/index.ts +++ b/src/speckit/index.ts @@ -26,6 +26,8 @@ export { generateSpecKitProject, } from "./generate.js"; +// plan.ts moved to @sysprom/core (it is pure); re-export so existing +// `import { ... } from "sysprom/src/speckit"` callers keep resolving. export { initDocument, addTask, @@ -41,4 +43,4 @@ export { type GateResult, type BlockageReason, type TaskBlockage, -} from "./plan.js"; +} from "@sysprom/core/speckit/plan.js"; diff --git a/src/speckit/parse.ts b/src/speckit/parse.ts index 3c77a45..84119c6 100644 --- a/src/speckit/parse.ts +++ b/src/speckit/parse.ts @@ -5,7 +5,7 @@ import type { Node, Relationship, NodeStatus, -} from "../schema.js"; +} from "@sysprom/core"; // --------------------------------------------------------------------------- // Helper types diff --git a/src/sync.ts b/src/sync.ts index 1348e27..01d107b 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,7 +1,7 @@ import { readFileSync, statSync, existsSync } from "node:fs"; import { createHash } from "node:crypto"; import { markdownToJson } from "./md-to-json.js"; -import { SysProMDocument } from "./schema.js"; +import { SysProMDocument } from "@sysprom/core"; /** * Result of detecting changes between JSON and Markdown representations. diff --git a/tsconfig.json b/tsconfig.json index ffd7c97..bd1a064 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,13 @@ { + "extends": "./tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "nodenext", - "moduleResolution": "nodenext", - "strict": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "skipLibCheck": true, "outDir": "dist", "rootDir": ".", - "declaration": true + "types": ["node"], + "paths": { + "@sysprom/core": ["./packages/core/src/index.ts"], + "@sysprom/core/*": ["./packages/core/src/*"] + } }, - "types": ["node"], "include": ["src/**/*.ts", "tests/**/*.ts"] } diff --git a/turbo.json b/turbo.json index e95eb93..201e6e6 100644 --- a/turbo.json +++ b/turbo.json @@ -1,16 +1,30 @@ { "$schema": "https://turbo.build/schema.json", "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "typecheck": { + "dependsOn": ["^build"], + "outputs": [] + }, + "test": { + "dependsOn": ["^build"], + "outputs": [] + }, "_lint": { + "dependsOn": ["@sysprom/core#build"], "inputs": ["src/**/*.ts", "tests/**/*.ts", "eslint.config.ts"], "outputs": [] }, "_typecheck": { + "dependsOn": ["@sysprom/core#build"], "inputs": ["src/**/*.ts", "tests/**/*.ts", "tsconfig.json"], "outputs": [] }, "_compile": { - "dependsOn": ["_typecheck"], + "dependsOn": ["_typecheck", "@sysprom/core#build"], "inputs": ["src/**/*.ts", "tsconfig.json"], "outputs": ["dist/**"] }, @@ -20,12 +34,12 @@ "outputs": ["schema.json"] }, "_test": { - "dependsOn": ["_typecheck"], + "dependsOn": ["_typecheck", "@sysprom/core#build"], "inputs": ["src/**/*.ts", "tests/**/*.ts"], "outputs": [] }, "_test:coverage": { - "dependsOn": ["_typecheck"], + "dependsOn": ["_typecheck", "@sysprom/core#build"], "inputs": ["src/**/*.ts", "tests/**/*.ts"], "outputs": ["coverage/**"], "cache": false @@ -33,7 +47,6 @@ "_docs:cli": { "inputs": [ "src/cli/**/*.ts", - "src/schema.ts", "scripts/generate-cli-docs.ts" ], "outputs": ["docs/cli/**"] @@ -48,7 +61,13 @@ "outputs": ["site/**"] }, "_build": { - "dependsOn": ["_compile", "_schema", "_docs:cli", "_docs:api"], + "dependsOn": [ + "_compile", + "_schema", + "_docs:cli", + "_docs:api", + "@sysprom/core#build" + ], "outputs": [] }, "_validate": { From 1a400b1ff335e6cd2ffc99de68c704f4bd66cadf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 14:41:38 +0100 Subject: [PATCH 04/28] test(core): relocate pure-logic tests to @sysprom/core Moves the 11 pure-logic test suites (graph, query, infer-{completeness, derived,impact,lifecycle}, mutate, node-id, plan-task-lifecycle, safe-removal, safe-removal-phase2) into packages/core/tests/ where they type-check and run against the core package directly. These tests have no node:fs dependencies; they use only node:test/node:assert. Root tests that import moved symbols (schema types, canonicalise, operations) now import those from @sysprom/core. Tests touching the filesystem or CLI (roundtrip, json-to-md, sync-*, add-cli, coverage, speckit-*, temporal, validate, stats, safety-guards) stay at root and import pure symbols from @sysprom/core while keeping fs-backed imports from the root src wrappers. --- {tests => packages/core/tests}/graph.unit.test.ts | 0 .../core/tests}/infer-completeness.unit.test.ts | 0 {tests => packages/core/tests}/infer-derived.unit.test.ts | 0 {tests => packages/core/tests}/infer-impact.unit.test.ts | 0 {tests => packages/core/tests}/infer-lifecycle.unit.test.ts | 0 {tests => packages/core/tests}/mutate.unit.test.ts | 0 {tests => packages/core/tests}/node-id.unit.test.ts | 0 .../core/tests}/plan-task-lifecycle.unit.test.ts | 0 {tests => packages/core/tests}/query.unit.test.ts | 0 .../core/tests}/safe-removal-phase2.unit.test.ts | 0 {tests => packages/core/tests}/safe-removal.unit.test.ts | 0 tests/add-cli.unit.test.ts | 2 +- tests/coverage.unit.test.ts | 6 ++---- tests/json-to-md.unit.test.ts | 2 +- tests/roundtrip.unit.test.ts | 2 +- tests/safety-guards.unit.test.ts | 2 +- tests/speckit-generate.unit.test.ts | 2 +- tests/speckit-plan.unit.test.ts | 4 ++-- tests/stats.unit.test.ts | 2 +- tests/sync-cli-dir.unit.test.ts | 4 ++-- tests/sync-cli-exit-code.unit.test.ts | 4 ++-- tests/sync-cli.unit.test.ts | 4 ++-- tests/sync.unit.test.ts | 4 ++-- tests/temporal.unit.test.ts | 2 +- tests/validate.unit.test.ts | 2 +- 25 files changed, 20 insertions(+), 22 deletions(-) rename {tests => packages/core/tests}/graph.unit.test.ts (100%) rename {tests => packages/core/tests}/infer-completeness.unit.test.ts (100%) rename {tests => packages/core/tests}/infer-derived.unit.test.ts (100%) rename {tests => packages/core/tests}/infer-impact.unit.test.ts (100%) rename {tests => packages/core/tests}/infer-lifecycle.unit.test.ts (100%) rename {tests => packages/core/tests}/mutate.unit.test.ts (100%) rename {tests => packages/core/tests}/node-id.unit.test.ts (100%) rename {tests => packages/core/tests}/plan-task-lifecycle.unit.test.ts (100%) rename {tests => packages/core/tests}/query.unit.test.ts (100%) rename {tests => packages/core/tests}/safe-removal-phase2.unit.test.ts (100%) rename {tests => packages/core/tests}/safe-removal.unit.test.ts (100%) diff --git a/tests/graph.unit.test.ts b/packages/core/tests/graph.unit.test.ts similarity index 100% rename from tests/graph.unit.test.ts rename to packages/core/tests/graph.unit.test.ts diff --git a/tests/infer-completeness.unit.test.ts b/packages/core/tests/infer-completeness.unit.test.ts similarity index 100% rename from tests/infer-completeness.unit.test.ts rename to packages/core/tests/infer-completeness.unit.test.ts diff --git a/tests/infer-derived.unit.test.ts b/packages/core/tests/infer-derived.unit.test.ts similarity index 100% rename from tests/infer-derived.unit.test.ts rename to packages/core/tests/infer-derived.unit.test.ts diff --git a/tests/infer-impact.unit.test.ts b/packages/core/tests/infer-impact.unit.test.ts similarity index 100% rename from tests/infer-impact.unit.test.ts rename to packages/core/tests/infer-impact.unit.test.ts diff --git a/tests/infer-lifecycle.unit.test.ts b/packages/core/tests/infer-lifecycle.unit.test.ts similarity index 100% rename from tests/infer-lifecycle.unit.test.ts rename to packages/core/tests/infer-lifecycle.unit.test.ts diff --git a/tests/mutate.unit.test.ts b/packages/core/tests/mutate.unit.test.ts similarity index 100% rename from tests/mutate.unit.test.ts rename to packages/core/tests/mutate.unit.test.ts diff --git a/tests/node-id.unit.test.ts b/packages/core/tests/node-id.unit.test.ts similarity index 100% rename from tests/node-id.unit.test.ts rename to packages/core/tests/node-id.unit.test.ts diff --git a/tests/plan-task-lifecycle.unit.test.ts b/packages/core/tests/plan-task-lifecycle.unit.test.ts similarity index 100% rename from tests/plan-task-lifecycle.unit.test.ts rename to packages/core/tests/plan-task-lifecycle.unit.test.ts diff --git a/tests/query.unit.test.ts b/packages/core/tests/query.unit.test.ts similarity index 100% rename from tests/query.unit.test.ts rename to packages/core/tests/query.unit.test.ts diff --git a/tests/safe-removal-phase2.unit.test.ts b/packages/core/tests/safe-removal-phase2.unit.test.ts similarity index 100% rename from tests/safe-removal-phase2.unit.test.ts rename to packages/core/tests/safe-removal-phase2.unit.test.ts diff --git a/tests/safe-removal.unit.test.ts b/packages/core/tests/safe-removal.unit.test.ts similarity index 100% rename from tests/safe-removal.unit.test.ts rename to packages/core/tests/safe-removal.unit.test.ts diff --git a/tests/add-cli.unit.test.ts b/tests/add-cli.unit.test.ts index 712e252..e54246c 100644 --- a/tests/add-cli.unit.test.ts +++ b/tests/add-cli.unit.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { addNodeOp } from "../src/index.js"; -import type { SysProMDocument } from "../src/schema.js"; +import type { SysProMDocument } from "@sysprom/core"; function makeDoc(): SysProMDocument { return { diff --git a/tests/coverage.unit.test.ts b/tests/coverage.unit.test.ts index 5e5cc22..8a68edd 100644 --- a/tests/coverage.unit.test.ts +++ b/tests/coverage.unit.test.ts @@ -21,14 +21,12 @@ import { ExternalReference, Metadata, toJSONSchema, -} from "../src/schema.js"; -import { canonicalise } from "../src/canonical-json.js"; -import { + canonicalise, textToString, textToLines, textToMarkdown, markdownToText, -} from "../src/text.js"; +} from "@sysprom/core"; // --------------------------------------------------------------------------- // schema.ts — .is() type guards diff --git a/tests/json-to-md.unit.test.ts b/tests/json-to-md.unit.test.ts index 7ab71a1..a82dacf 100644 --- a/tests/json-to-md.unit.test.ts +++ b/tests/json-to-md.unit.test.ts @@ -14,7 +14,7 @@ import { jsonToMarkdownSingle, jsonToMarkdownMultiDoc, } from "../src/json-to-md.js"; -import type { SysProMDocument } from "../src/schema.js"; +import type { SysProMDocument } from "@sysprom/core"; function minimal(): SysProMDocument { return { diff --git a/tests/roundtrip.unit.test.ts b/tests/roundtrip.unit.test.ts index 8d1f7cf..2aff0d6 100644 --- a/tests/roundtrip.unit.test.ts +++ b/tests/roundtrip.unit.test.ts @@ -11,7 +11,7 @@ import { markdownSingleToJson, markdownMultiDocToJson, } from "../src/md-to-json.js"; -import type { SysProMDocument } from "../src/schema.js"; +import type { SysProMDocument } from "@sysprom/core"; function fixture(): SysProMDocument { return { diff --git a/tests/safety-guards.unit.test.ts b/tests/safety-guards.unit.test.ts index 970430c..22efbb3 100644 --- a/tests/safety-guards.unit.test.ts +++ b/tests/safety-guards.unit.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { addRelationshipOp, validateOp } from "../src/index.js"; -import type { SysProMDocument } from "../src/schema.js"; +import type { SysProMDocument } from "@sysprom/core"; describe("CHG33: Graph Mutation Safety Guards", () => { describe("Duplicate relationship detection", () => { diff --git a/tests/speckit-generate.unit.test.ts b/tests/speckit-generate.unit.test.ts index 80e4313..1ca316d 100644 --- a/tests/speckit-generate.unit.test.ts +++ b/tests/speckit-generate.unit.test.ts @@ -7,7 +7,7 @@ import { generateTasks, generateChecklist, } from "../src/speckit/generate.js"; -import type { SysProMDocument, Node, Relationship } from "../src/schema.js"; +import type { SysProMDocument, Node, Relationship } from "@sysprom/core"; // ============================================================================ // Helper function to create test documents diff --git a/tests/speckit-plan.unit.test.ts b/tests/speckit-plan.unit.test.ts index 40cbd70..cd12bcc 100644 --- a/tests/speckit-plan.unit.test.ts +++ b/tests/speckit-plan.unit.test.ts @@ -6,8 +6,8 @@ import { planStatus, planProgress, checkGate, -} from "../src/speckit/plan.js"; -import type { SysProMDocument, Node, Relationship } from "../src/schema.js"; +} from "@sysprom/core/speckit/plan.js"; +import type { SysProMDocument, Node, Relationship } from "@sysprom/core"; // ============================================================================ // Helper function to create test documents diff --git a/tests/stats.unit.test.ts b/tests/stats.unit.test.ts index 870a31d..bcdb367 100644 --- a/tests/stats.unit.test.ts +++ b/tests/stats.unit.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { statsOp } from "../src/index.js"; -import type { SysProMDocument, Node } from "../src/schema.js"; +import type { SysProMDocument, Node } from "@sysprom/core"; function makeDoc( nodes: Node[] = [], diff --git a/tests/sync-cli-dir.unit.test.ts b/tests/sync-cli-dir.unit.test.ts index a2fe05f..b4908b1 100644 --- a/tests/sync-cli-dir.unit.test.ts +++ b/tests/sync-cli-dir.unit.test.ts @@ -9,10 +9,10 @@ import { } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { canonicalise } from "../src/canonical-json.js"; +import { canonicalise } from "@sysprom/core"; import { syncCommand } from "../src/cli/commands/sync.js"; -import type { SysProMDocument } from "../src/schema.js"; +import type { SysProMDocument } from "@sysprom/core"; function createTestDoc(): SysProMDocument { return { diff --git a/tests/sync-cli-exit-code.unit.test.ts b/tests/sync-cli-exit-code.unit.test.ts index 85f6c10..1ebd58f 100644 --- a/tests/sync-cli-exit-code.unit.test.ts +++ b/tests/sync-cli-exit-code.unit.test.ts @@ -10,9 +10,9 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; -import { canonicalise } from "../src/canonical-json.js"; +import { canonicalise } from "@sysprom/core"; import { jsonToMarkdownSingle } from "../src/json-to-md.js"; -import type { SysProMDocument } from "../src/schema.js"; +import type { SysProMDocument } from "@sysprom/core"; function runSyncCli(args: string[]): number | null { const projectRoot = resolve(import.meta.dirname, ".."); diff --git a/tests/sync-cli.unit.test.ts b/tests/sync-cli.unit.test.ts index b58e730..a6555f8 100644 --- a/tests/sync-cli.unit.test.ts +++ b/tests/sync-cli.unit.test.ts @@ -9,11 +9,11 @@ import { } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { canonicalise } from "../src/canonical-json.js"; +import { canonicalise } from "@sysprom/core"; import { jsonToMarkdownSingle } from "../src/json-to-md.js"; import { markdownToJson } from "../src/md-to-json.js"; import { syncCommand } from "../src/cli/commands/sync.js"; -import type { SysProMDocument, Node } from "../src/schema.js"; +import type { SysProMDocument, Node } from "@sysprom/core"; function createTestDoc(): SysProMDocument { return { diff --git a/tests/sync.unit.test.ts b/tests/sync.unit.test.ts index dcf2017..1f0a9a3 100644 --- a/tests/sync.unit.test.ts +++ b/tests/sync.unit.test.ts @@ -4,9 +4,9 @@ import { mkdtempSync, rmSync, writeFileSync, utimesSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { detectChanges } from "../src/sync.js"; -import { canonicalise } from "../src/canonical-json.js"; +import { canonicalise } from "@sysprom/core"; import { jsonToMarkdownSingle } from "../src/json-to-md.js"; -import type { SysProMDocument } from "../src/schema.js"; +import type { SysProMDocument } from "@sysprom/core"; function createTestDoc(): SysProMDocument { return { diff --git a/tests/temporal.unit.test.ts b/tests/temporal.unit.test.ts index 35857a3..929f8ef 100644 --- a/tests/temporal.unit.test.ts +++ b/tests/temporal.unit.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { timelineOp, nodeHistoryOp, stateAtOp } from "../src/index.js"; -import type { SysProMDocument, Node, Relationship } from "../src/schema.js"; +import type { SysProMDocument, Node, Relationship } from "@sysprom/core"; function makeDoc( nodes: Node[] = [], diff --git a/tests/validate.unit.test.ts b/tests/validate.unit.test.ts index aa11b20..4e79d15 100644 --- a/tests/validate.unit.test.ts +++ b/tests/validate.unit.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { validateOp } from "../src/index.js"; -import type { SysProMDocument, Node } from "../src/schema.js"; +import type { SysProMDocument, Node } from "@sysprom/core"; function makeDoc( nodes: Node[] = [], From 49ffc30188e22a75b438a9304555516a78eed0b3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 16:46:36 +0100 Subject: [PATCH 05/28] build(sysprom): bundle @sysprom/core into the published package via tsdown Switch the root compile from tsc to tsdown so @sysprom/core is inlined into sysprom's dist while zod, commander, picocolors and the MCP SDK stay external. The published package no longer declares @sysprom/core (moved to devDependencies), so npm install sysprom resolves without a private dep. tsdown also emits a single self-contained index.d.mts (core types inlined) and shebangs the CLI/MCP bins. Paths, exports, files and bin entries move from dist/src/*.js to the tsdown dist/*.mjs layout. allowDefaultProject gains tsdown.config.ts. Also bumps typescript 6.0.2 -> 6.0.3 (latest; recursive Zod type elision is unchanged). --- eslint.config.ts | 6 +- package.json | 25 +- packages/core/package.json | 6 +- pnpm-lock.yaml | 711 ++++++++++++++++++++++++++++++++----- tsdown.config.ts | 25 ++ turbo.json | 7 +- 6 files changed, 672 insertions(+), 108 deletions(-) create mode 100644 tsdown.config.ts diff --git a/eslint.config.ts b/eslint.config.ts index 9ec8b83..245112e 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -230,7 +230,11 @@ export default defineConfig( languageOptions: { parserOptions: { projectService: { - allowDefaultProject: ["eslint.config.ts", "scripts/*.ts"], + allowDefaultProject: [ + "eslint.config.ts", + "tsdown.config.ts", + "scripts/*.ts", + ], }, tsconfigRootDir: import.meta.dirname, }, diff --git a/package.json b/package.json index 7c7dd37..b57dfde 100644 --- a/package.json +++ b/package.json @@ -20,23 +20,23 @@ ], "packageManager": "pnpm@10.32.1", "type": "module", - "main": "./dist/src/index.js", - "types": "./dist/src/index.d.ts", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", "exports": { ".": { - "types": "./dist/src/index.d.ts", - "import": "./dist/src/index.js" + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" } }, "files": [ - "dist/src/**/*.js", - "dist/src/**/*.d.ts", + "dist/**/*.mjs", + "dist/**/*.d.mts", "schema.json" ], "bin": { - "sysprom": "dist/src/cli/index.js", - "spm": "dist/src/cli/index.js", - "sysprom-mcp": "dist/src/mcp/index.js" + "sysprom": "dist/cli/index.mjs", + "spm": "dist/cli/index.mjs", + "sysprom-mcp": "dist/mcp/index.mjs" }, "scripts": { "build": "turbo run _build", @@ -51,7 +51,7 @@ "spm": "tsx src/cli/index.ts", "_lint": "eslint --cache .", "_typecheck": "tsc --noEmit", - "_compile": "tsc", + "_compile": "tsdown", "_schema": "tsx src/generate-schema.ts", "_test": "tsx --test tests/*.test.ts", "_test:coverage": "c8 --src src --exclude 'src/cli/**' --exclude 'src/generate-schema.ts' tsx --test tests/*.test.ts", @@ -71,7 +71,6 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", - "@sysprom/core": "workspace:*", "commander": "14.0.3", "picocolors": "1.1.1", "zod": "4.3.6" @@ -88,6 +87,7 @@ "@semantic-release/changelog": "6.0.3", "@semantic-release/exec": "7.1.0", "@semantic-release/git": "10.0.1", + "@sysprom/core": "workspace:*", "@types/node": "25.5.0", "c8": "11.0.0", "conventional-changelog-conventionalcommits": "9.3.0", @@ -101,12 +101,13 @@ "lint-staged": "16.4.0", "prettier": "3.8.1", "semantic-release": "25.0.3", + "tsdown": "0.22.3", "tsx": "4.21.0", "turbo": "2.8.20", "typedoc": "0.28.18", "typedoc-plugin-markdown": "4.11.0", "typedoc-plugin-zod": "1.4.3", - "typescript": "6.0.2", + "typescript": "6.0.3", "typescript-eslint": "8.58.0" } } diff --git a/packages/core/package.json b/packages/core/package.json index f547c3f..534da57 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,9 @@ "import": "./dist/speckit/plan.js" } }, - "files": ["dist/**"], + "files": [ + "dist/**" + ], "scripts": { "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit", @@ -35,6 +37,6 @@ "devDependencies": { "@types/node": "25.5.0", "tsx": "4.21.0", - "typescript": "6.0.2" + "typescript": "6.0.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 773b948..cb0a7d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0(zod@4.3.6) - '@sysprom/core': - specifier: workspace:* - version: link:packages/core commander: specifier: 14.0.3 version: 14.0.3 @@ -26,7 +23,7 @@ importers: devDependencies: '@commitlint/cli': specifier: 20.5.0 - version: 20.5.0(@types/node@25.5.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.3.0)(typescript@6.0.2) + version: 20.5.0(@types/node@25.5.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.3.0)(typescript@6.0.3) '@commitlint/config-conventional': specifier: 20.5.0 version: 20.5.0 @@ -50,13 +47,16 @@ importers: version: 7.5.1 '@semantic-release/changelog': specifier: 6.0.3 - version: 6.0.3(semantic-release@25.0.3(typescript@6.0.2)) + version: 6.0.3(semantic-release@25.0.3(typescript@6.0.3)) '@semantic-release/exec': specifier: 7.1.0 - version: 7.1.0(semantic-release@25.0.3(typescript@6.0.2)) + version: 7.1.0(semantic-release@25.0.3(typescript@6.0.3)) '@semantic-release/git': specifier: 10.0.1 - version: 10.0.1(semantic-release@25.0.3(typescript@6.0.2)) + version: 10.0.1(semantic-release@25.0.3(typescript@6.0.3)) + '@sysprom/core': + specifier: workspace:* + version: link:packages/core '@types/node': specifier: 25.5.0 version: 25.5.0 @@ -95,7 +95,10 @@ importers: version: 3.8.1 semantic-release: specifier: 25.0.3 - version: 25.0.3(typescript@6.0.2) + version: 25.0.3(typescript@6.0.3) + tsdown: + specifier: 0.22.3 + version: 0.22.3(tsx@4.21.0)(typescript@6.0.3) tsx: specifier: 4.21.0 version: 4.21.0 @@ -104,19 +107,19 @@ importers: version: 2.8.20 typedoc: specifier: 0.28.18 - version: 0.28.18(typescript@6.0.2) + version: 0.28.18(typescript@6.0.3) typedoc-plugin-markdown: specifier: 4.11.0 - version: 4.11.0(typedoc@0.28.18(typescript@6.0.2)) + version: 4.11.0(typedoc@0.28.18(typescript@6.0.3)) typedoc-plugin-zod: specifier: 1.4.3 - version: 1.4.3(typedoc@0.28.18(typescript@6.0.2)) + version: 1.4.3(typedoc@0.28.18(typescript@6.0.3)) typescript: - specifier: 6.0.2 - version: 6.0.2 + specifier: 6.0.3 + version: 6.0.3 typescript-eslint: specifier: 8.58.0 - version: 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + version: 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3) packages/core: dependencies: @@ -131,8 +134,8 @@ importers: specifier: 4.21.0 version: 4.21.0 typescript: - specifier: 6.0.2 - version: 6.0.2 + specifier: 6.0.3 + version: 6.0.3 packages: @@ -152,10 +155,31 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/generator@8.0.0': + resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-string-parser@8.0.0': + resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.2': + resolution: {integrity: sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/parser@8.0.0': + resolution: {integrity: sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + + '@babel/types@8.0.0': + resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -245,6 +269,15 @@ packages: conventional-commits-parser: optional: true + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@es-joy/jsdoccomment@0.84.0': resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -516,6 +549,9 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -536,6 +572,12 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -584,6 +626,9 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -600,6 +645,107 @@ packages: resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} engines: {node: '>=12'} + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.1.2': + resolution: {integrity: sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.2': + resolution: {integrity: sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.2': + resolution: {integrity: sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.2': + resolution: {integrity: sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.2': + resolution: {integrity: sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.2': + resolution: {integrity: sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.2': + resolution: {integrity: sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.2': + resolution: {integrity: sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.2': + resolution: {integrity: sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.2': + resolution: {integrity: sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.2': + resolution: {integrity: sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.2': + resolution: {integrity: sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.2': + resolution: {integrity: sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.2': + resolution: {integrity: sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.2': + resolution: {integrity: sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -718,6 +864,9 @@ packages: cpu: [arm64] os: [win32] + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -733,6 +882,9 @@ packages: '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -874,6 +1026,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -890,6 +1046,10 @@ packages: array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + ast-kit@3.0.0: + resolution: {integrity: sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ==} + engines: {node: ^22.18.0 || >=24.11.0} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -900,6 +1060,9 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + birpc@4.0.0: + resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -940,6 +1103,10 @@ packages: monocart-coverage-reports: optional: true + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1139,6 +1306,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1158,6 +1328,15 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1177,6 +1356,10 @@ packages: emojilib@2.4.0: resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1317,6 +1500,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1504,6 +1690,10 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + git-log-parser@1.2.1: resolution: {integrity: sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ==} @@ -1577,6 +1767,9 @@ packages: resolution: {integrity: sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==} engines: {node: '>=20'} + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + hosted-git-info@7.0.2: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} @@ -1643,6 +1836,10 @@ packages: import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + import-without-cache@0.4.0: + resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} + engines: {node: ^22.18.0 || >=24.0.0} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -1775,6 +1972,11 @@ packages: resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} engines: {node: '>=20.0.0'} + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -2246,6 +2448,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -2387,6 +2593,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2449,6 +2658,9 @@ packages: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -2522,6 +2734,30 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown-plugin-dts@0.26.0: + resolution: {integrity: sha512-e+kEPtUiDES0htk5iqkSeF4EzAV7R+vugGB44iPDuw1Kw9E+WyL1VG7PaV0IIjGHLiacztMBcMTyrr8ON9CT1Q==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@ts-macro/tsc': ^0.3.6 + '@typescript/native-preview': '>=7.0.0-dev.20260325.1' + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@ts-macro/tsc': + optional: true + '@typescript/native-preview': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.1.2: + resolution: {integrity: sha512-x0CrQQqCXWGeI8dTvFfN/Dnv3yMKT9hv5jFjlOreKAx9wqLq9wz7VvLLHyaAXC90/CpggTu9SisSbsJJTPSjNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -2550,6 +2786,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -2745,10 +2986,18 @@ packages: resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2765,12 +3014,53 @@ packages: resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==} engines: {node: '>= 0.4'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' + tsdown@0.22.3: + resolution: {integrity: sha512-louqbfA8Qf//B9jTTL0FPtXTNpjCWv1VPkbcmQMph2pTpzs+LnB1tbe4tDDRVpo2BjF5SgUXaTZe45SxB8pWHg==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.3 + '@tsdown/exe': 0.22.3 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -2833,8 +3123,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - typescript@6.0.2: - resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true @@ -2846,6 +3136,9 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -3027,18 +3320,40 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/generator@8.0.0': + dependencies: + '@babel/parser': 8.0.0 + '@babel/types': 8.0.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + + '@babel/helper-string-parser@8.0.0': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@8.0.2': {} + + '@babel/parser@8.0.0': + dependencies: + '@babel/types': 8.0.0 + + '@babel/types@8.0.0': + dependencies: + '@babel/helper-string-parser': 8.0.0 + '@babel/helper-validator-identifier': 8.0.2 + '@bcoe/v8-coverage@1.0.2': {} '@colors/colors@1.5.0': optional: true - '@commitlint/cli@20.5.0(@types/node@25.5.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.3.0)(typescript@6.0.2)': + '@commitlint/cli@20.5.0(@types/node@25.5.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.3.0)(typescript@6.0.3)': dependencies: '@commitlint/format': 20.5.0 '@commitlint/lint': 20.5.0 - '@commitlint/load': 20.5.0(@types/node@25.5.0)(typescript@6.0.2) + '@commitlint/load': 20.5.0(@types/node@25.5.0)(typescript@6.0.3) '@commitlint/read': 20.5.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.3.0) '@commitlint/types': 20.5.0 tinyexec: 1.0.4 @@ -3087,14 +3402,14 @@ snapshots: '@commitlint/rules': 20.5.0 '@commitlint/types': 20.5.0 - '@commitlint/load@20.5.0(@types/node@25.5.0)(typescript@6.0.2)': + '@commitlint/load@20.5.0(@types/node@25.5.0)(typescript@6.0.3)': dependencies: '@commitlint/config-validator': 20.5.0 '@commitlint/execute-rule': 20.0.0 '@commitlint/resolve-extends': 20.5.0 '@commitlint/types': 20.5.0 - cosmiconfig: 9.0.1(typescript@6.0.2) - cosmiconfig-typescript-loader: 6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@6.0.2))(typescript@6.0.2) + cosmiconfig: 9.0.1(typescript@6.0.3) + cosmiconfig-typescript-loader: 6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3) is-plain-obj: 4.1.0 lodash.mergewith: 4.6.2 picocolors: 1.1.1 @@ -3157,6 +3472,22 @@ snapshots: conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.3.0 + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@es-joy/jsdoccomment@0.84.0': dependencies: '@types/estree': 1.0.8 @@ -3362,6 +3693,11 @@ snapshots: '@istanbuljs/schema@0.1.3': {} + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -3393,6 +3729,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.2 + optional: true + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.6': @@ -3453,6 +3796,8 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@oxc-project/types@0.137.0': {} + '@pkgr/core@0.2.9': {} '@pnpm/config.env-replace@1.1.0': {} @@ -3467,17 +3812,72 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.1.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.2': + optional: true + + '@rolldown/binding-darwin-x64@1.1.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.2': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.2': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@sec-ant/readable-stream@0.4.1': {} - '@semantic-release/changelog@6.0.3(semantic-release@25.0.3(typescript@6.0.2))': + '@semantic-release/changelog@6.0.3(semantic-release@25.0.3(typescript@6.0.3))': dependencies: '@semantic-release/error': 3.0.0 aggregate-error: 3.1.0 fs-extra: 11.3.4 lodash: 4.17.23 - semantic-release: 25.0.3(typescript@6.0.2) + semantic-release: 25.0.3(typescript@6.0.3) - '@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.3(typescript@6.0.2))': + '@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.3(typescript@6.0.3))': dependencies: conventional-changelog-angular: 8.3.0 conventional-changelog-writer: 8.4.0 @@ -3487,7 +3887,7 @@ snapshots: import-from-esm: 2.0.0 lodash-es: 4.17.23 micromatch: 4.0.8 - semantic-release: 25.0.3(typescript@6.0.2) + semantic-release: 25.0.3(typescript@6.0.3) transitivePeerDependencies: - supports-color @@ -3495,7 +3895,7 @@ snapshots: '@semantic-release/error@4.0.0': {} - '@semantic-release/exec@7.1.0(semantic-release@25.0.3(typescript@6.0.2))': + '@semantic-release/exec@7.1.0(semantic-release@25.0.3(typescript@6.0.3))': dependencies: '@semantic-release/error': 4.0.0 aggregate-error: 3.1.0 @@ -3503,11 +3903,11 @@ snapshots: execa: 9.6.1 lodash-es: 4.17.23 parse-json: 8.3.0 - semantic-release: 25.0.3(typescript@6.0.2) + semantic-release: 25.0.3(typescript@6.0.3) transitivePeerDependencies: - supports-color - '@semantic-release/git@10.0.1(semantic-release@25.0.3(typescript@6.0.2))': + '@semantic-release/git@10.0.1(semantic-release@25.0.3(typescript@6.0.3))': dependencies: '@semantic-release/error': 3.0.0 aggregate-error: 3.1.0 @@ -3517,11 +3917,11 @@ snapshots: lodash: 4.17.23 micromatch: 4.0.8 p-reduce: 2.1.0 - semantic-release: 25.0.3(typescript@6.0.2) + semantic-release: 25.0.3(typescript@6.0.3) transitivePeerDependencies: - supports-color - '@semantic-release/github@12.0.6(semantic-release@25.0.3(typescript@6.0.2))': + '@semantic-release/github@12.0.6(semantic-release@25.0.3(typescript@6.0.3))': dependencies: '@octokit/core': 7.0.6 '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) @@ -3537,14 +3937,14 @@ snapshots: lodash-es: 4.17.23 mime: 4.1.0 p-filter: 4.1.0 - semantic-release: 25.0.3(typescript@6.0.2) + semantic-release: 25.0.3(typescript@6.0.3) tinyglobby: 0.2.15 undici: 7.24.5 url-join: 5.0.0 transitivePeerDependencies: - supports-color - '@semantic-release/npm@13.1.5(semantic-release@25.0.3(typescript@6.0.2))': + '@semantic-release/npm@13.1.5(semantic-release@25.0.3(typescript@6.0.3))': dependencies: '@actions/core': 3.0.0 '@semantic-release/error': 4.0.0 @@ -3559,11 +3959,11 @@ snapshots: rc: 1.2.8 read-pkg: 10.1.0 registry-auth-token: 5.1.1 - semantic-release: 25.0.3(typescript@6.0.2) + semantic-release: 25.0.3(typescript@6.0.3) semver: 7.7.4 tempy: 3.2.0 - '@semantic-release/release-notes-generator@14.1.0(semantic-release@25.0.3(typescript@6.0.2))': + '@semantic-release/release-notes-generator@14.1.0(semantic-release@25.0.3(typescript@6.0.3))': dependencies: conventional-changelog-angular: 8.3.0 conventional-changelog-writer: 8.4.0 @@ -3575,7 +3975,7 @@ snapshots: into-stream: 7.0.0 lodash-es: 4.17.23 read-package-up: 11.0.0 - semantic-release: 25.0.3(typescript@6.0.2) + semantic-release: 25.0.3(typescript@6.0.3) transitivePeerDependencies: - supports-color @@ -3629,6 +4029,11 @@ snapshots: '@turbo/windows-arm64@2.8.20': optional: true + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -3643,6 +4048,8 @@ snapshots: '@types/istanbul-lib-coverage@2.0.6': {} + '@types/jsesc@2.5.1': {} + '@types/json-schema@7.0.15': {} '@types/mdast@4.0.4': @@ -3659,40 +4066,40 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.58.0 eslint: 10.1.0(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.58.0 '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 eslint: 10.1.0(jiti@2.6.1) - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.58.0(typescript@6.0.2)': + '@typescript-eslint/project-service@8.58.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.3) '@typescript-eslint/types': 8.58.0 debug: 4.4.3 - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -3701,47 +4108,47 @@ snapshots: '@typescript-eslint/types': 8.58.0 '@typescript-eslint/visitor-keys': 8.58.0 - '@typescript-eslint/tsconfig-utils@8.58.0(typescript@6.0.2)': + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@6.0.3)': dependencies: - typescript: 6.0.2 + typescript: 6.0.3 - '@typescript-eslint/type-utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/type-utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3) debug: 4.4.3 eslint: 10.1.0(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.58.0': {} - '@typescript-eslint/typescript-estree@8.58.0(typescript@6.0.2)': + '@typescript-eslint/typescript-estree@8.58.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.58.0(typescript@6.0.2) - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2) + '@typescript-eslint/project-service': 8.58.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.3) '@typescript-eslint/types': 8.58.0 '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.15 - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.58.0 '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.3) eslint: 10.1.0(jiti@2.6.1) - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -3809,6 +4216,8 @@ snapshots: ansi-styles@6.2.3: {} + ansis@4.3.1: {} + any-promise@1.3.0: {} are-docs-informative@0.0.2: {} @@ -3819,12 +4228,20 @@ snapshots: array-ify@1.0.0: {} + ast-kit@3.0.0: + dependencies: + '@babel/parser': 8.0.0 + estree-walker: 3.0.3 + pathe: 2.0.3 + balanced-match@1.0.2: {} balanced-match@4.0.4: {} before-after-hook@4.0.0: {} + birpc@4.0.0: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -3876,6 +4293,8 @@ snapshots: yargs: 17.7.2 yargs-parser: 21.1.1 + cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4027,21 +4446,21 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - cosmiconfig-typescript-loader@6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@6.0.2))(typescript@6.0.2): + cosmiconfig-typescript-loader@6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3): dependencies: '@types/node': 25.5.0 - cosmiconfig: 9.0.1(typescript@6.0.2) + cosmiconfig: 9.0.1(typescript@6.0.3) jiti: 2.6.1 - typescript: 6.0.2 + typescript: 6.0.3 - cosmiconfig@9.0.1(typescript@6.0.2): + cosmiconfig@9.0.1(typescript@6.0.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 js-yaml: 4.1.1 parse-json: 5.2.0 optionalDependencies: - typescript: 6.0.2 + typescript: 6.0.3 cross-spawn@7.0.6: dependencies: @@ -4065,6 +4484,8 @@ snapshots: deep-is@0.1.4: {} + defu@6.1.7: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -4081,6 +4502,8 @@ snapshots: dependencies: is-obj: 2.0.0 + dts-resolver@3.0.0: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4099,6 +4522,8 @@ snapshots: emojilib@2.4.0: {} + empathic@2.0.1: {} + encodeurl@2.0.0: {} entities@4.5.0: {} @@ -4211,8 +4636,8 @@ snapshots: minimatch: 10.2.4 scslre: 0.3.0 semver: 7.7.4 - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 eslint-scope@9.1.2: dependencies: @@ -4286,6 +4711,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} @@ -4513,6 +4942,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + git-log-parser@1.2.1: dependencies: argv-formatter: 1.0.0 @@ -4581,6 +5014,8 @@ snapshots: hook-std@4.0.0: {} + hookable@6.1.1: {} + hosted-git-info@7.0.2: dependencies: lru-cache: 10.4.3 @@ -4645,6 +5080,8 @@ snapshots: import-meta-resolve@4.2.0: {} + import-without-cache@0.4.0: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -4737,6 +5174,8 @@ snapshots: jsdoc-type-pratt-parser@7.1.1: {} + jsesc@3.1.0: {} + json-buffer@3.0.1: {} json-parse-better-errors@1.0.2: {} @@ -5304,6 +5743,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.1.3: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -5428,6 +5869,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5472,6 +5915,8 @@ snapshots: dependencies: side-channel: 1.1.0 + quansync@1.0.0: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -5558,6 +6003,43 @@ snapshots: rfdc@1.4.1: {} + rolldown-plugin-dts@0.26.0(rolldown@1.1.2)(typescript@6.0.3): + dependencies: + '@babel/generator': 8.0.0 + '@babel/helper-validator-identifier': 8.0.2 + '@babel/parser': 8.0.0 + ast-kit: 3.0.0 + birpc: 4.0.0 + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.1.3 + rolldown: 1.1.2 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.1.2: + dependencies: + '@oxc-project/types': 0.137.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.2 + '@rolldown/binding-darwin-arm64': 1.1.2 + '@rolldown/binding-darwin-x64': 1.1.2 + '@rolldown/binding-freebsd-x64': 1.1.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.2 + '@rolldown/binding-linux-arm64-gnu': 1.1.2 + '@rolldown/binding-linux-arm64-musl': 1.1.2 + '@rolldown/binding-linux-ppc64-gnu': 1.1.2 + '@rolldown/binding-linux-s390x-gnu': 1.1.2 + '@rolldown/binding-linux-x64-gnu': 1.1.2 + '@rolldown/binding-linux-x64-musl': 1.1.2 + '@rolldown/binding-openharmony-arm64': 1.1.2 + '@rolldown/binding-wasm32-wasi': 1.1.2 + '@rolldown/binding-win32-arm64-msvc': 1.1.2 + '@rolldown/binding-win32-x64-msvc': 1.1.2 + router@2.2.0: dependencies: debug: 4.4.3 @@ -5578,15 +6060,15 @@ snapshots: refa: 0.12.1 regexp-ast-analysis: 0.7.1 - semantic-release@25.0.3(typescript@6.0.2): + semantic-release@25.0.3(typescript@6.0.3): dependencies: - '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.3(typescript@6.0.2)) + '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.3(typescript@6.0.3)) '@semantic-release/error': 4.0.0 - '@semantic-release/github': 12.0.6(semantic-release@25.0.3(typescript@6.0.2)) - '@semantic-release/npm': 13.1.5(semantic-release@25.0.3(typescript@6.0.2)) - '@semantic-release/release-notes-generator': 14.1.0(semantic-release@25.0.3(typescript@6.0.2)) + '@semantic-release/github': 12.0.6(semantic-release@25.0.3(typescript@6.0.3)) + '@semantic-release/npm': 13.1.5(semantic-release@25.0.3(typescript@6.0.3)) + '@semantic-release/release-notes-generator': 14.1.0(semantic-release@25.0.3(typescript@6.0.3)) aggregate-error: 5.0.0 - cosmiconfig: 9.0.1(typescript@6.0.2) + cosmiconfig: 9.0.1(typescript@6.0.3) debug: 4.4.3 env-ci: 11.2.0 execa: 9.6.1 @@ -5616,6 +6098,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.4: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -5837,11 +6321,18 @@ snapshots: tinyexec@1.0.4: {} + tinyexec@1.2.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -5855,9 +6346,40 @@ snapshots: traverse@0.6.8: {} - ts-api-utils@2.5.0(typescript@6.0.2): + tree-kill@1.2.2: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tsdown@0.22.3(tsx@4.21.0)(typescript@6.0.3): dependencies: - typescript: 6.0.2 + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + obug: 2.1.3 + picomatch: 4.0.4 + rolldown: 1.1.2 + rolldown-plugin-dts: 0.26.0(rolldown@1.1.2)(typescript@6.0.3) + semver: 7.8.4 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + optionalDependencies: + tsx: 4.21.0 + typescript: 6.0.3 + transitivePeerDependencies: + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - vue-tsc + + tslib@2.8.1: + optional: true tsx@4.21.0: dependencies: @@ -5897,41 +6419,46 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typedoc-plugin-markdown@4.11.0(typedoc@0.28.18(typescript@6.0.2)): + typedoc-plugin-markdown@4.11.0(typedoc@0.28.18(typescript@6.0.3)): dependencies: - typedoc: 0.28.18(typescript@6.0.2) + typedoc: 0.28.18(typescript@6.0.3) - typedoc-plugin-zod@1.4.3(typedoc@0.28.18(typescript@6.0.2)): + typedoc-plugin-zod@1.4.3(typedoc@0.28.18(typescript@6.0.3)): dependencies: - typedoc: 0.28.18(typescript@6.0.2) + typedoc: 0.28.18(typescript@6.0.3) - typedoc@0.28.18(typescript@6.0.2): + typedoc@0.28.18(typescript@6.0.3): dependencies: '@gerrit0/mini-shiki': 3.23.0 lunr: 2.3.9 markdown-it: 14.1.1 minimatch: 10.2.4 - typescript: 6.0.2 + typescript: 6.0.3 yaml: 2.8.3 - typescript-eslint@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2): + typescript-eslint@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3) eslint: 10.1.0(jiti@2.6.1) - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - typescript@6.0.2: {} + typescript@6.0.3: {} uc.micro@2.1.0: {} uglify-js@3.19.3: optional: true + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + undici-types@7.18.2: {} undici@6.24.1: {} diff --git a/tsdown.config.ts b/tsdown.config.ts new file mode 100644 index 0000000..9366cd4 --- /dev/null +++ b/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "tsdown"; + +/** + * Build the published `sysprom` package. + * + * `@sysprom/core` is inlined (alwaysBundle) so the published package has no + * runtime dependency on it; the real runtime deps and Node builtins stay + * external. CLI/MCP entries get a shebang via onSuccess. + */ +export default defineConfig({ + entry: ["src/index.ts", "src/cli/index.ts", "src/mcp/index.ts"], + format: "esm", + outDir: "dist", + clean: true, + dts: true, + deps: { + neverBundle: [ + "zod", + "commander", + "picocolors", + "@modelcontextprotocol/sdk", + ], + alwaysBundle: [/^@sysprom\/core/], + }, +}); diff --git a/turbo.json b/turbo.json index 201e6e6..0df22d9 100644 --- a/turbo.json +++ b/turbo.json @@ -25,7 +25,12 @@ }, "_compile": { "dependsOn": ["_typecheck", "@sysprom/core#build"], - "inputs": ["src/**/*.ts", "tsconfig.json"], + "inputs": [ + "src/**/*.ts", + "packages/core/src/**/*.ts", + "tsconfig.json", + "tsdown.config.ts" + ], "outputs": ["dist/**"] }, "_schema": { From 6ad4cb940962964fbca18cbda08d094a35c71c5c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 16:57:30 +0100 Subject: [PATCH 06/28] feat(web): scaffold Vite + React + vanilla-extract viewer Add packages/web as a workspace package: Vite 6, React 19, TypeScript, vanilla-extract for zero-runtime CSS, Radix UI primitives for headless tabs. Configured for GitHub Pages root deployment (base: '/'). All domain logic comes from @sysprom/core (workspace:*); nothing imports from the root sysprom package or node:* builtins. --- packages/web/.gitignore | 2 + packages/web/index.html | 12 + packages/web/package.json | 30 + packages/web/tsconfig.json | 18 + packages/web/vite.config.ts | 8 + pnpm-lock.yaml | 3407 ++++++++++++++++++++++++++++++++++- 6 files changed, 3390 insertions(+), 87 deletions(-) create mode 100644 packages/web/.gitignore create mode 100644 packages/web/index.html create mode 100644 packages/web/package.json create mode 100644 packages/web/tsconfig.json create mode 100644 packages/web/vite.config.ts diff --git a/packages/web/.gitignore b/packages/web/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/packages/web/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 0000000..33ebb8f --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,12 @@ + + + + + + SysProM Viewer + + +
+ + + diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..e996550 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sysprom/core": "workspace:*", + "@radix-ui/react-dialog": "1.1.17", + "@radix-ui/react-select": "2.3.1", + "@radix-ui/react-tabs": "1.1.15", + "@radix-ui/react-tooltip": "1.2.10", + "mermaid": "11.15.0", + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vanilla-extract/vite-plugin": "5.2.2", + "@vitejs/plugin-react": "5.2.0", + "typescript": "6.0.3", + "vite": "6.4.3" + } +} diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 0000000..805d2a9 --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "paths": { + "@sysprom/core": ["../core/src/index.ts"] + } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts new file mode 100644 index 0000000..1e74279 --- /dev/null +++ b/packages/web/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; + +export default defineConfig({ + base: "/", + plugins: [react(), vanillaExtractPlugin()], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb0a7d5..b7b9b38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,52 @@ importers: specifier: 6.0.3 version: 6.0.3 + packages/web: + dependencies: + '@radix-ui/react-dialog': + specifier: 1.1.17 + version: 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-select': + specifier: 2.3.1 + version: 2.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': + specifier: 1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip': + specifier: 1.2.10 + version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@sysprom/core': + specifier: workspace:* + version: link:../core + mermaid: + specifier: 11.15.0 + version: 11.15.0 + react: + specifier: 19.2.7 + version: 19.2.7 + react-dom: + specifier: 19.2.7 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/react': + specifier: 19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vanilla-extract/vite-plugin': + specifier: 5.2.2 + version: 5.2.2(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + '@vitejs/plugin-react': + specifier: 5.2.0 + version: 5.2.0(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 6.4.3 + version: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + packages: '@actions/core@3.0.0': @@ -151,14 +197,59 @@ packages: '@actions/io@3.0.2': resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + '@babel/generator@8.0.0': resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0': resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} engines: {node: ^22.18.0 || >=24.11.0} @@ -167,15 +258,66 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.2': resolution: {integrity: sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@8.0.0': resolution: {integrity: sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==} engines: {node: ^22.18.0 || >=24.11.0} hasBin: true + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@babel/types@8.0.0': resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==} engines: {node: ^22.18.0 || >=24.11.0} @@ -184,6 +326,12 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -269,15 +417,27 @@ packages: conventional-commits-parser: optional: true + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + '@es-joy/jsdoccomment@0.84.0': resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -286,156 +446,312 @@ packages: resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} engines: {node: '>=10'} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.4': resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.4': resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.4': resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.4': resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.4': resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.4': resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.4': resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.4': resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.4': resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.4': resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.4': resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.4': resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.4': resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.4': resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.4': resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.4': resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.4': resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.4': resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.4': resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.4': resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.4': resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.4': resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.4': resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.4': resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.4': resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} @@ -516,6 +832,21 @@ packages: resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@gerrit0/mini-shiki@3.23.0': resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} @@ -545,6 +876,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.3': + resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@istanbuljs/schema@0.1.3': resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} @@ -552,6 +889,9 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -562,6 +902,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mermaid-js/parser@1.1.1': + resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -626,6 +969,9 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-project/types@0.137.0': resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} @@ -648,70 +994,459 @@ packages: '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - '@rolldown/binding-android-arm64@1.1.2': - resolution: {integrity: sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.1.2': - resolution: {integrity: sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] + '@radix-ui/number@1.1.2': + resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} - '@rolldown/binding-darwin-x64@1.1.2': - resolution: {integrity: sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] + '@radix-ui/primitive@1.1.4': + resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} - '@rolldown/binding-freebsd-x64@1.1.2': - resolution: {integrity: sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] + '@radix-ui/react-arrow@1.1.10': + resolution: {integrity: sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.2': - resolution: {integrity: sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] + '@radix-ui/react-collection@1.1.10': + resolution: {integrity: sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.2': - resolution: {integrity: sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true - '@rolldown/binding-linux-arm64-musl@1.1.2': - resolution: {integrity: sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] + '@radix-ui/react-context@1.1.4': + resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.2': - resolution: {integrity: sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] + '@radix-ui/react-dialog@1.1.17': + resolution: {integrity: sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.2': - resolution: {integrity: sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] + '@radix-ui/react-direction@1.1.2': + resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true - '@rolldown/binding-linux-x64-gnu@1.1.2': - resolution: {integrity: sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] + '@radix-ui/react-dismissable-layer@1.1.13': + resolution: {integrity: sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.4': + resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.10': + resolution: {integrity: sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.2': + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-popper@1.3.1': + resolution: {integrity: sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.12': + resolution: {integrity: sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.6': + resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.6': + resolution: {integrity: sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.13': + resolution: {integrity: sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.3.1': + resolution: {integrity: sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.0': + resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.15': + resolution: {integrity: sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.10': + resolution: {integrity: sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.2': + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.2': + resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.2': + resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.2': + resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.2': + resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.6': + resolution: {integrity: sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.2': + resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-android-arm64@1.1.2': + resolution: {integrity: sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-arm64@1.1.2': + resolution: {integrity: sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.2': + resolution: {integrity: sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-freebsd-x64@1.1.2': + resolution: {integrity: sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.2': + resolution: {integrity: sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-gnu@1.1.2': + resolution: {integrity: sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-arm64-musl@1.1.2': + resolution: {integrity: sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-ppc64-gnu@1.1.2': + resolution: {integrity: sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.2': + resolution: {integrity: sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.2': + resolution: {integrity: sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-musl@1.1.2': resolution: {integrity: sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw==} @@ -720,31 +1455,195 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.2': - resolution: {integrity: sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-openharmony-arm64@1.1.2': + resolution: {integrity: sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-wasm32-wasi@1.1.2': + resolution: {integrity: sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-arm64-msvc@1.1.2': + resolution: {integrity: sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.2': + resolution: {integrity: sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.2': - resolution: {integrity: sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.2': - resolution: {integrity: sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.2': - resolution: {integrity: sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.1': - resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -867,6 +1766,111 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -876,6 +1880,12 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -900,6 +1910,17 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -962,6 +1983,35 @@ packages: resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@vanilla-extract/babel-plugin-debug-ids@1.2.2': + resolution: {integrity: sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw==} + + '@vanilla-extract/compiler@0.7.0': + resolution: {integrity: sha512-rZQ40HVmsxfGLjoflwwsaUBLfpbpKDoZC19oiDA0FHq4LdrYtyVbFkc0MfqkNo/qBCvaZfsRezCqk0QQxCqZ8w==} + + '@vanilla-extract/css@1.20.1': + resolution: {integrity: sha512-5I9RNo5uZW9tsBnqrWzJqELegOqTHBrZyDFnES0gR9gJJHBB9dom1N0bwITM9tKwBcfKrTX4a6DHVeQdJ2ubQA==} + + '@vanilla-extract/integration@8.0.10': + resolution: {integrity: sha512-01IB5gbrgTe8IIrtfRXXTmACl5D8Enzqp2cKbCWaMKXmnoilXXVCPbJoA96q88PXkNDXsXepCxUugMvEmL3c7A==} + + '@vanilla-extract/private@1.0.9': + resolution: {integrity: sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==} + + '@vanilla-extract/vite-plugin@5.2.2': + resolution: {integrity: sha512-AUyB4fDR2b/Mo0lcXhhlf6RxnDPYwFMyKKopalJ4BwQNKYzZSoTwHJ1PLPO9SKhpz7lzXc0Z18GHQZOewzl3YA==} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1043,6 +2093,10 @@ packages: argv-formatter@1.0.0: resolution: {integrity: sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} @@ -1057,6 +2111,11 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.38: + resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + engines: {node: '>=6.0.0'} + hasBin: true + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -1085,6 +2144,11 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -1119,6 +2183,9 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1197,6 +2264,14 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + comment-parser@1.4.5: resolution: {integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==} engines: {node: '>= 12.0.0'} @@ -1207,6 +2282,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -1262,6 +2340,12 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cosmiconfig-typescript-loader@6.2.0: resolution: {integrity: sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==} engines: {node: '>=v18'} @@ -1287,6 +2371,172 @@ packages: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} engines: {node: '>=12'} + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1299,6 +2549,14 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -1306,9 +2564,19 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deep-object-diff@1.1.9: + resolution: {integrity: sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1317,6 +2585,13 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -1324,6 +2599,9 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -1347,6 +2625,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.376: + resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -1391,10 +2672,21 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-toolkit@1.47.1: + resolution: {integrity: sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.4: resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} @@ -1511,6 +2803,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eval@0.1.8: + resolution: {integrity: sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==} + engines: {node: '>= 0.8'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -1655,6 +2951,10 @@ packages: functional-red-black-tree@1.0.1: resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1667,6 +2967,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -1735,6 +3039,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -1813,6 +3120,10 @@ packages: engines: {node: '>=18'} hasBin: true + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -1866,6 +3177,13 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + into-stream@7.0.0: resolution: {integrity: sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==} engines: {node: '>=12'} @@ -1954,6 +3272,9 @@ packages: resolution: {integrity: sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==} engines: {node: '>= 0.6.0'} + javascript-stringify@2.1.0: + resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -2001,6 +3322,11 @@ packages: json-with-bigint@3.5.8: resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} @@ -2008,12 +3334,99 @@ packages: resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2098,6 +3511,9 @@ packages: resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} engines: {node: 20 || >=22} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} @@ -2127,6 +3543,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2170,6 +3591,9 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-query-parser@2.0.2: + resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -2185,6 +3609,9 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + mermaid@11.15.0: + resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -2319,12 +3746,23 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + modern-ahocorasick@1.1.0: + resolution: {integrity: sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.13: + resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -2342,6 +3780,10 @@ packages: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} + node-releases@2.0.48: + resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + engines: {node: '>=18'} + normalize-package-data@6.0.2: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} @@ -2527,6 +3969,9 @@ packages: resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} engines: {node: '>=4'} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2566,6 +4011,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -2619,6 +4067,19 @@ packages: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2673,6 +4134,49 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + read-package-up@11.0.0: resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} engines: {node: '>=18'} @@ -2712,6 +4216,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-like@0.1.2: + resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} + reserved-identifiers@1.2.0: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} engines: {node: '>=18'} @@ -2734,6 +4241,9 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown-plugin-dts@0.26.0: resolution: {integrity: sha512-e+kEPtUiDES0htk5iqkSeF4EzAV7R+vugGB44iPDuw1Kw9E+WyL1VG7PaV0IIjGHLiacztMBcMTyrr8ON9CT1Q==} engines: {node: ^22.18.0 || >=24.11.0} @@ -2753,21 +4263,40 @@ packages: vue-tsc: optional: true + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rolldown@1.1.2: resolution: {integrity: sha512-x0CrQQqCXWGeI8dTvFfN/Dnv3yMKT9hv5jFjlOreKAx9wqLq9wz7VvLLHyaAXC90/CpggTu9SisSbsJJTPSjNQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} @@ -2781,6 +4310,10 @@ packages: resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==} engines: {node: '>=12'} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -2849,6 +4382,10 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -2932,6 +4469,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + super-regex@1.1.0: resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} engines: {node: '>=18'} @@ -3024,6 +4564,10 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + tsdown@0.22.3: resolution: {integrity: sha512-louqbfA8Qf//B9jTTL0FPtXTNpjCWv1VPkbcmQMph2pTpzs+LnB1tbe4tDDRVpo2BjF5SgUXaTZe45SxB8pWHg==} engines: {node: ^22.18.0 || >=24.11.0} @@ -3131,6 +4675,9 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -3193,6 +4740,12 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -3200,9 +4753,33 @@ packages: resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -3214,6 +4791,94 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-node@6.0.0: + resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + web-worker@1.5.0: resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} @@ -3248,6 +4913,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -3314,12 +4982,53 @@ snapshots: '@actions/io@3.0.2': {} + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.2.4 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/generator@8.0.0': dependencies: '@babel/parser': 8.0.0 @@ -3329,16 +5038,99 @@ snapshots: '@types/jsesc': 2.5.1 jsesc: 3.1.0 + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.0': {} '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.2': {} + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/parser@8.0.0': dependencies: '@babel/types': 8.0.0 + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.0': dependencies: '@babel/helper-string-parser': 8.0.0 @@ -3346,6 +5138,10 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/types@11.1.2': {} + '@colors/colors@1.5.0': optional: true @@ -3472,22 +5268,40 @@ snapshots: conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.3.0 + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 optional: true + '@emotion/hash@0.9.2': {} + '@es-joy/jsdoccomment@0.84.0': dependencies: '@types/estree': 1.0.8 @@ -3498,81 +5312,159 @@ snapshots: '@es-joy/resolve.exports@1.2.0': {} + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.27.4': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.27.4': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.4': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.4': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.4': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.4': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.4': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.4': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.4': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.4': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.4': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.4': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.4': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.4': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.4': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.4': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.4': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.4': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.4': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.4': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.4': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.4': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.4': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.4': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.4': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.4': optional: true @@ -3658,13 +5550,30 @@ snapshots: '@eslint/plugin-kit@0.4.1': dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.1.1 + levn: 0.4.1 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 - '@eslint/plugin-kit@0.6.1': + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@eslint/core': 1.1.1 - levn: 0.4.1 + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/utils@0.2.11': {} '@gerrit0/mini-shiki@3.23.0': dependencies: @@ -3691,6 +5600,14 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.3': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@istanbuljs/schema@0.1.3': {} '@jridgewell/gen-mapping@0.3.13': @@ -3698,6 +5615,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -3707,6 +5629,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mermaid-js/parser@1.1.1': + dependencies: + '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.12(hono@4.12.9) @@ -3729,6 +5655,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -3796,6 +5729,8 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.137.0': {} '@pkgr/core@0.2.9': {} @@ -3816,42 +5751,388 @@ snapshots: dependencies: quansync: 1.0.0 + '@radix-ui/number@1.1.2': {} + + '@radix-ui/primitive@1.1.4': {} + + '@radix-ui/react-arrow@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collection@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dismissable-layer@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-popper@1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-roving-focus@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-select@2.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-tabs@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-tooltip@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-previous@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-visually-hidden@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/rect@1.1.2': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + '@rolldown/binding-android-arm64@1.1.2': optional: true + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + '@rolldown/binding-darwin-arm64@1.1.2': optional: true + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + '@rolldown/binding-darwin-x64@1.1.2': optional: true + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + '@rolldown/binding-freebsd-x64@1.1.2': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.1.2': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.1.2': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + '@rolldown/binding-linux-arm64-musl@1.1.2': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.1.2': optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.1.2': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + '@rolldown/binding-linux-x64-gnu@1.1.2': optional: true + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + '@rolldown/binding-linux-x64-musl@1.1.2': optional: true + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + '@rolldown/binding-openharmony-arm64@1.1.2': optional: true + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + '@rolldown/binding-wasm32-wasi@1.1.2': dependencies: '@emnapi/core': 1.11.1 @@ -3859,14 +6140,97 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + '@rolldown/binding-win32-arm64-msvc@1.1.2': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + '@rolldown/binding-win32-x64-msvc@1.1.2': optional: true + '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + '@sec-ant/readable-stream@0.4.1': {} '@semantic-release/changelog@6.0.3(semantic-release@25.0.3(typescript@6.0.3))': @@ -4034,6 +6398,144 @@ snapshots: tslib: 2.8.1 optional: true + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -4042,6 +6544,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -4064,6 +6570,17 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@3.0.3': {} '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.3)': @@ -4152,10 +6669,112 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.58.0': + '@typescript-eslint/visitor-keys@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@vanilla-extract/babel-plugin-debug-ids@1.2.2': + dependencies: + '@babel/core': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@vanilla-extract/compiler@0.7.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)': + dependencies: + '@vanilla-extract/css': 1.20.1 + '@vanilla-extract/integration': 8.0.10 + vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + vite-node: 6.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - babel-plugin-macros + - esbuild + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + '@vanilla-extract/css@1.20.1': + dependencies: + '@emotion/hash': 0.9.2 + '@vanilla-extract/private': 1.0.9 + css-what: 6.2.2 + csstype: 3.2.3 + dedent: 1.7.2 + deep-object-diff: 1.1.9 + deepmerge: 4.3.1 + lru-cache: 10.4.3 + media-query-parser: 2.0.2 + modern-ahocorasick: 1.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - babel-plugin-macros + + '@vanilla-extract/integration@8.0.10': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@vanilla-extract/babel-plugin-debug-ids': 1.2.2 + '@vanilla-extract/css': 1.20.1 + dedent: 1.7.2 + esbuild: 0.27.4 + eval: 0.1.8 + find-up: 5.0.0 + javascript-stringify: 2.1.0 + mlly: 1.8.2 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + '@vanilla-extract/private@1.0.9': {} + + '@vanilla-extract/vite-plugin@5.2.2(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': dependencies: - '@typescript-eslint/types': 8.58.0 - eslint-visitor-keys: 5.0.1 + '@vanilla-extract/compiler': 0.7.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + '@vanilla-extract/integration': 8.0.10 + vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - babel-plugin-macros + - esbuild + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + '@vitejs/plugin-react@5.2.0(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color accepts@2.0.0: dependencies: @@ -4226,6 +6845,10 @@ snapshots: argv-formatter@1.0.0: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + array-ify@1.0.0: {} ast-kit@3.0.0: @@ -4238,6 +6861,8 @@ snapshots: balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.38: {} + before-after-hook@4.0.0: {} birpc@4.0.0: {} @@ -4275,6 +6900,14 @@ snapshots: dependencies: fill-range: 7.1.1 + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.38 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.376 + node-releases: 2.0.48 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + builtin-modules@3.3.0: {} bytes@3.1.2: {} @@ -4307,6 +6940,8 @@ snapshots: callsites@3.1.0: {} + caniuse-lite@1.0.30001799: {} + ccount@2.0.1: {} chalk@2.4.2: @@ -4390,6 +7025,10 @@ snapshots: commander@14.0.3: {} + commander@7.2.0: {} + + commander@8.3.0: {} + comment-parser@1.4.5: {} compare-func@2.0.0: @@ -4399,6 +7038,8 @@ snapshots: concat-map@0.0.1: {} + confbox@0.1.8: {} + config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -4446,6 +7087,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cosmiconfig-typescript-loader@6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3): dependencies: '@types/node': 25.5.0 @@ -4472,6 +7121,196 @@ snapshots: dependencies: type-fest: 1.4.0 + css-what@6.2.2: {} + + csstype@3.2.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + + cytoscape@3.34.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.17.23 + + dayjs@1.11.21: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -4480,16 +7319,30 @@ snapshots: dependencies: character-entities: 2.0.2 + dedent@1.7.2: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} + deep-object-diff@1.1.9: {} + + deepmerge@4.3.1: {} + defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + depd@2.0.0: {} dequal@2.0.3: {} + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -4498,6 +7351,10 @@ snapshots: dependencies: path-type: 4.0.0 + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 @@ -4516,6 +7373,8 @@ snapshots: ee-first@1.1.1: {} + electron-to-chromium@1.5.376: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -4545,10 +7404,43 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 + es-toolkit@1.47.1: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -4719,6 +7611,11 @@ snapshots: etag@1.8.1: {} + eval@0.1.8: + dependencies: + '@types/node': 25.5.0 + require-like: 0.1.2 + eventemitter3@5.0.4: {} eventsource-parser@3.0.6: {} @@ -4905,6 +7802,8 @@ snapshots: functional-red-black-tree@1.0.1: {} + gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} get-east-asian-width@1.5.0: {} @@ -4922,6 +7821,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -4989,6 +7890,8 @@ snapshots: graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} + handlebars@4.7.8: dependencies: minimist: 1.2.8 @@ -5058,6 +7961,10 @@ snapshots: husky@9.1.7: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -5096,6 +8003,10 @@ snapshots: ini@4.1.1: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + into-stream@7.0.0: dependencies: from2: 2.3.0 @@ -5162,6 +8073,8 @@ snapshots: java-properties@1.0.2: {} + javascript-stringify@2.1.0: {} + jiti@2.6.1: {} jose@6.2.2: {} @@ -5192,6 +8105,8 @@ snapshots: json-with-bigint@3.5.8: {} + json5@2.2.3: {} + jsonfile@6.2.0: dependencies: universalify: 2.0.1 @@ -5200,15 +8115,74 @@ snapshots: jsx-ast-utils-x@0.1.0: {} + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lines-and-columns@1.2.4: {} linkify-it@5.0.0: @@ -5291,6 +8265,10 @@ snapshots: lru-cache@11.2.7: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + lunr@2.3.9: {} make-asynchronous@1.1.0: @@ -5327,6 +8305,8 @@ snapshots: marked@15.0.12: {} + marked@16.4.2: {} + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -5444,6 +8424,10 @@ snapshots: mdurl@2.0.0: {} + media-query-parser@2.0.2: + dependencies: + '@babel/runtime': 7.29.7 + media-typer@1.1.0: {} meow@13.2.0: {} @@ -5452,6 +8436,30 @@ snapshots: merge-stream@2.0.0: {} + mermaid@11.15.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.3 + '@mermaid-js/parser': 1.1.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.4.11 + es-toolkit: 1.47.1 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.0 + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -5685,6 +8693,15 @@ snapshots: minipass@7.1.3: {} + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + modern-ahocorasick@1.1.0: {} + ms@2.1.3: {} mz@2.7.0: @@ -5693,6 +8710,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.13: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} @@ -5708,6 +8727,8 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 + node-releases@2.0.48: {} + normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 @@ -5812,6 +8833,8 @@ snapshots: p-try@1.0.0: {} + package-manager-detector@1.6.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5852,6 +8875,8 @@ snapshots: parseurl@1.3.3: {} + path-data-parser@0.1.0: {} + path-exists@3.0.0: {} path-exists@4.0.0: {} @@ -5886,6 +8911,25 @@ snapshots: find-up: 2.1.0 load-json-file: 4.0.0 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.13 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -5933,6 +8977,42 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-refresh@0.18.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react@19.2.7: {} + read-package-up@11.0.0: dependencies: find-up-simple: 1.0.1 @@ -5988,6 +9068,8 @@ snapshots: require-from-string@2.0.2: {} + require-like@0.1.2: {} + reserved-identifiers@1.2.0: {} resolve-from@4.0.0: {} @@ -6003,6 +9085,8 @@ snapshots: rfdc@1.4.1: {} + robust-predicates@3.0.3: {} + rolldown-plugin-dts@0.26.0(rolldown@1.1.2)(typescript@6.0.3): dependencies: '@babel/generator': 8.0.0 @@ -6019,6 +9103,27 @@ snapshots: transitivePeerDependencies: - oxc-resolver + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + rolldown@1.1.2: dependencies: '@oxc-project/types': 0.137.0 @@ -6040,6 +9145,44 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.2 '@rolldown/binding-win32-x64-msvc': 1.1.2 + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + router@2.2.0: dependencies: debug: 4.4.3 @@ -6050,10 +9193,14 @@ snapshots: transitivePeerDependencies: - supports-color + rw@1.3.3: {} + safe-buffer@5.1.2: {} safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + scslre@0.3.0: dependencies: '@eslint-community/regexpp': 4.12.2 @@ -6096,6 +9243,8 @@ snapshots: semver-regex@4.0.5: {} + semver@6.3.1: {} + semver@7.7.4: {} semver@7.8.4: {} @@ -6185,6 +9334,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + source-map-js@1.2.1: {} + source-map@0.6.1: {} spawn-error-forwarder@1.0.0: {} @@ -6262,6 +9413,8 @@ snapshots: strip-json-comments@3.1.1: {} + stylis@4.4.0: {} + super-regex@1.1.0: dependencies: function-timeout: 1.0.2 @@ -6352,6 +9505,8 @@ snapshots: dependencies: typescript: 6.0.3 + ts-dedent@2.3.0: {} + tsdown@0.22.3(tsx@4.21.0)(typescript@6.0.3): dependencies: ansis: 4.3.1 @@ -6378,8 +9533,7 @@ snapshots: - oxc-resolver - vue-tsc - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsx@4.21.0: dependencies: @@ -6451,6 +9605,8 @@ snapshots: uc.micro@2.1.0: {} + ufo@1.6.4: {} + uglify-js@3.19.3: optional: true @@ -6502,14 +9658,37 @@ snapshots: unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 url-join@5.0.0: {} + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + util-deprecate@1.0.2: {} + uuid@14.0.0: {} + v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -6523,6 +9702,58 @@ snapshots: vary@1.1.2: {} + vite-node@6.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + cac: 7.0.0 + es-module-lexer: 2.1.0 + obug: 2.1.3 + pathe: 2.0.3 + vite: 8.0.16(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.5.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + tsx: 4.21.0 + yaml: 2.8.3 + + vite@8.0.16(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.5.0 + esbuild: 0.27.4 + fsevents: 2.3.3 + jiti: 2.6.1 + tsx: 4.21.0 + yaml: 2.8.3 + web-worker@1.5.0: {} which@2.0.2: @@ -6551,6 +9782,8 @@ snapshots: y18n@5.0.8: {} + yallist@3.1.1: {} + yaml@2.8.3: {} yargs-parser@20.2.9: {} From c884b81ccdd1a9d1e1613fb7018f837c9fff6d8f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 16:57:42 +0100 Subject: [PATCH 07/28] feat(web): add vanilla-extract theme and global styles Lean, readable design system using vanilla-extract createTheme: a muted palette, monospace badges for IDs/types, and a tabbed layout shell. The theme class is applied at the root wrapper. --- packages/web/src/main.tsx | 15 ++ packages/web/src/styles.css.ts | 276 +++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 packages/web/src/main.tsx create mode 100644 packages/web/src/styles.css.ts diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx new file mode 100644 index 0000000..a82517d --- /dev/null +++ b/packages/web/src/main.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import { themeClass } from "./styles.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("#root element not found"); + +createRoot(root).render( + +
+ +
+
, +); diff --git a/packages/web/src/styles.css.ts b/packages/web/src/styles.css.ts new file mode 100644 index 0000000..6a79136 --- /dev/null +++ b/packages/web/src/styles.css.ts @@ -0,0 +1,276 @@ +import { globalStyle, style, createTheme } from "@vanilla-extract/css"; + +export const [themeClass, theme] = createTheme({ + color: { + bg: "#fafafa", + surface: "#ffffff", + border: "#e2e2e8", + text: "#1a1a22", + textMuted: "#6b6b78", + accent: "#3b5bdb", + accentBg: "#eef1fd", + error: "#c92a2a", + errorBg: "#fdf0f0", + }, + font: { + body: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif', + mono: 'ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace', + }, + space: { + xs: "4px", + sm: "8px", + md: "12px", + lg: "16px", + xl: "24px", + }, +}); + +globalStyle("*", { + boxSizing: "border-box", +}); + +globalStyle("html, body", { + margin: 0, + padding: 0, +}); + +globalStyle("body", { + backgroundColor: theme.color.bg, + color: theme.color.text, + fontFamily: theme.font.body, + fontSize: "14px", + lineHeight: "1.5", +}); + +export const appShell = style({ + maxWidth: "1100px", + margin: "0 auto", + padding: theme.space.xl, +}); + +export const header = style({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + flexWrap: "wrap", + gap: theme.space.md, + paddingBottom: theme.space.lg, + borderBottom: `1px solid ${theme.color.border}`, + marginBottom: theme.space.xl, +}); + +export const title = style({ + margin: 0, + fontSize: "20px", + fontWeight: 600, +}); + +export const controls = style({ + display: "flex", + alignItems: "center", + gap: theme.space.sm, + flexWrap: "wrap", +}); + +export const button = style({ + fontFamily: theme.font.body, + fontSize: "13px", + padding: `${theme.space.xs} ${theme.space.md}`, + border: `1px solid ${theme.color.border}`, + borderRadius: "6px", + backgroundColor: theme.color.surface, + color: theme.color.text, + cursor: "pointer", + selectors: { + "&:hover": { + borderColor: theme.color.accent, + }, + }, +}); + +export const primaryButton = style({ + backgroundColor: theme.color.accent, + color: "#ffffff", + borderColor: theme.color.accent, +}); + +export const dropZone = style({ + border: `2px dashed ${theme.color.border}`, + borderRadius: "8px", + padding: theme.space.xl, + textAlign: "center" as const, + color: theme.color.textMuted, + selectors: { + "&[data-active='true']": { + borderColor: theme.color.accent, + backgroundColor: theme.color.accentBg, + }, + }, +}); + +export const errorBox = style({ + backgroundColor: theme.color.errorBg, + color: theme.color.error, + border: `1px solid ${theme.color.error}`, + borderRadius: "6px", + padding: theme.space.md, + margin: `${theme.space.md} 0`, + fontFamily: theme.font.mono, + fontSize: "12px", + whiteSpace: "pre-wrap" as const, +}); + +export const table = style({ + width: "100%", + borderCollapse: "collapse", + fontSize: "13px", +}); + +globalStyle(`.${table} th`, { + textAlign: "left", + padding: theme.space.sm, + borderBottom: `1px solid ${theme.color.border}`, + fontWeight: 600, + color: theme.color.textMuted, + fontSize: "12px", + textTransform: "uppercase", + letterSpacing: "0.04em", +}); + +globalStyle(`.${table} td`, { + padding: theme.space.sm, + borderBottom: `1px solid ${theme.color.border}`, + verticalAlign: "top", +}); + +export const monospace = style({ + fontFamily: theme.font.mono, + fontSize: "12px", +}); + +export const badge = style({ + display: "inline-block", + padding: `1px ${theme.space.xs}`, + borderRadius: "4px", + fontSize: "11px", + fontFamily: theme.font.mono, + backgroundColor: theme.color.accentBg, + color: theme.color.accent, +}); + +export const filterRow = style({ + display: "flex", + gap: theme.space.sm, + alignItems: "center", + marginBottom: theme.space.md, + flexWrap: "wrap", +}); + +export const select = style({ + fontFamily: theme.font.body, + fontSize: "13px", + padding: `${theme.space.xs} ${theme.space.sm}`, + border: `1px solid ${theme.color.border}`, + borderRadius: "6px", + backgroundColor: theme.color.surface, +}); + +export const input = style({ + fontFamily: theme.font.body, + fontSize: "13px", + padding: `${theme.space.xs} ${theme.space.sm}`, + border: `1px solid ${theme.color.border}`, + borderRadius: "6px", + backgroundColor: theme.color.surface, +}); + +export const statGrid = style({ + display: "grid", + gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", + gap: theme.space.md, +}); + +export const statCard = style({ + backgroundColor: theme.color.surface, + border: `1px solid ${theme.color.border}`, + borderRadius: "8px", + padding: theme.space.md, +}); + +export const statLabel = style({ + fontSize: "12px", + color: theme.color.textMuted, + textTransform: "uppercase", + letterSpacing: "0.04em", + marginBottom: theme.space.xs, +}); + +export const statValue = style({ + fontSize: "22px", + fontWeight: 600, +}); + +export const statSub = style({ + fontSize: "12px", + color: theme.color.textMuted, + marginTop: theme.space.xs, +}); + +export const graphContainer = style({ + backgroundColor: theme.color.surface, + border: `1px solid ${theme.color.border}`, + borderRadius: "8px", + padding: theme.space.lg, + overflow: "auto", + minHeight: "300px", +}); + +export const traceTree = style({ + fontFamily: theme.font.mono, + fontSize: "13px", + lineHeight: "1.7", +}); + +export const muted = style({ + color: theme.color.textMuted, +}); + +export const tabsRoot = style({ + display: "flex", + flexDirection: "column", + gap: theme.space.lg, +}); + +export const tabsList = style({ + display: "flex", + gap: "0", + borderBottom: `1px solid ${theme.color.border}`, +}); + +export const tabsTrigger = style({ + fontFamily: theme.font.body, + fontSize: "13px", + padding: `${theme.space.sm} ${theme.space.lg}`, + border: "none", + background: "none", + cursor: "pointer", + color: theme.color.textMuted, + borderBottom: "2px solid transparent", + marginBottom: "-1px", + selectors: { + '&[data-state="active"]': { + color: theme.color.accent, + borderBottomColor: theme.color.accent, + fontWeight: 600, + }, + "&:hover": { + color: theme.color.text, + }, + }, +}); + +export const sectionTitle = style({ + fontSize: "15px", + fontWeight: 600, + margin: `${theme.space.lg} 0 ${theme.space.sm}`, +}); From dc9c1620b01e745330ce05609f4e47547b38f48c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 17:03:08 +0100 Subject: [PATCH 08/28] feat(web): load and validate SysProM documents Support three load paths: single .SysProM.json (parsed with SysProMDocument.parse), single .SysProM.md (markdownSingleToJson), and multiple .md files (parseMultiDoc). Validation issues from validateOp are surfaced in an error panel. Includes drag-and-drop, file picker, 'Load sample' (fetches the repo's self-describing document from public/), and JSON export. --- packages/web/public/sample.SysProM.json | 5326 +++++++++++++++++++++++ packages/web/src/App.tsx | 237 + packages/web/src/load.ts | 52 + 3 files changed, 5615 insertions(+) create mode 100644 packages/web/public/sample.SysProM.json create mode 100644 packages/web/src/App.tsx create mode 100644 packages/web/src/load.ts diff --git a/packages/web/public/sample.SysProM.json b/packages/web/public/sample.SysProM.json new file mode 100644 index 0000000..c1e53be --- /dev/null +++ b/packages/web/public/sample.SysProM.json @@ -0,0 +1,5326 @@ +{ + "$schema": "./schema.json", + "external_references": [ + { + "description": "Original design conversation that produced the SysProM model.", + "identifier": "https://chatgpt.com/c/69bd2439-ad5c-838d-9af1-2e1294a0d331", + "node_id": "I1", + "role": "source" + }, + { + "description": "Spec Kit's workflow limitations motivated the addition of process modelling.", + "identifier": "https://github.com/github/spec-kit", + "node_id": "D4", + "role": "prior_art" + }, + { + "description": "Ralplan's consensus planning protocol demonstrated the need for roles, stages, and gates as first-class nodes.", + "identifier": "https://github.com/yeachan-heo/oh-my-claudecode/blob/main/skills/ralplan/SKILL.md", + "node_id": "D4", + "role": "prior_art" + }, + { + "description": "GSD's multi-runtime orchestration demonstrated the need for modes and runtime-adaptive realisations.", + "identifier": "https://github.com/gsd-build/get-shit-done", + "node_id": "D4", + "role": "prior_art" + }, + { + "description": "RFC 2119 defines the interpretation of MUST, SHOULD, and MAY used throughout the specification.", + "identifier": "https://datatracker.ietf.org/doc/html/rfc2119", + "node_id": "CN8", + "role": "standard" + } + ], + "metadata": { + "doc_type": "sysprom", + "scope": "system", + "status": "active", + "title": "SysProM — System Provenance Model", + "version": 1 + }, + "nodes": [ + { + "description": "Enable any system — regardless of domain — to record where every part came from, what decisions shaped it, and how it reached its current form.", + "id": "INT1", + "name": "System Provenance", + "type": "intent" + }, + { + "description": [ + "A system is understood through distinct layers of abstraction: intent, concept, capability, structure, and realisation.", + "Each layer is valid independently of the layers below it." + ], + "id": "CON1", + "name": "Layered Abstraction", + "type": "concept" + }, + { + "description": [ + "Systems evolve through explicit decisions.", + "Decisions select between alternatives, affect nodes, and must preserve invariants.", + "Decisions are never deleted — they are superseded." + ], + "id": "CON2", + "name": "Decision-Driven Evolution", + "type": "concept" + }, + { + "description": [ + "The history of a system is additive. Nodes are deprecated or retired, not erased.", + "Changes record what happened; they do not overwrite prior state." + ], + "id": "CON3", + "name": "Append-Only History", + "type": "concept" + }, + { + "description": [ + "Any node may be treated as a subsystem with its own internal structure.", + "The same model applies at every level of nesting." + ], + "id": "CON4", + "name": "Recursive Composition", + "type": "concept" + }, + { + "description": [ + "Workflows, roles, stages, gates, and artefacts are modelled with the same rigour as domain concepts.", + "Process is not a second-class concern." + ], + "id": "CON5", + "name": "Process as Structure", + "type": "concept" + }, + { + "description": [ + "The model defines semantics, not storage.", + "It may be represented in Markdown, JSON, a database, or any other structured format." + ], + "id": "CON6", + "name": "Format Agnosticism", + "type": "concept" + }, + { + "description": [ + "Nodes may relate to resources outside the graph.", + "Content may be internalised for portability or referenced for traceability, or both." + ], + "id": "CON7", + "name": "External Resource Handling", + "type": "concept" + }, + { + "description": "A system conforms to SysProM if it meets a defined set of minimum requirements, ensuring interoperability and consistency.", + "id": "CON8", + "name": "Conformance", + "type": "concept" + }, + { + "description": "A recommended modelling profile for product repositories that uses SysProM to describe the system's specification, design, and implementation with a consistent trace chain across layers.", + "id": "CON9", + "name": "System Provenance Profile", + "type": "concept" + }, + { + "description": "Trace any node from intent through concept, capability, and structure to realisation.", + "id": "CAP1", + "name": "Cross-Layer Traceability", + "type": "capability" + }, + { + "description": "Record choices with alternatives considered, rationale, affected nodes, and preserved invariants.", + "id": "CAP2", + "name": "Decision Recording", + "type": "capability" + }, + { + "description": [ + "Define rules that must hold across all valid system states.", + "Decisions and changes must explicitly identify which invariants they preserve." + ], + "id": "CAP3", + "name": "Invariant Enforcement", + "type": "capability" + }, + { + "description": "Record every addition, modification, removal, and transition with scope, decision, and lifecycle state.", + "id": "CAP4", + "name": "Change Tracking", + "type": "capability" + }, + { + "description": "Represent any node as a subsystem with its own nodes, relationships, decisions, and changes, using the same conventions at every depth.", + "id": "CAP5", + "name": "Recursive Modelling", + "type": "capability" + }, + { + "description": "Define protocols, stages, roles, gates, modes, artefacts, and artefact flows as first-class graph nodes.", + "id": "CAP6", + "name": "Process Modelling", + "type": "capability" + }, + { + "description": "Encode the model in a single file, a multi-document folder, nested folders, or a non-file format, with the same underlying semantics.", + "id": "CAP7", + "name": "Flexible Representation", + "type": "capability" + }, + { + "description": "Reference or internalise resources outside the graph with typed roles (input, output, context, evidence, source, standard, prior_art).", + "id": "CAP8", + "name": "External Resource Referencing", + "type": "capability" + }, + { + "description": "The model MUST support multiple concurrent evolution paths (e.g. experimental branches).", + "id": "CAP9", + "name": "Branching", + "type": "capability" + }, + { + "description": "Branches MAY be merged.", + "id": "CAP10", + "name": "Merging", + "type": "capability" + }, + { + "description": "Previously deprecated or retired nodes MAY be reintroduced.", + "id": "CAP11", + "name": "Revival", + "type": "capability" + }, + { + "description": "Guide authors to model product systems through intent, concept, capability, element, realisation, artefact, decision, and change nodes with practical traceability and implementation provenance.", + "id": "CAP12", + "name": "Product Repository Modelling Guidance", + "type": "capability" + }, + { + "description": "Concept nodes MUST NOT depend on realisation nodes. The conceptual layer must be valid even if no realisation exists.", + "id": "INV1", + "name": "Concept Independence", + "type": "invariant" + }, + { + "description": "Every change MUST reference at least one decision. No change occurs without an explicit choice.", + "id": "INV2", + "name": "Decision-Change Linkage", + "type": "invariant" + }, + { + "description": [ + "A decision that affects domain nodes (intent, concept, capability, element, invariant) MUST identify the invariants it preserves via must_preserve relationships.", + "A decision that affects only non-domain nodes (realisation, policy, protocol, stage, role, gate, mode, artefact) SHOULD identify preserved invariants but is not required to.", + "Domain nodes define what the system IS. Non-domain nodes define how it works or how it is implemented." + ], + "id": "INV3", + "name": "Invariant Preservation", + "type": "invariant" + }, + { + "description": [ + "The same model MUST apply at all levels of recursion.", + "A subsystem uses the same node types, relationship types, and conventions as the root system." + ], + "id": "INV4", + "name": "Recursive Consistency", + "type": "invariant" + }, + { + "description": "Superseded decisions and deprecated nodes MUST remain part of the system. History is never deleted.", + "id": "INV5", + "name": "Append-Only History", + "type": "invariant" + }, + { + "description": "Every node MUST have a unique identifier and a defined type. No anonymous entities.", + "id": "INV6", + "name": "Node Identity", + "type": "invariant" + }, + { + "description": "Every relationship MUST be directional, typed, and reference valid nodes.", + "id": "INV7", + "name": "Relationship Validity", + "type": "invariant" + }, + { + "description": "Every gate MUST reference the invariant or policy it enforces.", + "id": "INV8", + "name": "Gate Justification", + "type": "invariant" + }, + { + "description": "Refinement flows downward through abstraction layers. Nodes MUST NOT refine nodes in a lower layer.", + "id": "INV9", + "name": "Layer Direction", + "type": "invariant" + }, + { + "description": "Realisation nodes MUST implement element nodes. A realisation without a parent element is invalid.", + "id": "INV10", + "name": "Realisation Implements Element", + "type": "invariant" + }, + { + "description": "Stages within a protocol MUST have defined ordering via precedes or must_follow relationships.", + "id": "INV11", + "name": "Stage Ordering", + "type": "invariant" + }, + { + "description": "Every decision MUST reference the nodes it affects via an affects relationship.", + "id": "INV12", + "name": "Decision Affects Reference", + "type": "invariant" + }, + { + "description": "Every decision MUST define the selected option and list alternatives considered.", + "id": "INV13", + "name": "Decision Selection", + "type": "invariant" + }, + { + "description": "Every change MUST define its scope (the nodes it affects).", + "id": "INV14", + "name": "Change Scope", + "type": "invariant" + }, + { + "description": "Every change MUST define its operations (add, update, remove, link).", + "id": "INV15", + "name": "Change Operations", + "type": "invariant" + }, + { + "description": "Every change MUST define its lifecycle state.", + "id": "INV16", + "name": "Change Lifecycle State", + "type": "invariant" + }, + { + "description": "Every node MUST be addressable — reachable by its identifier from any point in the graph.", + "id": "INV17", + "name": "Node Addressability", + "type": "invariant" + }, + { + "description": "Extensions (additional node types, relationship types, lifecycle states) MUST NOT violate core constraints.", + "id": "INV18", + "name": "Extension Constraint Preservation", + "type": "invariant" + }, + { + "description": "Every external reference MUST include a role describing the relationship to the node.", + "id": "INV19", + "name": "External Reference Role Required", + "type": "invariant" + }, + { + "description": [ + "External references are always from a node in the graph to a resource outside it.", + "External resources do not point back into the graph." + ], + "id": "INV20", + "name": "External Reference Directionality", + "type": "invariant" + }, + { + "description": "A conformant system MUST define nodes with types.", + "id": "INV28", + "name": "Conformance — Typed Nodes", + "type": "invariant" + }, + { + "description": "A conformant system MUST define relationships between nodes.", + "id": "INV29", + "name": "Conformance — Relationships", + "type": "invariant" + }, + { + "description": "A conformant system MUST define lifecycle states for decisions and changes.", + "id": "INV30", + "name": "Conformance — Lifecycle States", + "type": "invariant" + }, + { + "description": "A conformant system MUST define at least one invariant.", + "id": "INV31", + "name": "Conformance — At Least One Invariant", + "type": "invariant" + }, + { + "description": "A conformant system MUST support traceability across abstraction layers.", + "id": "INV32", + "name": "Conformance — Traceability", + "type": "invariant" + }, + { + "description": [ + "Fields that carry human-readable content (description, context, rationale, internalised) MAY be either a string or an array of strings.", + "Both forms are semantically equivalent.", + "Implementations MUST accept either form wherever a text field appears." + ], + "id": "INV21", + "name": "Text Field Duality", + "type": "invariant" + }, + { + "description": [ + "Node types, node statuses, relationship types, and external reference roles MUST be members of their defined enums.", + "Unknown values MUST be rejected at validation time.", + "Extensions MUST add new values to the enum rather than bypassing it." + ], + "id": "INV22", + "name": "Strict Type Enums", + "type": "invariant" + }, + { + "description": "Relationships to or from retired nodes are only permitted for semantically appropriate types (supersedes, derived_from, references). Operational relationship types (depends_on, implements, constrained_by, must_follow, governed_by, affects, etc.) are refused. Enforced in addRelationship and checked by validate.", + "id": "INV23", + "name": "Retired Node Relationship Guard", + "type": "invariant" + }, + { + "description": "A document must not contain two relationships with identical from, type, and to. Enforced in addRelationship (refuse) and validate (report).", + "id": "INV24", + "name": "No Duplicate Relationships", + "type": "invariant" + }, + { + "description": "Each relationship type has a set of valid source and target node types. The endpoint matrix must remain semantically meaningful while allowing practical system-provenance patterns such as role-to-concept or role-to-protocol performs, capability-to-artefact produces, invariant-to-concept applies_to, protocol decomposition via part_of, and abstract workflow control via orchestrates. Enforced in addRelationship (refuse) and validate (report).", + "id": "INV25", + "name": "Relationship Endpoint Type Validity", + "type": "invariant" + }, + { + "description": "Setting a node to status: retired via updateNode must report all active nodes that hold operational relationships (depends_on, implements, constrained_by, must_follow, governed_by, affects) to/from it. The caller sees the impact before the change is applied.", + "id": "INV26", + "name": "Retirement Impact Awareness", + "type": "invariant" + }, + { + "description": "When both JSON and Markdown representations of a SysProM document exist, mutations via the CLI must automatically keep them in sync. Users should not need to manually run json2md or md2json after every change.", + "id": "INV27", + "name": "Auto-sync JSON and Markdown representations", + "type": "invariant" + }, + { + "description": "Intent, decisions, and realisations are distinct concerns and should be recorded separately.", + "id": "PRIN1", + "name": "Separate What From Why From How", + "type": "principle" + }, + { + "description": "Documents describe state. Decisions explain why state changed.", + "id": "PRIN2", + "name": "Decisions Are More Important Than Documents", + "type": "principle" + }, + { + "description": "Every node gets an ID. No anonymous ideas.", + "id": "PRIN3", + "name": "Everything Has Identity", + "type": "principle" + }, + { + "description": "Timelines are one projection of the graph. The graph is the source of truth.", + "id": "PRIN4", + "name": "Think Graph, Not Timeline", + "type": "principle" + }, + { + "description": "What exists now and how it got there are recorded separately.", + "id": "PRIN5", + "name": "Separate State From History", + "type": "principle" + }, + { + "description": "When removing a node from active use, mark it as deprecated rather than deleting it.", + "id": "POL1", + "name": "Prefer Deprecation Over Deletion", + "type": "policy" + }, + { + "description": "A decision SHOULD list the alternatives considered, not only the selected option.", + "id": "POL2", + "name": "Decisions Must Record Alternatives", + "type": "policy" + }, + { + "description": "A change SHOULD explicitly state which nodes it affects and which it does not.", + "id": "POL3", + "name": "Changes Must Define Scope", + "type": "policy" + }, + { + "description": "Capability nodes SHOULD refine concept nodes.", + "id": "POL4", + "name": "Capabilities Should Refine Concepts", + "type": "policy" + }, + { + "description": "Element nodes SHOULD realise capability nodes.", + "id": "POL5", + "name": "Elements Should Realise Capabilities", + "type": "policy" + }, + { + "description": "When portability matters, the relevant content from an external resource SHOULD be captured directly within a node so the document set is self-contained.", + "id": "POL6", + "name": "Prefer Internalisation for Portability", + "type": "policy" + }, + { + "description": "Implementations SHOULD ensure integrity of node identities.", + "id": "POL7", + "name": "Security — Node Identity Integrity", + "type": "policy" + }, + { + "description": "Implementations SHOULD ensure consistency of relationships.", + "id": "POL8", + "name": "Security — Relationship Consistency", + "type": "policy" + }, + { + "description": "Implementations SHOULD ensure controlled modification of decisions and changes.", + "id": "POL9", + "name": "Security — Controlled Modification", + "type": "policy" + }, + { + "description": [ + "The root entry point MUST be identifiable as a SysProM document.", + "Valid filenames include SysProM.md, SYSPROM.md, SPM.md, and README.spm.md.", + "Alternatively, front matter containing doc_type: sysprom is sufficient." + ], + "id": "POL10", + "name": "Root Entry Point Identification", + "type": "policy" + }, + { + "description": [ + "A SysProM document set MAY live at any location.", + "Valid locations include: repository root (./SysProM.md), dedicated folder (./SysProM/README.md), docs directory (./docs/SysProM.md), or any other reasonable path." + ], + "id": "POL11", + "name": "Root Entry Point Location", + "type": "policy" + }, + { + "description": [ + "When a system is described across multiple files, a hub document (typically README.md) SHOULD serve as the root node.", + "Separate files per concern: INTENT.md, INVARIANTS.md, STATE.md, DECISIONS.md, CHANGES.md.", + "No document is mandatory." + ], + "id": "POL12", + "name": "Multi-Document Hub", + "type": "policy" + }, + { + "description": [ + "A node represented as a single file SHOULD use the .spm.md extension.", + "Filename SHOULD include node ID and MAY include name: F1-sync.spm.md (preferred), F1.spm.md, or sync.spm.md." + ], + "id": "POL13", + "name": "Single-File Node Extension", + "type": "policy" + }, + { + "description": [ + "A node represented as a folder SHOULD be named using the node ID and name: F1-sync/ (preferred), F1/, or sync/.", + "The folder MUST contain at least a README. md." + ], + "id": "POL14", + "name": "Folder Node Naming", + "type": "policy" + }, + { + "description": [ + "Node folders MAY be grouped under intermediate directories by type (e. g. features/, components/).", + "Grouping folders are organisational and do not represent nodes." + ], + "id": "POL15", + "name": "Grouping Folders", + "type": "policy" + }, + { + "description": [ + "Parent-child relationships between nodes are implicit from the folder hierarchy.", + "Explicit parent references in frontmatter are not required." + ], + "id": "POL16", + "name": "Parent Linking Implicit", + "type": "policy" + }, + { + "description": [ + "Relationships MAY be expressed as labelled lists, arrow chains, tables, nested lists, or mermaid diagrams.", + "Formats may be mixed. The choice is a presentation concern." + ], + "id": "POL17", + "name": "Relationship Notation Flexibility", + "type": "policy" + }, + { + "description": [ + "Front matter defines document-level metadata only (title, doc_type, scope, status, version).", + "Node-level data belongs in the document body." + ], + "id": "POL18", + "name": "Frontmatter Is Metadata Only", + "type": "policy" + }, + { + "description": "Generated README navigation and document roles MUST only link to files that contain nodes. Dead links to absent files MUST NOT be generated.", + "id": "POL19", + "name": "README Links Only to Present Files", + "type": "policy" + }, + { + "description": [ + "A subsystem is rendered as a single .spm.md file when it would produce only one document file type AND the result is 100 lines or fewer.", + "A subsystem is rendered as a multi-document folder when it would produce multiple file types OR the single file would exceed 100 lines.", + "Subsystems of the same node type are automatically grouped into a type-named directory when 2 or more exist." + ], + "id": "POL20", + "name": "Subsystem Representation Heuristic", + "type": "policy" + }, + { + "description": "The set of node types that model what the system is.", + "id": "ELEM1", + "lifecycle": { + "active": true + }, + "name": "Domain Node Family", + "subsystem": { + "metadata": { + "doc_type": "element", + "scope": "element", + "status": "active", + "title": "ELEM1 — Domain Node Family" + }, + "nodes": [ + { + "description": "System purpose or goal. Stable, independent of implementation.", + "id": "CON1-INTENT", + "name": "intent", + "type": "concept" + }, + { + "description": "Abstract model or idea. Defines what the system is, independent of structure and realisation.", + "id": "CON2-CONCEPT", + "name": "concept", + "type": "concept" + }, + { + "description": "Enabled behaviour. Derived from concepts, independent of implementation.", + "id": "CON3-CAPABILITY", + "name": "capability", + "type": "concept" + }, + { + "description": "Structural unit (logical part of system). Defines organisation, may depend on other elements.", + "id": "CON4-ELEMENT", + "name": "element", + "type": "concept" + }, + { + "description": "Concrete implementation of an element. Multiple realisations may coexist (alternative, concurrent, or experimental).", + "id": "CON5-REALISATION", + "name": "realisation", + "type": "concept" + }, + { + "description": "Constraint that must always hold. Independent of decisions and changes. Constrains allowable system states.", + "id": "CON6-INVARIANT", + "name": "invariant", + "type": "concept" + } + ] + }, + "type": "element" + }, + { + "description": "The set of node types that model how work flows through the system.", + "id": "ELEM2", + "lifecycle": { + "active": true + }, + "name": "Process Node Family", + "subsystem": { + "metadata": { + "doc_type": "element", + "scope": "element", + "status": "active", + "title": "ELEM2 — Process Node Family" + }, + "nodes": [ + { + "description": "Normative design value. May be overridden with justification, unlike invariants.", + "id": "CON1-PRINCIPLE", + "name": "principle", + "type": "concept" + }, + { + "description": "Operational routing, gating, or selection rule. Implements principles and invariants.", + "id": "CON2-POLICY", + "name": "policy", + "type": "concept" + }, + { + "description": "Defined sequence of stages performed by roles. Protocols MAY depend on other protocols.", + "id": "CON3-PROTOCOL", + "name": "protocol", + "type": "concept" + }, + { + "description": "A step within a protocol. MUST have defined ordering. MAY produce or consume artefacts.", + "id": "CON4-STAGE", + "name": "stage", + "type": "concept" + }, + { + "description": "A participant that performs stages. MAY be human, automated, or agent-based.", + "id": "CON5-ROLE", + "name": "role", + "type": "concept" + }, + { + "description": [ + "Conditional blocker or redirector. MUST reference the invariant or policy it enforces.", + "MAY block a stage or route to an alternative." + ], + "id": "CON6-GATE", + "name": "gate", + "type": "concept" + }, + { + "description": [ + "Named behavioural configuration that modifies a protocol or stage without redefining the system.", + "MAY be triggered by gates, selected by policies, or chosen explicitly." + ], + "id": "CON7-MODE", + "name": "mode", + "type": "concept" + } + ] + }, + "type": "element" + }, + { + "description": "The set of node types that model what is produced and consumed.", + "id": "ELEM3", + "lifecycle": { + "active": true + }, + "name": "Artefact Node Family", + "subsystem": { + "metadata": { + "doc_type": "element", + "scope": "element", + "status": "active", + "title": "ELEM3 — Artefact Node Family" + }, + "nodes": [ + { + "description": "A document, record, or output produced or consumed during system evolution.", + "id": "CON1-ARTEFACT", + "name": "artefact", + "type": "concept" + }, + { + "description": [ + "A transformation of one artefact into another within a stage.", + "Provides traceability of how documents and records evolve through a process." + ], + "id": "CON2-ARTEFACT_FLOW", + "name": "artefact_flow", + "type": "concept" + } + ] + }, + "type": "element" + }, + { + "description": "The set of node types that model how the system changes over time.", + "id": "ELEM4", + "lifecycle": { + "active": true + }, + "name": "Evolution Node Family", + "subsystem": { + "metadata": { + "doc_type": "element", + "scope": "element", + "status": "active", + "title": "ELEM4 — Evolution Node Family" + }, + "nodes": [ + { + "description": [ + "Selection between alternatives that influences system structure or behaviour.", + "MUST define selected option, list alternatives, identify affected nodes, and identify preserved invariants.", + "MAY supersede prior decisions. Superseded decisions remain in history." + ], + "id": "CON1-DECISION", + "name": "decision", + "type": "concept" + }, + { + "description": [ + "System modification over time.", + "MUST define scope, reference decisions, define operations (add/update/remove/link), and define lifecycle state.", + "MAY include execution plan. MAY overlap, depend on other changes, and be partially completed." + ], + "id": "CON2-CHANGE", + "name": "change", + "type": "concept" + } + ] + }, + "type": "element" + }, + { + "description": "Optional node types for views and snapshots.", + "id": "ELEM5", + "lifecycle": { + "active": true + }, + "name": "Projection Node Family", + "subsystem": { + "metadata": { + "doc_type": "element", + "scope": "element", + "status": "active", + "title": "ELEM5 — Projection Node Family" + }, + "nodes": [ + { + "description": "A named projection of a subset of the graph.", + "id": "CON1-VIEW", + "name": "view", + "type": "concept" + }, + { + "description": "A named point in evolution.", + "id": "CON2-MILESTONE", + "name": "milestone", + "type": "concept" + }, + { + "description": "A frozen snapshot of the system.", + "id": "CON3-VERSION", + "name": "version", + "type": "concept" + } + ] + }, + "type": "element" + }, + { + "description": "The set of typed, directed connections available between nodes.", + "id": "ELEM6", + "lifecycle": { + "active": true + }, + "name": "Relationship Type Registry", + "subsystem": { + "metadata": { + "doc_type": "element", + "scope": "element", + "status": "active", + "title": "ELEM6 — Relationship Type Registry" + }, + "nodes": [ + { + "description": "Abstraction refinement (e.g. capability refines concept).", + "id": "CON1-REFINES", + "name": "refines", + "type": "concept" + }, + { + "description": "Fulfils a capability.", + "id": "CON2-REALISES", + "name": "realises", + "type": "concept" + }, + { + "description": "Fulfils an element.", + "id": "CON3-IMPLEMENTS", + "name": "implements", + "type": "concept" + }, + { + "description": "Dependency between nodes.", + "id": "CON4-DEPENDS_ON", + "name": "depends_on", + "type": "concept" + }, + { + "description": "Governed by an invariant.", + "id": "CON5-CONSTRAINED_BY", + "name": "constrained_by", + "type": "concept" + }, + { + "description": "Impact of a decision or change on a node.", + "id": "CON6-AFFECTS", + "name": "affects", + "type": "concept" + }, + { + "description": "Replacement of one node by another.", + "id": "CON7-SUPERSEDES", + "name": "supersedes", + "type": "concept" + }, + { + "description": "Preservation of an invariant by a decision or change.", + "id": "CON8-MUST_PRESERVE", + "name": "must_preserve", + "type": "concept" + }, + { + "description": "A role performs a stage.", + "id": "CON9-PERFORMS", + "name": "performs", + "type": "concept" + }, + { + "description": "A stage belongs to a protocol.", + "id": "CON10-PART_OF", + "name": "part_of", + "type": "concept" + }, + { + "description": "Ordering between stages.", + "id": "CON11-PRECEDES", + "name": "precedes", + "type": "concept" + }, + { + "description": "Strict ordering constraint between stages.", + "id": "CON12-MUST_FOLLOW", + "name": "must_follow", + "type": "concept" + }, + { + "description": "A gate blocks a stage.", + "id": "CON13-BLOCKS", + "name": "blocks", + "type": "concept" + }, + { + "description": "A gate redirects to a stage.", + "id": "CON14-ROUTES_TO", + "name": "routes_to", + "type": "concept" + }, + { + "description": "An abstract workflow machine directs executable milestones, stages, gates, or artefact flows.", + "id": "CON25-ORCHESTRATES", + "name": "orchestrates", + "type": "concept" + }, + { + "description": "A gate or policy is governed by a principle or invariant.", + "id": "CON15-GOVERNED_BY", + "name": "governed_by", + "type": "concept" + }, + { + "description": "A mode modifies a protocol or stage.", + "id": "CON16-MODIFIES", + "name": "modifies", + "type": "concept" + }, + { + "description": "A mode is triggered by a gate.", + "id": "CON17-TRIGGERED_BY", + "name": "triggered_by", + "type": "concept" + }, + { + "description": "A mode applies to a protocol.", + "id": "CON18-APPLIES_TO", + "name": "applies_to", + "type": "concept" + }, + { + "description": "A stage produces an artefact.", + "id": "CON19-PRODUCES", + "name": "produces", + "type": "concept" + }, + { + "description": "A stage consumes an artefact.", + "id": "CON20-CONSUMES", + "name": "consumes", + "type": "concept" + }, + { + "description": "An artefact flow transforms input to output.", + "id": "CON21-TRANSFORMS_INTO", + "name": "transforms_into", + "type": "concept" + }, + { + "description": "A decision selects a realisation.", + "id": "CON22-SELECTS", + "name": "selects", + "type": "concept" + }, + { + "description": "A gate or mode requires a condition.", + "id": "CON23-REQUIRES", + "name": "requires", + "type": "concept" + }, + { + "description": "A mode disables a capability.", + "id": "CON24-DISABLES", + "name": "disables", + "type": "concept" + } + ] + }, + "type": "element" + }, + { + "description": "The mechanism for relating nodes to resources outside the graph.", + "id": "ELEM7", + "lifecycle": { + "active": true + }, + "name": "External Reference Model", + "subsystem": { + "metadata": { + "doc_type": "element", + "scope": "element", + "status": "active", + "title": "ELEM7 — External Reference Model" + }, + "nodes": [ + { + "description": [ + "Content from an external resource captured directly within a node.", + "The node becomes self-contained and does not depend on the external source." + ], + "id": "CON1-INTERNALISE", + "name": "Internalisation", + "type": "concept" + }, + { + "description": "A declared relationship to a resource outside the graph, identified by a serialisation-specific identifier with a typed role.", + "id": "CON2-REFERENCE", + "name": "External Reference", + "type": "concept" + }, + { + "description": "Resource that informed the creation of this node.", + "id": "CON3-ROLE-INPUT", + "name": "input", + "type": "concept" + }, + { + "description": "Resource produced as a result of this node.", + "id": "CON4-ROLE-OUTPUT", + "name": "output", + "type": "concept" + }, + { + "description": "Background material relevant to understanding this node.", + "id": "CON5-ROLE-CONTEXT", + "name": "context", + "type": "concept" + }, + { + "description": "Material that supports or justifies this node.", + "id": "CON6-ROLE-EVIDENCE", + "name": "evidence", + "type": "concept" + }, + { + "description": "Original material from which this node was derived.", + "id": "CON7-ROLE-SOURCE", + "name": "source", + "type": "concept" + }, + { + "description": "External standard or specification that this node conforms to or enforces.", + "id": "CON8-ROLE-STANDARD", + "name": "standard", + "type": "concept" + }, + { + "description": "Existing work that this node relates to or was influenced by.", + "id": "CON9-ROLE-PRIOR_ART", + "name": "prior_art", + "type": "concept" + } + ] + }, + "type": "element" + }, + { + "description": "Conventions for encoding SysProM in file-based formats.", + "id": "ELEM8", + "lifecycle": { + "active": true + }, + "name": "File Representation", + "type": "element" + }, + { + "description": "The model supports branching, merging, and revival of nodes.", + "id": "ELEM9", + "lifecycle": { + "active": true + }, + "name": "Non-Linear Evolution", + "type": "element" + }, + { + "description": "The mechanism for extending SysProM beyond its core types.", + "id": "ELEM10", + "lifecycle": { + "active": true + }, + "name": "Extensibility", + "type": "element" + }, + { + "description": "Primary representation using headings as nodes, lists as relationships, checkboxes as lifecycle, front matter as document metadata.", + "id": "REAL1", + "lifecycle": { + "active": true + }, + "name": "Markdown Representation", + "type": "realisation" + }, + { + "description": "All sections in one file (SysProM.md, SYSPROM.md, SPM.md, or README.spm.md).", + "id": "REAL2", + "lifecycle": { + "active": true + }, + "name": "Single-File Form", + "type": "realisation" + }, + { + "description": "Separate files per concern (README, INTENT, INVARIANTS, STATE, DECISIONS, CHANGES).", + "id": "REAL3", + "lifecycle": { + "active": true + }, + "name": "Multi-Document Form", + "type": "realisation" + }, + { + "description": "Node folders with their own document sets. Nodes may also be single files using .spm.md extension.", + "id": "REAL4", + "lifecycle": { + "active": true + }, + "name": "Recursive Folder Form", + "type": "realisation" + }, + { + "description": "JSON representation validated against schema.json. Supports recursive composition via the subsystem property.", + "id": "REAL5", + "lifecycle": { + "active": true + }, + "name": "JSON Serialisation", + "type": "realisation" + }, + { + "description": "The lifecycle state machine for decision nodes.", + "id": "PROT1", + "name": "Decision Lifecycle", + "type": "protocol" + }, + { + "description": "The lifecycle state machine for change nodes.", + "id": "PROT2", + "name": "Change Lifecycle", + "type": "protocol" + }, + { + "description": "The general lifecycle state machine for nodes.", + "id": "PROT3", + "name": "Node Lifecycle", + "type": "protocol" + }, + { + "description": "Decision has been proposed but not yet evaluated.", + "id": "STG1-DEC-PROPOSED", + "name": "proposed", + "type": "stage" + }, + { + "description": "Decision has been accepted as the chosen path.", + "id": "STG2-DEC-ACCEPTED", + "name": "accepted", + "type": "stage" + }, + { + "description": "Decision has been implemented in the system.", + "id": "STG3-DEC-IMPLEMENTED", + "name": "implemented", + "type": "stage" + }, + { + "description": "Decision has been fully adopted across the system.", + "id": "STG4-DEC-ADOPTED", + "name": "adopted", + "type": "stage" + }, + { + "description": "Decision has been replaced by a newer decision.", + "id": "STG5-DEC-SUPERSEDED", + "name": "superseded", + "type": "stage" + }, + { + "description": "Decision was accepted but later abandoned without implementation.", + "id": "STG6-DEC-ABANDONED", + "name": "abandoned", + "type": "stage" + }, + { + "description": "Decision was accepted but deferred for later implementation.", + "id": "STG7-DEC-DEFERRED", + "name": "deferred", + "type": "stage" + }, + { + "description": "Change has been defined with scope and operations.", + "id": "STG8-CHG-DEFINED", + "name": "defined", + "type": "stage" + }, + { + "description": "Change has been introduced into the system.", + "id": "STG9-CHG-INTRODUCED", + "name": "introduced", + "type": "stage" + }, + { + "description": "Change is being actively worked on.", + "id": "STG10-CHG-IN_PROGRESS", + "name": "in_progress", + "type": "stage" + }, + { + "description": "Change has been fully applied.", + "id": "STG11-CHG-COMPLETE", + "name": "complete", + "type": "stage" + }, + { + "description": "Change has been consolidated and cleaned up.", + "id": "STG12-CHG-CONSOLIDATED", + "name": "consolidated", + "type": "stage" + }, + { + "description": "Node has been proposed but is not yet active.", + "id": "STG13-NODE-PROPOSED", + "name": "proposed", + "type": "stage" + }, + { + "description": "Node is currently active in the system.", + "id": "STG14-NODE-ACTIVE", + "name": "active", + "type": "stage" + }, + { + "description": "Node is deprecated but still present in the system.", + "id": "STG15-NODE-DEPRECATED", + "name": "deprecated", + "type": "stage" + }, + { + "description": "Node has been retired from active use.", + "id": "STG16-NODE-RETIRED", + "name": "retired", + "type": "stage" + }, + { + "description": "Comparison of SysProM against existing systems and tools, demonstrating what each covers and where SysProM fills gaps.", + "id": "ART1", + "name": "System Comparisons", + "subsystem": { + "metadata": { + "doc_type": "artefact", + "scope": "artefact", + "title": "ART1 — System Comparisons" + }, + "nodes": [ + { + "description": [ + "Architecture Decision Records cover decision history only.", + "SysProM subsumes ADR and adds structure, evolution, invariants, and graph-native traceability." + ], + "id": "CON1-ADR", + "name": "ADR Comparison", + "type": "concept" + }, + { + "description": [ + "C4 covers multi-level structure (Context, Container, Component, Code).", + "It is static — no decisions, changes, or time. SysProM adds all three." + ], + "id": "CON2", + "name": "C4 Comparison", + "type": "concept" + }, + { + "description": [ + "Domain-Driven Design covers intent and concept layers via ubiquitous language and bounded contexts.", + "Weak on decisions and evolution. SysProM adds both." + ], + "id": "CON3-DDD", + "name": "DDD Comparison", + "type": "concept" + }, + { + "description": [ + "Event sourcing tracks changes as first-class events.", + "No abstraction layers, no decisions (why), no semantic structure. SysProM adds all three." + ], + "id": "CON4-ES", + "name": "Event Sourcing Comparison", + "type": "concept" + }, + { + "description": [ + "Knowledge graphs provide the underlying data model (nodes + typed edges).", + "Lack lifecycle, decision semantics, and temporal workflow. SysProM adds these." + ], + "id": "CON5-KG", + "name": "Knowledge Graph Comparison", + "type": "concept" + }, + { + "description": [ + "Traceability matrices link requirements to design to implementation.", + "Very linear, no decisions, no non-linearity. SysProM is graph-native." + ], + "id": "CON6-TRACE", + "name": "Traceability Matrix Comparison", + "type": "concept" + }, + { + "description": [ + "ArchiMate/TOGAF cover capability and structure layers with standardised elements.", + "Heavyweight, static, weak on iteration. SysProM is lightweight and decision-driven." + ], + "id": "CON7-EA", + "name": "Enterprise Architecture Comparison", + "type": "concept" + }, + { + "description": [ + "RFC processes cover decision lifecycle (proposal, discussion, acceptance).", + "Not connected to a system model. SysProM integrates decisions with the system graph." + ], + "id": "CON8-RFC", + "name": "RFC Process Comparison", + "type": "concept" + }, + { + "description": [ + "Git provides temporal mechanics (append-only history, branching, merging).", + "No semantic structure — just file diffs. SysProM adds typed nodes and relationships." + ], + "id": "CON9-GIT", + "name": "Git Comparison", + "type": "concept" + }, + { + "description": [ + "MBSE is the closest formal equivalent — requirements, structure, behaviour, constraints.", + "SysProM can be understood as a lightweight, human-readable, decision-centric MBSE." + ], + "id": "CON10-MBSE", + "name": "MBSE/SysML Comparison", + "type": "concept" + }, + { + "description": [ + "Spec Kit is a spec-driven development toolkit where specifications become executable.", + "Can be fully modelled within SysProM as a protocol with stages, artefacts, and artefact flows.", + "SysProM adds invariants, decision supersession, and graph-native traceability.", + "Bidirectional interoperability: SysProM can import Spec-Kit project files (spec.md, plan.md, tasks.md, constitution.md, checklist.md) into typed nodes and export them back, enabling round-trip editing in either format." + ], + "id": "CON11-SPECKIT", + "name": "Spec Kit Comparison", + "type": "concept" + }, + { + "description": [ + "Ralplan runs Planner, Architect, and Critic roles sequentially until consensus.", + "Its process can be modelled as a SysProM protocol with roles, stages, gates, and modes.", + "SysProM can store Ralplan's outputs but cannot replace the planning intelligence itself." + ], + "id": "CON12-RALPLAN", + "name": "Ralplan Comparison", + "type": "concept" + }, + { + "description": [ + "get-shit-done presents a simple command surface while hiding orchestration complexity.", + "Can be modelled as a system with multiple runtime realisations governed by a user-surface stability principle.", + "SysProM captures the architectural truth: one stable capability surface, multiple interchangeable realisations." + ], + "id": "CON13-GSD", + "name": "GSD Comparison", + "type": "concept" + }, + { + "description": [ + "ADR=decisions, C4=structure, DDD=concepts, Event sourcing=changes, Knowledge graph=relationships, Traceability=linking, EA=layers, RFC=lifecycle, Git=history, MBSE=formal modelling.", + "Each optimises one axis. SysProM combines all into a single recursive graph.", + "Spec Kit, Ralplan, and GSD compose with SysProM: Ralplan generates plans, Spec Kit stores specs, GSD executes, SysProM tracks provenance." + ], + "id": "CON14-SUMMARY", + "name": "Summary", + "type": "concept" + } + ] + }, + "type": "artefact" + }, + { + "description": "A worked example demonstrating SysProM applied to a document conversion webapp with placement-agnostic contracts and conditional sync constraints.", + "id": "ART2", + "name": "Document Workspace Example", + "subsystem": { + "metadata": { + "doc_type": "artefact", + "scope": "artefact", + "title": "ART2 — Document Workspace Example" + }, + "nodes": [ + { + "description": "Enable users to ingest, transform, store, and access documents consistently across contexts.", + "id": "INT1", + "name": "Document Workspace", + "type": "intent" + }, + { + "description": "Documents can be converted between representations.", + "id": "CON1", + "name": "Document Transformation", + "type": "concept" + }, + { + "description": "Documents can be stored and later retrieved.", + "id": "CON2", + "name": "Document Persistence", + "type": "concept" + }, + { + "description": "Documents can be kept consistent across multiple contexts.", + "id": "CON3", + "name": "Document Synchronisation", + "type": "concept" + }, + { + "description": "A document retains identity regardless of storage location.", + "id": "INV1", + "name": "Stable Document Identity", + "type": "invariant" + }, + { + "description": "The conversion contract is identical regardless of execution location.", + "id": "INV2", + "name": "Placement-Agnostic Conversion", + "type": "invariant" + }, + { + "description": "The storage contract is identical regardless of persistence location.", + "id": "INV3", + "name": "Placement-Agnostic Storage", + "type": "invariant" + }, + { + "description": "If synchronisation is enabled, persistence must support shared remote state.", + "id": "INV4", + "name": "Sync Requires Shared Persistence", + "type": "invariant" + }, + { + "id": "ELEM1", + "name": "Transformation Engine", + "type": "element" + }, + { + "id": "ELEM2", + "name": "Document Store", + "type": "element" + }, + { + "id": "REAL1", + "lifecycle": { + "active": true + }, + "name": "Local Conversion", + "type": "realisation" + }, + { + "id": "REAL2", + "lifecycle": { + "active": true + }, + "name": "Remote Conversion", + "type": "realisation" + }, + { + "id": "REAL3", + "lifecycle": { + "active": true + }, + "name": "Local Storage", + "type": "realisation" + }, + { + "id": "REAL4", + "lifecycle": { + "active": true + }, + "name": "Remote Storage", + "type": "realisation" + }, + { + "id": "DEC1", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true + }, + "name": "Abstract Conversion Placement", + "options": [ + { + "description": "UI distinguishes local and remote", + "id": "O1" + }, + { + "description": "Single contract independent of placement", + "id": "O2" + } + ], + "rationale": "Execution location is a realisation concern.", + "selected": "O2", + "type": "decision" + } + ], + "relationships": [ + { + "from": "CON1", + "to": "INT1", + "type": "refines" + }, + { + "from": "CON2", + "to": "INT1", + "type": "refines" + }, + { + "from": "CON3", + "to": "INT1", + "type": "refines" + }, + { + "from": "ELEM1", + "to": "CON1", + "type": "realises" + }, + { + "from": "ELEM2", + "to": "CON2", + "type": "realises" + }, + { + "from": "REAL1", + "to": "ELEM1", + "type": "implements" + }, + { + "from": "REAL2", + "to": "ELEM1", + "type": "implements" + }, + { + "from": "REAL3", + "to": "ELEM2", + "type": "implements" + }, + { + "from": "REAL4", + "to": "ELEM2", + "type": "implements" + }, + { + "from": "DEC1", + "to": "ELEM1", + "type": "affects" + }, + { + "from": "DEC1", + "to": "INV2", + "type": "must_preserve" + } + ] + }, + "type": "artefact" + }, + { + "description": "A worked example demonstrating SysProM process modelling with roles, protocols, stages, gates, modes, and artefact flows.", + "id": "ART3", + "name": "Planning Workflow Example", + "subsystem": { + "metadata": { + "doc_type": "artefact", + "scope": "artefact", + "title": "ART3 — Planning Workflow Example" + }, + "nodes": [ + { + "description": "Enable work to move from idea to execution in a controlled, reviewable, and traceable way.", + "id": "INT1", + "name": "Reliable Change Delivery", + "type": "intent" + }, + { + "description": "No change may be executed unless its scope can be identified.", + "id": "INV1", + "name": "Traceable Scope", + "type": "invariant" + }, + { + "description": "Approval cannot occur until review has completed.", + "id": "INV2", + "name": "Review Before Approval", + "type": "invariant" + }, + { + "id": "PROT1", + "name": "Consensus Planning", + "type": "protocol" + }, + { + "id": "STG1", + "name": "Draft Plan", + "type": "stage" + }, + { + "id": "STG2", + "name": "Architectural Review", + "type": "stage" + }, + { + "id": "STG3", + "name": "Critical Evaluation", + "type": "stage" + }, + { + "description": "Produces an initial scoped plan.", + "id": "ROLE1", + "name": "Planner", + "type": "role" + }, + { + "description": "Reviews structural and design soundness.", + "id": "ROLE2", + "name": "Architect", + "type": "role" + }, + { + "description": "Evaluates quality, completeness, and testability.", + "id": "ROLE3", + "name": "Critic", + "type": "role" + }, + { + "description": "Blocks execution when work is vague or underspecified.", + "id": "GATE1", + "name": "Scope Gate", + "type": "gate" + }, + { + "id": "MODE1", + "name": "Standard Mode", + "type": "mode" + }, + { + "description": "Adds explicit user checkpoints.", + "id": "MODE2", + "name": "Interactive Mode", + "type": "mode" + }, + { + "description": "Adds stronger risk analysis and broader verification.", + "id": "MODE3", + "name": "Deliberate Mode", + "type": "mode" + } + ], + "relationships": [ + { + "from": "STG1", + "to": "PROT1", + "type": "part_of" + }, + { + "from": "STG2", + "to": "PROT1", + "type": "part_of" + }, + { + "from": "STG2", + "to": "STG1", + "type": "must_follow" + }, + { + "from": "STG3", + "to": "PROT1", + "type": "part_of" + }, + { + "from": "STG3", + "to": "STG2", + "type": "must_follow" + }, + { + "from": "GATE1", + "to": "INV1", + "type": "governed_by" + }, + { + "from": "MODE2", + "to": "PROT1", + "type": "modifies" + }, + { + "from": "MODE3", + "to": "PROT1", + "type": "modifies" + } + ] + }, + "type": "artefact" + }, + { + "description": "README guidance describing recommended node usage, trace chains, relationship usage, and implementation provenance patterns for product repositories.", + "id": "ART4", + "name": "System Provenance Profile Guidance", + "type": "artefact" + }, + { + "context": [ + "The model needs to represent systems, workflows, and history.", + "Mixing these concerns makes the graph hard to query and reason about." + ], + "id": "DEC1", + "lifecycle": { + "accepted": "2026-03-21", + "implemented": true, + "proposed": "2026-03-21", + "superseded": false + }, + "name": "Separate Domain From Process From Evolution", + "options": [ + { + "description": "Single flat node type for everything", + "id": "D1-O1" + }, + { + "description": "Typed nodes without family grouping", + "id": "D1-O2" + }, + { + "description": "Nodes grouped into domain, process, artefact, and evolution families", + "id": "D1-O3" + } + ], + "rationale": [ + "Grouping into families enforces separation of concerns.", + "Domain structure should not be tangled with process mechanics or evolution history." + ], + "selected": "D1-O3", + "type": "decision" + }, + { + "context": "Systems evolve through choices, but most models bury decisions in prose or lose them entirely.", + "id": "DEC2", + "lifecycle": { + "accepted": "2026-03-21", + "implemented": true, + "proposed": "2026-03-21", + "superseded": false + }, + "name": "Make Decisions First-Class Entities", + "options": [ + { + "description": "Embed decisions in prose within documents", + "id": "D2-O1" + }, + { + "description": "Record decisions as standalone log entries", + "id": "D2-O2" + }, + { + "description": "Model decisions as typed graph nodes with relationships to affected nodes and preserved invariants", + "id": "D2-O3" + } + ], + "rationale": [ + "Decisions are the causal mechanism of system evolution.", + "Graph nodes with typed relationships make decisions queryable, traceable, and enforceable." + ], + "selected": "D2-O3", + "type": "decision" + }, + { + "context": "Systems have rules at different levels of rigidity. Collapsing all rules into one type loses critical information.", + "id": "DEC3", + "lifecycle": { + "accepted": "2026-03-21", + "implemented": true, + "proposed": "2026-03-21", + "superseded": false + }, + "name": "Distinguish Invariants From Principles From Policies", + "options": [ + { + "description": "Single constraint type for all rules", + "id": "D3-O1" + }, + { + "description": "Two types: hard invariant and soft guideline", + "id": "D3-O2" + }, + { + "description": "Three types: invariant (must always hold), principle (normative value, may be overridden), policy (operational rule)", + "id": "D3-O3" + } + ], + "rationale": "The three-way split matches how real systems distinguish structural guarantees from design values from operational rules.", + "selected": "D3-O3", + "type": "decision" + }, + { + "context": "Workflow-heavy systems (planning tools, spec-driven workflows, runtime orchestration) cannot be adequately modelled with decisions and changes alone.", + "id": "DEC4", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true, + "superseded": false + }, + "name": "Add Process Modelling", + "options": [ + { + "description": "Model process implicitly through decisions and changes only", + "id": "D4-O1" + }, + { + "description": "Add roles and stages but not gates, modes, or artefacts", + "id": "D4-O2" + }, + { + "description": "Add full process modelling: roles, protocols, stages, gates, policies, modes, artefacts, artefact flows", + "id": "D4-O3" + } + ], + "rationale": [ + "Without process nodes, roles become invisible, gates become buried in decision prose, and artefact lineage is lost.", + "Full process modelling makes the model capable of encoding systems like Spec Kit, Ralplan, and get-shit-done." + ], + "selected": "D4-O3", + "type": "decision" + }, + { + "context": "The model needs a practical encoding but should not be locked to one format.", + "id": "DEC5", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true, + "superseded": false + }, + "name": "Format-Agnostic With Markdown as Primary Representation", + "options": [ + { + "description": "Define a single mandatory format (e.g. JSON schema)", + "id": "D5-O1" + }, + { + "description": "Define semantics only, with no reference representation", + "id": "D5-O2" + }, + { + "description": "Define semantics as normative, with Markdown as the primary practical representation", + "id": "D5-O3" + } + ], + "rationale": [ + "Markdown is human-readable, Git-friendly, renders on GitHub/GitLab, and supports front matter, headings, links, and checkboxes which map directly to model concepts.", + "Other formats remain valid." + ], + "selected": "D5-O3", + "type": "decision" + }, + { + "context": "Systems contain subsystems. If subsystems use different conventions, the model fractures.", + "id": "DEC6", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true, + "superseded": false + }, + "name": "Recursive Composition Using Same Conventions", + "options": [ + { + "description": "Flat structure only (no nesting)", + "id": "D6-O1" + }, + { + "description": "Nesting with different conventions at each level", + "id": "D6-O2" + }, + { + "description": "Same document set, naming, and relationship conventions at every level of nesting", + "id": "D6-O3" + } + ], + "rationale": "Recursive consistency means any node can be understood in isolation using the same patterns, and tools can process any level without special cases.", + "selected": "D6-O3", + "type": "decision" + }, + { + "context": "Deleting history destroys traceability. If a decision is removed, it becomes impossible to answer why a node exists.", + "id": "DEC7", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true, + "superseded": false + }, + "name": "Append-Only History", + "options": [ + { + "description": "Allow deletion of nodes and decisions", + "id": "D7-O1" + }, + { + "description": "Allow overwriting of decisions with new versions", + "id": "D7-O2" + }, + { + "description": "Append-only: superseded decisions and deprecated nodes remain; nothing is deleted", + "id": "D7-O3" + } + ], + "rationale": "Append-only preserves the full provenance chain.", + "selected": "D7-O3", + "type": "decision" + }, + { + "context": [ + "Nodes often relate to resources outside the graph.", + "The model must handle this without coupling to a specific serialisation format." + ], + "id": "DEC8", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true, + "superseded": false + }, + "name": "Support External Resources via Reference and Internalisation", + "options": [ + { + "description": "No external reference support — all content must be internalised", + "id": "D8-O1" + }, + { + "description": "External references only — all content is by pointer", + "id": "D8-O2" + }, + { + "description": "Both approaches supported independently or together, with serialisation-specific identifiers and typed roles", + "id": "D8-O3" + } + ], + "rationale": [ + "Internalisation enables portability. References enable traceability.", + "Supporting both gives implementors flexibility without losing either property." + ], + "selected": "D8-O3", + "type": "decision" + }, + { + "context": [ + "JSON does not support multiline strings.", + "Long descriptions serialised as single strings with embedded \\n are hard to read and produce poor diffs.", + "An array of lines preserves readability in serialised form." + ], + "id": "DEC9", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true, + "superseded": false + }, + "name": "Allow Array-of-Lines for Text Fields", + "options": [ + { + "description": "Single string only — use \\n for newlines", + "id": "D9-O1" + }, + { + "description": "Array of strings only — always use arrays", + "id": "D9-O2" + }, + { + "description": "Either form accepted — string for short content, array for multiline", + "id": "D9-O3" + } + ], + "rationale": [ + "Short descriptions gain nothing from being wrapped in an array.", + "Long descriptions gain readability and diff quality from line-per-element arrays.", + "Accepting both avoids forcing a style while enabling better ergonomics where it matters." + ], + "selected": "D9-O3", + "type": "decision" + }, + { + "context": [ + "SysProM's purpose is provenance and traceability.", + "If relationship types, node types, and statuses are arbitrary strings, layer constraints cannot be enforced, labels cannot be derived for rendering, and semantic validation is impossible.", + "The extensibility section says extensions MUST NOT violate core constraints, but an open string type makes that unenforceable." + ], + "id": "DEC10", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true, + "superseded": false + }, + "name": "Use Strict Enums for Core Types", + "options": [ + { + "description": "Open strings with examples — any value accepted, core types documented only", + "id": "D10-O1" + }, + { + "description": "Strict enums — only declared values accepted, extensions must add to the enum", + "id": "D10-O2" + } + ], + "rationale": [ + "Strict enums enable schema-level validation, type-safe label derivation, and enforceable layer constraints.", + "The DRY labelledEnum pattern ensures each type is defined once with its label, eliminating duplication." + ], + "selected": "D10-O2", + "type": "decision" + }, + { + "context": [ + "The README generator was producing navigation links and document role entries for all possible files (INTENT, INVARIANTS, STATE, DECISIONS, CHANGES) regardless of whether the subsystem had nodes of those types.", + "This created dead links in subsystem READMEs." + ], + "id": "DEC11", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true, + "superseded": false + }, + "name": "Only Link to Present Files in README", + "options": [ + { + "description": "Always link to all files — accept dead links as informational", + "id": "D11-O1" + }, + { + "description": "Only link to files that will be created based on present node types", + "id": "D11-O2" + } + ], + "rationale": "Dead links mislead readers and break tooling. Links should reflect reality.", + "selected": "D11-O2", + "type": "decision" + }, + { + "context": [ + "The README contained a Navigation section and a Document Roles table that restated what the filenames already communicate.", + "Anyone looking at a folder with INTENT.md, DECISIONS.md, etc. already knows what they contain." + ], + "id": "DEC12", + "lifecycle": { + "accepted": true, + "implemented": false, + "proposed": true, + "superseded": false + }, + "name": "Remove Navigation and Document Roles from README", + "options": [ + { + "description": "Keep both Navigation and Document Roles", + "id": "D12-O1" + }, + { + "description": "Keep Document Roles table only", + "id": "D12-O2" + }, + { + "description": "Remove both — the filenames are self-documenting", + "id": "D12-O3" + } + ], + "rationale": "Removing redundant sections reduces noise and maintenance burden. The file naming convention is the documentation.", + "selected": "D12-O3", + "type": "decision" + }, + { + "context": [ + "INV3 required every decision to identify preserved invariants.", + "Operational decisions (naming, tooling, presentation) genuinely have no invariants at risk.", + "Forcing must_preserve on these creates friction that discourages recording decisions, which defeats SysProM's provenance purpose.", + "SysProM already distinguishes domain nodes (intent, concept, capability, element, invariant) from non-domain nodes (realisation, policy, protocol, etc.)." + ], + "id": "DEC13", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true, + "superseded": false + }, + "name": "Layer-Dependent Invariant Preservation", + "options": [ + { + "description": "Keep INV3 as MUST for all decisions", + "id": "D13-O1" + }, + { + "description": "Soften INV3 to SHOULD for all decisions", + "id": "D13-O2" + }, + { + "description": "Add decision type/level classification", + "id": "D13-O3" + }, + { + "description": "MUST when affecting domain nodes, SHOULD when affecting only non-domain nodes — determined automatically from affects relationships", + "id": "D13-O4" + } + ], + "rationale": [ + "Automatic classification from affects relationships means no user burden.", + "Domain nodes define what the system IS — decisions affecting them must consider invariants.", + "Non-domain nodes define how the system works — decisions affecting only these are operational.", + "Leverages the existing layer model rather than adding new concepts." + ], + "selected": "D13-O4", + "type": "decision" + }, + { + "context": [ + "The distilled/ folder contained four reference documents (Specification, Comparisons, Examples, Naming) produced during SysProM's design.", + "These were external to the JSON but contained valuable content.", + "The specification is already captured as the JSON itself.", + "The comparisons, examples, and naming rationale could be modelled as artefact nodes with subsystems." + ], + "id": "DEC14", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true, + "superseded": false + }, + "name": "Internalise Design Archive into SysProM JSON", + "options": [ + { + "description": "Keep distilled/ as separate files, reference from JSON", + "id": "D14-O1" + }, + { + "description": "Internalise key content as artefact nodes with subsystems in the JSON", + "id": "D14-O2" + } + ], + "rationale": "Internalising makes the JSON self-contained. Artefact nodes with subsystems naturally model documents that contain structured content.", + "selected": "D14-O2", + "type": "decision" + }, + { + "context": [ + "Small subsystems (e.g. 6 node type definitions) are cleaner as single .spm.md files.", + "Large subsystems (e.g. 24 relationship type definitions at 107 lines) become unwieldy in a single file.", + "The file type count heuristic alone doesn't catch single-type subsystems that are too long." + ], + "id": "DEC15", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true, + "superseded": false + }, + "name": "Size-Based Subsystem Splitting", + "options": [ + { + "description": "Split only by file type count — single type always stays as one file", + "id": "D15-O1" + }, + { + "description": "Split by file type count AND line count — single type files over 100 lines become folders", + "id": "D15-O2" + } + ], + "rationale": "Combining both heuristics keeps small subsystems compact while splitting large ones for readability.", + "selected": "D15-O2", + "type": "decision" + }, + { + "context": [ + "SysProM can model Spec-Kit workflows as nodes and relationships, but could not read or write actual Spec-Kit files.", + "Users working with Spec-Kit (spec.md, plan.md, tasks.md, constitution.md, checklist.md) had no way to import their existing work into SysProM or export SysProM graphs to Spec-Kit format.", + "A parser+generator pair enables lossless round-trips between both ecosystems." + ], + "id": "DEC16", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true, + "superseded": false + }, + "name": "Add Bidirectional Spec-Kit Interoperability", + "options": [ + { + "description": "Import-only — parse Spec-Kit files into SysProM nodes, no export", + "id": "D16-O1" + }, + { + "description": "Export-only — generate Spec-Kit files from SysProM nodes, no import", + "id": "D16-O2" + }, + { + "description": "Full bidirectional with sync — import, export, sync, and diff commands", + "id": "D16-O3" + } + ], + "rationale": "Full bidirectional support allows users to start in either ecosystem and keep both in sync. Import-only or export-only would force a one-way migration rather than enabling collaborative workflows.", + "selected": "D16-O3", + "type": "decision" + }, + { + "context": [ + "Change nodes have a plan field (array of {description, done} tasks) defined in the schema, but no CLI command existed to manipulate it.", + "Subagents working in a Claude Code session had no way to discover, claim, or progress through tasks purely via CLI.", + "A dedicated task command enables fully CLI-driven subagent workflows against sysprom.spm.json files." + ], + "id": "DEC17", + "lifecycle": { + "accepted": true, + "implemented": true, + "proposed": true, + "superseded": false + }, + "name": "Add Task Subcommand for Change Plan Tracking", + "options": [ + { + "description": "Extend the update command with --plan-add and --plan-done flags", + "id": "D17-O1" + }, + { + "description": "Add a dedicated top-level task command with list/add/done/undone subcommands", + "id": "D17-O2" + } + ], + "rationale": "A dedicated command keeps the update command focused on node fields and provides a cleaner interface for agents scripting task workflows. The speckit command established this subcommand pattern.", + "selected": "D17-O2", + "type": "decision" + }, + { + "context": "The spec-kit planning integration initially used stage nodes for phases and change nodes for tasks, with flat plan:Task[] arrays for leaf items. This created three separate mechanisms for what is conceptually the same thing.", + "description": "Phases and tasks are structurally identical — a unit of work that can contain smaller units. Rather than maintaining separate stage nodes for phases and change nodes for tasks, use a single recursive model: change nodes with subsystems containing more change nodes. This eliminates the artificial three-layer model (protocol, stage, change, task) in favour of uniform recursive composition via SysProM's native subsystem mechanism.", + "id": "DEC18", + "lifecycle": { + "accepted": true, + "options": false + }, + "name": "Recursive Change Nodes for Planning", + "options": [ + { + "description": "Keep separate stage nodes for phases and change nodes for tasks (three-layer model)", + "id": "D18-OPT-A" + }, + { + "description": "Use recursive change nodes with subsystems for unlimited nesting depth (single mechanism)", + "id": "D18-OPT-B" + } + ], + "rationale": "A phase is just a task with children. Using change nodes with recursive subsystems provides unlimited nesting depth, eliminates the stage node type from planning, and reuses SysProM's existing subsystem recursion rather than inventing a parallel mechanism.", + "selected": "D18-OPT-B", + "type": "decision" + }, + { + "context": "SysProM had no temporal support. The lifecycle field tracked state completion but not when states were reached.", + "description": "Extend lifecycle values from boolean to boolean | string, where string values are ISO dates indicating when a state was reached. Date strings are truthy, so existing code using truthiness checks works unchanged. This single schema change enables timestamped lifecycle, temporal snapshots, and event ordering.", + "id": "DEC19", + "lifecycle": { + "accepted": true + }, + "name": "Extend Lifecycle with Temporal Timestamps", + "options": [ + { + "description": "Add separate timestamp fields (created_at, updated_at) to nodes", + "id": "D19-OPT-A" + }, + { + "description": "Extend lifecycle values from boolean to boolean | string (ISO date)", + "id": "D19-OPT-B" + } + ], + "rationale": "One schema change enables all three temporal capabilities: timestamped lifecycle, temporal snapshots via stateAt queries, and event ordering via timeline queries. Date strings are truthy, ensuring backwards compatibility.", + "selected": "D19-OPT-B", + "type": "decision" + }, + { + "context": "The CLI uses manual process.argv parsing with parseFlag() helpers duplicated across 11 command files. Usage text is embedded in console.error() strings, making automatic documentation generation impossible. A structured CLI framework is needed to enable programmatic access to command metadata for doc generation.", + "description": "Choose a CLI framework to replace manual argument parsing, enabling automatic documentation generation from command definitions.", + "id": "DEC20", + "lifecycle": { + "accepted": "2026-03-21", + "proposed": "2026-03-21" + }, + "name": "Adopt Commander.js for CLI", + "options": [ + { + "description": "Zero dependencies, lowest migration effort, programmatic command tree access. No built-in markdown export but command metadata is accessible via public API.", + "id": "D20-OPT-A" + }, + { + "description": "Built-in doc generation via oclif readme. Higher dependency count (~28), slower startup (~100ms), requires class-per-command refactor, uses colon-delimited subcommands.", + "id": "D20-OPT-B" + }, + { + "description": "Modern TypeScript-first alternatives (Citty, Clipanion, Stricli). Zero dependencies but none have built-in markdown doc generation.", + "id": "D20-OPT-C" + }, + { + "description": "Keep manual process.argv parsing. No migration effort but no automatic doc generation possible.", + "id": "D20-OPT-D" + } + ], + "rationale": "Commander.js has zero dependencies, the lowest migration effort from manual argv parsing, and exposes a public API for walking the command tree programmatically. This enables a simple doc generation script without adopting a heavier framework.", + "selected": "D20-OPT-A", + "type": "decision" + }, + { + "context": "The library has a rich public API (~70 exports) but no automated documentation generation. TypeDoc generates browsable docs from TypeScript source and JSDoc comments. The typedoc-plugin-zod plugin resolves rendering issues with Zod-inferred types.", + "description": "Choose TypeDoc with typedoc-plugin-zod for API documentation generation, producing both markdown (committed) and HTML (for GitHub Pages) output.", + "id": "DEC21", + "lifecycle": { + "accepted": "2026-03-21", + "proposed": "2026-03-21" + }, + "name": "Adopt TypeDoc for Documentation", + "options": [ + { + "description": "TypeDoc with typedoc-plugin-markdown for markdown output and typedoc-plugin-zod for clean Zod type rendering. Supports projectDocuments for including CLI docs in the HTML site.", + "id": "D21-OPT-A" + }, + { + "description": "Docusaurus, Starlight, or VitePress for a full documentation site framework. More features but heavier dependencies and over-engineered for a small project.", + "id": "D21-OPT-B" + } + ], + "rationale": "TypeDoc directly generates docs from TypeScript source with minimal configuration. The typedoc-plugin-zod plugin resolves the z.infer rendering issue without requiring explicit interfaces. The projectDocuments feature allows CLI docs to be included in the same HTML site.", + "selected": "D21-OPT-A", + "type": "decision" + }, + { + "context": "The build pipeline has multiple tasks with dependencies (typecheck, compile, schema generation, doc generation) managed via chained pnpm scripts. Turborepo provides task dependency graphs, parallel execution, and output caching.", + "description": "Choose Turborepo for build task orchestration with dependency management and output caching.", + "id": "DEC22", + "lifecycle": { + "accepted": "2026-03-21", + "proposed": "2026-03-21" + }, + "name": "Adopt Turborepo for Build Orchestration", + "options": [ + { + "description": "Turborepo with task dependency graph, input/output declarations, and automatic caching. Handles output directory cleaning on cache hits.", + "id": "D22-OPT-A" + }, + { + "description": "Keep chained pnpm scripts with && operators. Simpler but no caching, no parallelism, no dependency graph.", + "id": "D22-OPT-B" + } + ], + "rationale": "Turborepo provides automatic caching (FULL TURBO on repeat builds), parallel task execution, and explicit dependency declarations in turbo.json. It manages output directory cleaning automatically.", + "selected": "D22-OPT-A", + "type": "decision" + }, + { + "context": "The project uses conventional commit messages informally. Enforcing them with tooling enables automated releases, changelogs, and consistent commit history.", + "description": "Adopt commitlint for commit message enforcement, semantic-release for automated publishing, and husky for git hook management.", + "id": "DEC23", + "lifecycle": { + "accepted": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "Enforce Conventional Commits and Automated Releases", + "options": [ + { + "description": "commitlint with conventional commits preset, semantic-release with all commit types triggering releases, and husky for pre-commit and commit-msg hooks.", + "id": "D23-OPT-A" + }, + { + "description": "Manual release process with no commit message enforcement.", + "id": "D23-OPT-B" + } + ], + "rationale": "Automated enforcement ensures every commit follows conventional format, enabling semantic-release to determine version bumps and generate changelogs. All commit types trigger patch releases so no work is excluded.", + "selected": "D23-OPT-A", + "type": "decision" + }, + { + "context": "The codebase contained numerous as type assertions that bypass the type checker, telling the compiler to trust the developer rather than proving correctness at runtime.", + "description": "Remove all as type coercions and replace them with Zod schema validation, type guard functions, and properly typed parameters.", + "id": "DEC24", + "lifecycle": { + "accepted": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "Eliminate Type Assertions", + "options": [ + { + "description": "Replace all as assertions with runtime validation using Zod .is() type guards, instanceof checks, and properly typed function parameters.", + "id": "D24-OPT-A" + }, + { + "description": "Keep type assertions where TypeScript cannot express the constraint. Accept the risk of runtime type mismatches.", + "id": "D24-OPT-B" + } + ], + "rationale": "Runtime validation catches type errors that assertions silently mask. The Zod schema provides .is() type guards via defineSchema — using them is both safer and consistent with the single-source-of-truth pattern.", + "selected": "D24-OPT-A", + "type": "decision" + }, + { + "context": "The package previously shipped TypeScript source and required tsx at runtime. Consumers needed tsx as a dependency to use the CLI or import the library.", + "description": "Switch package entry points (main, exports, bin) from TypeScript source to compiled JavaScript in dist/, removing the tsx runtime dependency for consumers.", + "id": "DEC25", + "lifecycle": { + "accepted": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "Ship Compiled JavaScript", + "options": [ + { + "description": "Point main, exports, and bin to compiled dist/ output. Move tsx to devDependencies. Consumers only need Node.js.", + "id": "D25-OPT-A" + }, + { + "description": "Keep shipping TypeScript source with tsx as a runtime dependency.", + "id": "D25-OPT-B" + } + ], + "rationale": "Shipping compiled JavaScript removes the tsx runtime dependency, reduces install size, and follows standard npm package conventions.", + "selected": "D25-OPT-A", + "type": "decision" + }, + { + "context": "Adding nodes via the CLI required manually specifying IDs like D26 or CH24. This is tedious and error-prone, especially when the user must check existing IDs to avoid collisions.", + "description": "Auto-generate node IDs from a type-prefix convention when --id is omitted from the add command.", + "id": "DEC26", + "lifecycle": { + "accepted": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "Auto-Generate Node IDs", + "options": [ + { + "description": "Auto-generate IDs from NODE_ID_PREFIX map + highest existing number. Make --id optional.", + "id": "D26-OPT-A" + }, + { + "description": "Keep --id required. Users must manually track IDs.", + "id": "D26-OPT-B" + } + ], + "rationale": "The existing ID convention (D for decisions, CH for changes, INV for invariants, etc.) is consistent enough to derive automatically. The nextId function scans existing nodes for the highest number with that prefix and increments.", + "selected": "D26-OPT-A", + "type": "decision" + }, + { + "context": "The CLI covers core CRUD operations but lacks quality-of-life features that reduce friction for frequent use.", + "description": "Add auto-generated option IDs, init command, auto-sync, coloured output, JSON output on mutations, full-text search, graph export, rename, stricter validation, shell completions, and dry-run mode.", + "id": "DEC27", + "lifecycle": { + "accepted": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "CLI UX Improvements", + "options": [ + { + "description": "Implement all suggested UX improvements as a single cohesive change.", + "id": "D27-OPT-A" + }, + { + "description": "Implement incrementally, prioritising the most impactful features.", + "id": "D27-OPT-B" + } + ], + "rationale": "Each improvement targets a specific friction point: auto-IDs reduce manual bookkeeping, colour improves scanability, search/graph/rename add power-user workflows, and dry-run/completions improve developer experience.", + "selected": "D27-OPT-A", + "type": "decision" + }, + { + "context": "The CLI had three layers describing the same information independently: Zod schemas, Commander definitions, and run() functions with manual parseFlag helpers.", + "description": "Replace separate Commander definitions, run() functions, and doc generator metadata with a single defineCommand pattern using Zod schemas.", + "id": "DEC28", + "lifecycle": { + "accepted": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "Unify CLI with Zod-Driven Command Definitions", + "options": [ + { + "description": "defineCommand pattern with Zod schemas as single source of truth", + "id": "D28-OPT-A" + }, + { + "description": "Keep separate layers", + "id": "D28-OPT-B" + } + ], + "rationale": "A single defineCommand pattern eliminates duplication. Commander program, documentation, and validation are all derived from the Zod schema.", + "selected": "D28-OPT-A", + "type": "decision" + }, + { + "context": "Library functions have no metadata. CLI commands redeclare descriptions, types, and validation. Adding a feature requires updating 3 places.", + "description": "Each domain operation defined once with Zod input/output schemas. Programmatic API, CLI, and docs derived from the single definition.", + "id": "DEC29", + "lifecycle": { + "accepted": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "Unify Library API and CLI with defineOperation", + "options": [ + { + "description": "defineOperation pattern — operations carry Zod schemas, CLI is a thin adapter", + "id": "D29-OPT-A" + }, + { + "description": "Keep separate layers — library functions, CLI commands, and doc generator remain independent", + "id": "D29-OPT-B" + } + ], + "rationale": "Single source of truth eliminates duplication and drift. Operations are callable functions with introspectable schemas.", + "selected": "D29-OPT-A", + "type": "decision" + }, + { + "context": "SysProM is a CLI tool and library for tracking system provenance. Claude Code supports plugins that extend its capabilities with skills, commands, hooks, and agents. A plugin would give Claude native awareness of SysProM workflows — recording decisions, tracking changes, checking invariants — directly within coding sessions. The plugin is pure markdown (no compiled code) and delegates to the spm CLI at runtime.", + "id": "DEC30", + "name": "Distribute SysProM as a Claude Code Plugin", + "options": [ + { + "description": "Pure-markdown plugin with skills, commands, hooks, and agents that call spm via Bash. Use spm if globally installed, otherwise npx -y sysprom after npm publication.", + "id": "D30-OPT-A" + }, + { + "description": "Bundle compiled CLI into the plugin as a single-file JavaScript bundle. Fully self-contained but requires committing compiled code to git.", + "id": "D30-OPT-B" + }, + { + "description": "MCP server wrapping the programmatic API. Provides structured tool access but adds complexity and a Node.js runtime dependency within the plugin.", + "id": "D30-OPT-C" + }, + { + "description": "No plugin. Users rely on CLAUDE.md instructions and manual spm invocation.", + "id": "D30-OPT-D" + } + ], + "rationale": "A pure-markdown plugin requires no compiled code in git and no build step. Skills teach Claude how to use SysProM; the CLI is resolved at runtime via spm or npx. This follows the pattern of other CLI-wrapping plugins (wayback, devops tools) that treat the CLI as a prerequisite. Distribution via GitHub marketplace requires only a marketplace.json in the repo.", + "selected": "D30-OPT-A", + "type": "decision" + }, + { + "context": "Currently json2md and md2json are separate one-directional commands. Users must remember which direction to convert, and there is no conflict detection or resolution when both sides have diverged. A single sync command that is bidirectional by default would reduce friction and prevent data loss.", + "id": "DEC31", + "name": "Bidirectional Sync by Default", + "options": [ + { + "description": "Add a unified 'spm sync' command that is bidirectional by default, with sub-commands or flags for conflict handling (--prefer-json, --prefer-md, --interactive, --dry-run). Deprecate separate json2md/md2json as the primary workflow.", + "id": "OPT-A" + }, + { + "description": "Keep json2md and md2json as primary commands but add conflict detection warnings when the target has unsaved changes.", + "id": "OPT-B" + } + ], + "rationale": "A single sync command aligns with the principle of least surprise and reduces cognitive load. Explicit conflict-handling flags give users precise control when needed, while the default bidirectional behaviour covers the common case.", + "selected": "OPT-A", + "type": "decision" + }, + { + "context": "The Claude Code plugin (D30) uses CLI skills that shell out to spm. An MCP server wrapping SysProM's programmatic API would provide structured tool access with typed inputs/outputs via Zod schemas, eliminating CLI text parsing. The server uses stdio transport, ships as an extra bin entry in the same npm package, and is referenced from the plugin's .mcp.json. Same npm-publication prerequisite as the CLI.", + "id": "DEC32", + "name": "Add MCP Server for Programmatic API Access", + "options": [ + { + "description": "Add MCP server as extra bin entry (sysprom-mcp) in the existing sysprom package. Single source file at src/mcp/index.ts wrapping the programmatic API. Plugin .mcp.json references it via npx.", + "id": "OPT-A" + }, + { + "description": "Separate npm package (sysprom-mcp) in a packages/ monorepo workspace. Independent versioning but more infrastructure.", + "id": "OPT-B" + }, + { + "description": "No MCP server. Plugin relies entirely on CLI skills shelling out to spm.", + "id": "OPT-C" + } + ], + "rationale": "Same package avoids monorepo overhead. The MCP server is a thin wrapper around the existing programmatic API — one source file, one new dependency (@modelcontextprotocol/sdk), one extra bin entry. SysProM already has zod which satisfies the SDK peer dependency.", + "selected": "OPT-A", + "type": "decision" + }, + { + "context": "SysProM has speckit interop (import/export/sync/diff) hardcoded to one external format. Superpowers (obra/superpowers) uses a similar directory-of-markdown pattern for specs and plans. Other workflow tools may emerge. The speckit code has a clear detect/parse/generate structure that can be generalised.", + "id": "DEC33", + "name": "Abstract External Format Interop into Keyed Provider Registry", + "options": [ + { + "description": "Keyed provider registry — define an ExternalFormatProvider interface, register providers in a const object keyed by string literal, derive ProviderKey union from the registry. Auto-detect provider from directory structure, allow explicit --format flag.", + "id": "OPT-A" + }, + { + "description": "Kind-string provider — each provider carries a kind: string field, registry is an array scanned at runtime. Simpler but loses type-safe lookup.", + "id": "OPT-B" + }, + { + "description": "No abstraction — duplicate speckit code for superpowers with format-specific operations. Quick but violates DRY.", + "id": "OPT-C" + } + ], + "rationale": "Keyed registry gives type-safe lookup, avoids stringly-typed dispatch, and the ProviderKey union auto-updates when new providers are added. satisfies Record enforces the interface while preserving concrete types per key. Consistent with the codebase defineOperation pattern.", + "selected": "OPT-A", + "type": "decision" + }, + { + "context": "Current removeNode and removeRelationship operations silently break must_follow chains, leave dangling scope/operation references, and can lose nested subsystems without warning. Need safe removal that preserves graph integrity by default.", + "id": "DEC34", + "name": "Safe Graph Removal with Soft Delete Default", + "options": [ + { + "description": "Soft delete default with hard mode — default removal sets status: retired (preserves all edges), --hard does physical removal with automatic must_follow chain repair, scope cleanup, and structured impact summary. --hard on nodes with subsystems requires --recursive.", + "id": "OPT-A" + }, + { + "description": "Always physical removal with impact report — show what will break before removing, let the caller abort. No soft delete.", + "id": "OPT-B" + }, + { + "description": "Refuse if referenced — only allow removal when nothing references the node. Safe but overly restrictive.", + "id": "OPT-C" + } + ], + "rationale": "Soft delete via existing status: retired is zero-cost (no schema change) and never breaks the graph. Hard mode with chain repair handles the common must_follow case deterministically. Requiring --recursive for subsystem loss prevents accidental data destruction.", + "selected": "OPT-A", + "type": "decision" + }, + { + "context": "Several mutation operations (updateNode, addRelationship) lack safety checks. Status transitions to retired have no impact awareness. Duplicate relationships can be added. Type changes can invalidate existing relationships. addRelationship has no semantic validation of endpoint types.", + "id": "DEC35", + "name": "Graph Mutation Safety Guards", + "options": [ + { + "description": "Comprehensive guards — add retirement impact check to updateNode, duplicate prevention to addRelationship, type-change guard with relationship validity map, and relationship semantic validation for endpoint node types. Enforce in mutation operations and validate.", + "id": "OPT-A" + }, + { + "description": "Validation only — add checks to validate but do not guard mutation operations. Catch-after-the-fact approach.", + "id": "OPT-B" + }, + { + "description": "Guards only for high-severity — retirement impact and duplicate prevention only. Defer type and semantic validation.", + "id": "OPT-C" + } + ], + "rationale": "All four gaps are real and independently discoverable. Validation-only misses the opportunity to prevent bad state. Deferring type/semantic checks leaves a class of silent corruption. A relationship-type-to-endpoint-type map serves both the type-change guard and the semantic validation, so they share implementation cost.", + "selected": "OPT-A", + "type": "decision" + }, + { + "context": "CLI commands require explicit file paths. Users want sensible defaults (.spm.json, .spm.md, .spm/) and an init command that creates documents with context-dependent format.", + "id": "DEC36", + "name": "Default input resolution and init command", + "options": [ + { + "description": "Priority-based auto-detection with init --format flag", + "id": "OPT-A" + } + ], + "rationale": "Priority order matches user expectations; --format flag gives explicit control when defaults are wrong", + "selected": "OPT-A", + "type": "decision" + }, + { + "context": "SysProM supports JSON and Markdown serialisation but users request YAML for human-readable, diff-friendly documents and multi-file JSON for large projects. Adding these formats broadens adoption without changing the core model.", + "id": "DEC37", + "name": "Add YAML and multi-file JSON serialisation formats", + "options": [ + { + "description": "YAML single-file and multi-document plus multi-file JSON with new CLI commands", + "id": "OPT-A" + } + ], + "rationale": "YAML is widely expected for configuration-style documents; multi-file JSON mirrors the existing multi-file Markdown pattern for consistency", + "selected": "OPT-A", + "type": "decision" + }, + { + "context": "CLI commands for file I/O (init, json2md, md2json, sync, speckit, plan init) use positional arguments for paths. Flags are more consistent with other commands that already use --path, and allow auto-detection when omitted.", + "id": "DEC38", + "name": "Convert file-path positional args to flags", + "options": [ + { + "description": "Convert all positional args to flags", + "id": "OPT-A" + }, + { + "description": "Keep positional args as-is", + "id": "OPT-B" + } + ], + "rationale": "Entity IDs and search terms remain positional (natural operands). File paths become flags for consistency with --path used elsewhere, and to enable auto-detection when omitted.", + "selected": "OPT-A", + "type": "decision" + }, + { + "context": "MCP tools returned success but did not write to disk", + "id": "DEC39", + "name": "Fix MCP write operations not persisting", + "options": [ + { + "description": "Add saveDocument calls", + "id": "fix" + } + ], + "rationale": "Each write operation was missing the saveDocument call after modifying the document", + "selected": "fix", + "type": "decision" + }, + { + "context": "Running spm init .spm.json creates .spm.json.spm.json", + "id": "DEC40", + "name": "Fix init path suffix doubling", + "options": [ + { + "description": "Check if path ends with suffix before appending", + "id": "fix" + } + ], + "rationale": "Simple suffix check prevents doubling", + "selected": "fix", + "type": "decision" + }, + { + "context": "SysProM has typed directed graphs but no facility to derive new knowledge from graph structure. Ouroboros provides inference via LLM-scored ambiguity and convergence metrics. We want equivalent capability without LLM dependency — pure graph traversal and structural analysis.", + "id": "DEC41", + "name": "Add deterministic graph inference", + "options": [ + { + "description": "Single inferOp with type discriminator — one operation with a type parameter selecting the analysis category", + "id": "OPT-A" + }, + { + "description": "Separate operations per category — four independent operations (impact, completeness, lifecycle, derived) with distinct input/output schemas", + "id": "OPT-B" + }, + { + "description": "Extend validate with inference rules — add inference findings to the existing validate operation", + "id": "OPT-C" + } + ], + "rationale": "Each inference category has fundamentally different inputs (impact needs a startId; others do not) and output shapes. A union return type forces consumers to narrow on every call. Separate operations follow the existing pattern where validate, stats, trace, and query are all independent. Option C would bloat validate with unrelated concerns.", + "selected": "OPT-B", + "type": "decision" + }, + { + "context": "inferImpactOp only follows outgoing edges and ignores 14 of 24 relationship types. SysML models bidirectional typed traceability; ArchiMate adds polarity annotations on influence relationships. Both are needed to move Impact from partial to first-class support.", + "id": "DEC42", + "name": "Enhance impact analysis for SysML/ArchiMate parity", + "options": [ + { + "description": "Add influence relationship type only — no directional change", + "id": "OPT-A" + }, + { + "description": "Bidirectional traversal only — no polarity annotations", + "id": "OPT-B" + }, + { + "description": "Both — bidirectional traversal plus optional polarity and strength on Relationship plus influence type plus full relationship classification", + "id": "OPT-C" + } + ], + "rationale": "OPT-A alone cannot answer what depends on X — the key SysML gap. OPT-B alone cannot annotate polarity — the key ArchiMate gap. OPT-C delivers both with minimal schema additions (two optional fields, one new relationship type).", + "selected": "OPT-C", + "type": "decision" + }, + { + "context": "Product-system modelling revealed endpoint restrictions blocking valid specification, design, and implementation provenance patterns.", + "id": "DEC43", + "lifecycle": { + "implemented": true + }, + "name": "Expand endpoint type matrix for governance modelling", + "options": [ + { + "description": "Expand endpoints and add workflow-safe relationship types such as orchestrates — all additive, semantically valid", + "id": "OPT-A" + }, + { + "description": "Keep strict matrix — no changes", + "id": "OPT-B" + } + ], + "rationale": "The endpoint expansions are additive, preserve existing semantics, and make SysProM more effective for modelling real product systems without changing its core ontology. Abstract workflow machines can now connect cleanly to executable flow nodes without overloading part_of semantics.", + "selected": "OPT-A", + "type": "decision" + }, + { + "context": "Project tracking had two parallel task mechanisms: change.plan[] checklists and recursive change-node planning. This created duplicated semantics, unclear completion logic, and weak blockage derivation.", + "id": "DEC44", + "lifecycle": { + "implemented": true + }, + "name": "Adopt Graph-Native Task Lifecycle Model", + "options": [ + { + "description": "Remove change.plan[] and use lifecycle-bearing change nodes as the only task model", + "id": "OPT-A" + }, + { + "description": "Keep both models", + "id": "OPT-B" + }, + { + "description": "Keep plan[] only", + "id": "OPT-C" + } + ], + "rationale": "A single graph-native model keeps task semantics structurally consistent, enables deterministic blockage derivation from relationships and gates, and removes redundant command/API surfaces.", + "selected": "OPT-A", + "type": "decision" + }, + { + "description": "Establishes the core domain model with layered abstraction, decisions, changes, and invariants.", + "id": "CHG1", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Initial Model", + "operations": [ + { + "target": "INT1", + "type": "add" + }, + { + "target": "ELEM1", + "type": "add" + }, + { + "target": "ELEM4", + "type": "add" + }, + { + "target": "ELEM6", + "type": "add" + } + ], + "scope": [ + "INT1", + "CON1", + "CON2", + "CON3", + "CON4", + "CAP1", + "CAP2", + "CAP3", + "CAP4", + "CAP5", + "ELEM1", + "ELEM4", + "INV1", + "INV2", + "INV3", + "INV4", + "INV5", + "INV6", + "INV7", + "PRIN1", + "PRIN2", + "PRIN3", + "PRIN4", + "PRIN5" + ], + "type": "change" + }, + { + "description": "Extends the model with process, artefact, and projection node families.", + "id": "CHG2", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Add Process Modelling", + "operations": [ + { + "target": "ELEM2", + "type": "add" + }, + { + "target": "ELEM3", + "type": "add" + }, + { + "target": "ELEM5", + "type": "add" + } + ], + "scope": [ + "CON5", + "CAP6", + "ELEM2", + "ELEM3", + "ELEM5", + "INV8", + "INV9", + "INV10", + "INV11" + ], + "type": "change" + }, + { + "description": "Defines how SysProM may be encoded in files, including single-document, multi-document, and recursive folder forms.", + "id": "CHG3", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Add File Representation Conventions", + "operations": [ + { + "target": "ELEM8", + "type": "add" + }, + { + "target": "REAL1", + "type": "add" + }, + { + "target": "REAL2", + "type": "add" + }, + { + "target": "REAL3", + "type": "add" + }, + { + "target": "REAL4", + "type": "add" + }, + { + "target": "REAL5", + "type": "add" + } + ], + "scope": [ + "CON6", + "CAP7", + "REAL1", + "REAL2", + "REAL3", + "REAL4", + "REAL5", + "ELEM8", + "POL1", + "POL2", + "POL3", + "POL10", + "POL11", + "POL12", + "POL13", + "POL14", + "POL15", + "POL16", + "POL17", + "POL18" + ], + "type": "change" + }, + { + "description": "Adds the external reference and internalisation mechanism with typed roles.", + "id": "CHG4", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Add External Resources Model", + "operations": [ + { + "target": "CON7", + "type": "add" + }, + { + "target": "CAP8", + "type": "add" + }, + { + "target": "ELEM7", + "type": "add" + } + ], + "scope": [ + "CON7", + "CAP8", + "ELEM7", + "INV19", + "INV20", + "POL6" + ], + "type": "change" + }, + { + "description": "Adds the decision, change, and node lifecycle state machines as protocols with stages and ordering.", + "id": "CHG5", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Add Lifecycle Protocols", + "operations": [ + { + "target": "PROT1", + "type": "add" + }, + { + "target": "PROT2", + "type": "add" + }, + { + "target": "PROT3", + "type": "add" + } + ], + "scope": [ + "PROT1", + "PROT2", + "PROT3" + ], + "type": "change" + }, + { + "description": "Adds conformance requirements, missing invariants, security and extensibility policies, non-linear evolution capabilities, and complete node/relationship type vocabularies to make the JSON self-contained.", + "id": "CHG6", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Encode Full Normative Specification", + "operations": [ + { + "target": "CON8", + "type": "add" + }, + { + "target": "ELEM9", + "type": "add" + }, + { + "target": "ELEM10", + "type": "add" + } + ], + "scope": [ + "INV28", + "INV29", + "INV30", + "INV31", + "INV32", + "INV12", + "INV13", + "INV14", + "INV15", + "INV16", + "INV17", + "INV18", + "POL4", + "POL5", + "POL6", + "POL7", + "POL8", + "POL9", + "ELEM9", + "ELEM10", + "CAP9", + "CAP10", + "CAP11", + "CON8" + ], + "type": "change" + }, + { + "description": [ + "Adds support for text fields (description, context, rationale, internalised) to accept either a string or an array of strings.", + "Updates the JSON schema, Zod definitions, and specification." + ], + "id": "CHG7", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Add Text Field Duality", + "operations": [ + { + "target": "INV21", + "type": "add" + }, + { + "target": "DEC8", + "type": "add" + }, + { + "description": "JSON schema updated to accept string | string[] for text fields", + "target": "REAL5", + "type": "update" + }, + { + "description": "Specification updated with §6.2 Text Fields", + "target": "REAL1", + "type": "update" + } + ], + "scope": [ + "INV21", + "DEC8" + ], + "type": "change" + }, + { + "description": [ + "Replaces open z.string() types with z.enum() for node types, statuses, relationship types, and external reference roles.", + "Introduces labelledEnum() helper that defines values and labels in one place.", + "Derives SECTION_LABELS, RELATIONSHIP_LABELS, and reverse lookups from the label maps." + ], + "id": "CHG8", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Switch to Strict Enums with Labelled Definitions", + "operations": [ + { + "target": "INV22", + "type": "add" + }, + { + "target": "DEC8", + "type": "add" + }, + { + "description": "JSON schema now uses enum instead of string with examples", + "target": "REAL5", + "type": "update" + } + ], + "scope": [ + "INV22", + "DEC8", + "REAL5" + ], + "type": "change" + }, + { + "description": "README generator now only links to files that contain nodes for the given subsystem.", + "id": "CHG9", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Fix Dead Links in Subsystem READMEs", + "operations": [ + { + "target": "POL19", + "type": "add" + }, + { + "target": "DEC8", + "type": "add" + }, + { + "description": "json-to-md updated to check present node types before generating links", + "target": "REAL1", + "type": "update" + } + ], + "scope": [ + "POL19", + "DEC8", + "REAL1" + ], + "type": "change" + }, + { + "description": "Removes the Navigation section and Document Roles table from generated READMEs. The file naming convention is self-documenting.", + "id": "CHG10", + "lifecycle": { + "complete": false, + "defined": true, + "introduced": false + }, + "name": "Remove Navigation and Document Roles from README", + "operations": [ + { + "description": "README generation simplified to omit navigation and document roles", + "target": "REAL1", + "type": "update" + } + ], + "scope": [ + "DEC8", + "REAL1" + ], + "type": "change" + }, + { + "description": [ + "Updates INV3 to require must_preserve only when a decision affects domain nodes.", + "Decisions affecting only non-domain nodes (realisations, policies, process nodes) should but are not required to identify preserved invariants." + ], + "id": "CHG11", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Make Invariant Preservation Layer-Dependent", + "operations": [ + { + "description": "Reworded to distinguish domain and non-domain affects", + "target": "INV3", + "type": "update" + } + ], + "scope": [ + "INV3", + "DEC8" + ], + "type": "change" + }, + { + "description": [ + "Internalises content from distilled/ into the SysProM JSON.", + "Comparisons become an artefact node (ART1) with comparison summaries as subsystem concepts.", + "Worked examples become artefact nodes (ART2, ART3) with example SysProM graphs as subsystems.", + "Naming rationale is already captured in D8.", + "Specification is already captured as the JSON itself — distilled/Specification.md is redundant." + ], + "id": "CHG12", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Internalise Design Archive", + "operations": [ + { + "target": "ART1", + "type": "add" + }, + { + "target": "ART2", + "type": "add" + }, + { + "target": "ART3", + "type": "add" + } + ], + "scope": [ + "ART1", + "ART2", + "ART3" + ], + "type": "change" + }, + { + "description": [ + "Subsystems that would produce a single file over 100 lines are now split into multi-document folders.", + "Subsystems of the same node type are automatically grouped into type-named directories (e.g. elements/, artefacts/).", + "Both heuristics are automatic — no user configuration needed." + ], + "id": "CHG13", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Add Size-Based Subsystem Splitting and Auto-Grouping", + "operations": [ + { + "target": "POL20", + "type": "add" + }, + { + "target": "DEC8", + "type": "add" + }, + { + "description": "json-to-md updated with line count threshold and auto-grouping", + "target": "REAL1", + "type": "update" + } + ], + "scope": [ + "POL20", + "DEC8", + "REAL1" + ], + "type": "change" + }, + { + "description": [ + "New speckit/ module provides bidirectional conversion between Spec-Kit project files and SysProM nodes.", + "Parser maps constitution.md → invariant/protocol nodes, spec.md → artefact/capability/invariant nodes, plan.md → artefact/element/gate nodes, tasks.md → stage/change nodes, checklist.md → gate nodes.", + "Generator reverses the mapping to produce valid Spec-Kit markdown from SysProM graph data.", + "CLI adds import, export, sync, and diff subcommands under sysprom speckit.", + "Tests cover all 5 parser functions (40 cases) and all 5 generator functions (28 cases), plus round-trip fidelity." + ], + "id": "CHG14", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Implement Spec-Kit File Support", + "operations": [ + { + "target": "src/speckit/parse.ts", + "type": "add" + }, + { + "target": "src/speckit/generate.ts", + "type": "add" + }, + { + "target": "src/speckit/project.ts", + "type": "add" + }, + { + "target": "src/speckit/index.ts", + "type": "add" + }, + { + "target": "src/cli/speckit.ts", + "type": "add" + }, + { + "target": "tests/speckit-parse.unit.test.ts", + "type": "add" + }, + { + "target": "tests/speckit-generate.unit.test.ts", + "type": "add" + }, + { + "description": "Register speckit command", + "target": "src/cli/index.ts", + "type": "update" + }, + { + "description": "Export speckit modules", + "target": "src/index.ts", + "type": "update" + }, + { + "description": "Expand description to reflect interoperability", + "target": "CMP-SPECKIT", + "type": "update" + } + ], + "scope": [ + "DEC16", + "ELEM3", + "CMP-SPECKIT" + ], + "type": "change" + }, + { + "description": [ + "New task command provides list, add, done, and undone subcommands for manipulating the plan array on change nodes.", + "Two new mutate helpers (addPlanTask, updatePlanTask) follow the immutable doc-in/doc-out pattern of the existing mutate module.", + "task list supports --pending and --json flags enabling agent scripting via jq.", + "AGENTS.md documents the complete subagent workflow: discover, claim, progress, complete." + ], + "id": "CHG15", + "lifecycle": { + "complete": true, + "defined": true, + "introduced": true + }, + "name": "Implement task CLI Command for Subagent Plan Tracking", + "operations": [ + { + "target": "src/cli/task.ts", + "type": "add" + }, + { + "target": "tests/task-cli.unit.test.ts", + "type": "add" + }, + { + "target": "AGENTS.md", + "type": "add" + }, + { + "description": "Add addPlanTask() and updatePlanTask()", + "target": "src/mutate.ts", + "type": "update" + }, + { + "description": "Register task command", + "target": "src/cli/index.ts", + "type": "update" + } + ], + "scope": [ + "DEC17", + "CHG14" + ], + "type": "change" + }, + { + "description": "New spm plan command with five subcommands: init (scaffold feature skeleton), add-task (add tasks with optional --parent for nesting), status (workflow completeness report), progress (per-task ASCII progress bars), and gate (phase readiness validation). Phases are change nodes in PROT-IMPL.subsystem; subtasks nest recursively via child subsystems. Includes isTaskDone and countTasks helpers for recursive completion tracking. Updated generateTasks and parseTasks to use change-only model.", + "id": "CHG16", + "lifecycle": { + "complete": true + }, + "name": "Implement Plan Command with Recursive Task Model", + "scope": [ + "[\"EL-CLI\",\"EL-SPECKIT\"]" + ], + "type": "change" + }, + { + "description": "Extended lifecycle schema to accept ISO date strings alongside booleans. Added temporal query functions: timeline (chronological events), nodeHistory (single node history), stateAt (system state at a point in time). Updated markdown rendering and parsing for date lifecycle values. Added timeline and state-at subcommands to spm query.", + "id": "CHG17", + "lifecycle": { + "complete": true + }, + "name": "Implement Temporal Support", + "type": "change" + }, + { + "description": "Replace manual process.argv parsing across all CLI command files with Commander.js declarative command definitions. Add a doc generation script that walks Commander's command tree to produce markdown files for TypeDoc's projectDocuments feature.", + "id": "CHG18", + "lifecycle": { + "complete": true, + "implemented": "2026-03-21", + "proposed": "2026-03-21" + }, + "name": "Migrate CLI to Commander.js", + "type": "change" + }, + { + "description": "Configure TypeDoc for markdown API docs (docs/api/), HTML site generation (site/), and auto-generated CLI reference (docs/cli/) from Commander.js metadata. Add @param/@returns JSDoc tags to all public functions. Use typedoc-plugin-zod to render Zod-inferred types cleanly.", + "id": "CHG19", + "lifecycle": { + "complete": true, + "implemented": "2026-03-21", + "proposed": "2026-03-21" + }, + "name": "Add TypeDoc Documentation Pipeline", + "operations": [ + { + "description": "Create typedoc.json and typedoc.html.json configuration files", + "type": "add" + }, + { + "description": "Create scripts/generate-cli-docs.ts for auto-generating CLI docs from Commander metadata", + "type": "add" + }, + { + "description": "Add @param and @returns JSDoc tags to all public functions", + "type": "update" + } + ], + "scope": [ + "ELEM3" + ], + "type": "change" + }, + { + "description": "Add turbo.json with task dependency graph for typecheck, compile, schema, test, and doc generation tasks. Restructure package.json scripts into atomic _-prefixed tasks orchestrated by turbo. Turbo manages output caching and directory cleaning.", + "id": "CHG20", + "lifecycle": { + "complete": true, + "implemented": "2026-03-21", + "proposed": "2026-03-21" + }, + "name": "Add Turborepo Build Orchestration", + "operations": [ + { + "description": "Create turbo.json with task dependency graph and input/output declarations", + "type": "add" + }, + { + "description": "Restructure package.json scripts into atomic tasks with turbo entry points", + "type": "update" + } + ], + "scope": [ + "ELEM3" + ], + "type": "change" + }, + { + "description": "Set up GitHub Actions CI workflow with quality checks, docs generation, GitHub Pages deployment, and npm publishing via OIDC trusted publishers. Add commitlint with husky hooks, semantic-release with all commit types triggering releases, and Dependabot for dependency updates.", + "id": "CHG21", + "lifecycle": { + "complete": true, + "implemented": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "Add CI/CD Pipeline", + "type": "change" + }, + { + "description": "Replace all as type coercions across library and CLI code with runtime validation. Use Zod .is() and .safeParse() for domain type narrowing, isRecord() for object checks, instanceof for error handling, and properly typed Commander action handlers.", + "id": "CHG22", + "lifecycle": { + "complete": true, + "implemented": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "Remove Type Assertions", + "type": "change" + }, + { + "description": "Update package.json entry points to reference compiled JavaScript in dist/. Move tsx from dependencies to devDependencies. Change CLI shebang to #!/usr/bin/env node.", + "id": "CHG23", + "lifecycle": { + "complete": true, + "implemented": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "Switch to Compiled Distribution", + "type": "change" + }, + { + "description": "Add nextId() function and NODE_ID_PREFIX map. Make --id optional on the add command — auto-generates from type prefix + next available number.", + "id": "CHG24", + "lifecycle": { + "complete": true, + "implemented": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "Add Auto-ID Generation to CLI", + "type": "change" + }, + { + "description": "Add auto-option IDs, spm init, --sync, coloured output, --json on mutations, spm search, spm graph, spm rename, spm check, shell completions, and --dry-run.", + "id": "CHG25", + "lifecycle": { + "complete": true, + "implemented": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "CLI UX Improvements", + "type": "change" + }, + { + "description": "Create defineCommand() with Zod schema introspection for Commander generation and doc extraction. Migrate all 16 CLI commands to single-file definitions in src/cli/commands/. Delete old run() files.", + "id": "CHG26", + "lifecycle": { + "complete": true, + "implemented": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "Implement defineCommand Pattern", + "scope": [ + "ELEM3" + ], + "type": "change" + }, + { + "description": "Create defineOperation infrastructure, define operations for all domain functions, refactor CLI commands to thin adapters, update exports.", + "id": "CHG27", + "lifecycle": { + "complete": true, + "implemented": "2026-03-22", + "proposed": "2026-03-22" + }, + "name": "Implement defineOperation Pattern", + "scope": [ + "ELEM3" + ], + "type": "change" + }, + { + "description": "Add a Claude Code plugin to the SysProM repository with skills, commands, hooks, and agents for provenance-aware development workflows. The plugin is pure markdown — no compiled code. Commands call spm if available, falling back to npx -y sysprom after npm publication. Distribution via GitHub marketplace (marketplace.json in .claude-plugin/).", + "id": "CHG28", + "lifecycle": { + "introduced": true + }, + "name": "Implement Claude Code Plugin", + "type": "change" + }, + { + "description": "Add a unified 'spm sync' command that performs bidirectional synchronisation between JSON and Markdown representations by default, with flags for precise conflict handling.", + "id": "CHG29", + "lifecycle": { + "complete": true + }, + "name": "Implement Bidirectional Sync Command", + "scope": [ + "DEC31" + ], + "type": "change" + }, + { + "description": "Add an MCP server at src/mcp/index.ts that wraps SysProM's programmatic API as MCP tools over stdio transport. Add sysprom-mcp bin entry to package.json. Add @modelcontextprotocol/sdk dependency. Add .mcp.json to the plugin referencing npx -y sysprom-mcp. Tools: validate, stats, query-nodes, query-node, query-relationships, trace, add-node, remove-node, update-node, add-relationship, remove-relationship, timeline, state-at.", + "id": "CHG30", + "lifecycle": { + "complete": true + }, + "name": "Implement MCP Server", + "type": "change" + }, + { + "id": "CHG31", + "lifecycle": { + "proposed": true + }, + "name": "Implement Keyed Provider Registry for External Formats", + "type": "change" + }, + { + "id": "CHG32", + "lifecycle": { + "complete": true + }, + "name": "Implement Safe Graph Removal", + "type": "change" + }, + { + "id": "CHG33", + "lifecycle": { + "complete": true + }, + "name": "Implement Graph Mutation Safety Guards", + "type": "change" + }, + { + "description": "Make input arg optional with priority-based auto-detection (.spm.json > .spm.md > .spm/ > glob). Rework init command to support optional path with context-dependent format and --format flag.", + "id": "CHG34", + "lifecycle": { + "complete": true + }, + "name": "Add default input resolution and init command", + "scope": [ + "src/cli/shared.ts", + "src/cli/commands/init.ts" + ], + "type": "change" + }, + { + "description": "Implement YAML serialisation (single-file and multi-document) and multi-file JSON support with 8 new CLI commands.", + "id": "CHG35", + "name": "Add YAML Support and Multi-File JSON Formats", + "type": "change" + }, + { + "description": "Move path/input/output positional arguments to --flags in init, json2md, md2json, sync, speckit (import/export/sync/diff), and plan init commands.", + "id": "CHG36", + "name": "Convert file-path positional args to flags", + "type": "change" + }, + { + "description": "Add saveDocument calls to all MCP write operations", + "id": "CHG37", + "name": "Fix MCP persistence bug", + "scope": [ + "src/mcp/server.ts" + ], + "type": "change" + }, + { + "description": "Check if path ends with correct suffix before appending", + "id": "CHG38", + "name": "Fix init path suffix doubling", + "scope": [ + "src/cli/commands/init.ts" + ], + "type": "change" + }, + { + "description": "Add four inference operations (impact, completeness, lifecycle, derived) exposed via CLI subcommands, MCP tools, and programmatic API. Pure graph traversal — no LLM dependency.", + "id": "CHG39", + "lifecycle": { + "introduced": true + }, + "name": "Implement deterministic graph inference", + "scope": [ + "src/operations/infer-impact.ts", + "src/operations/infer-completeness.ts", + "src/operations/infer-lifecycle.ts", + "src/operations/infer-derived.ts", + "src/cli/commands/infer.ts", + "src/mcp/server.ts" + ], + "type": "change" + }, + { + "description": "Add bidirectional BFS, polarity/strength on relationships, influence relationship type, full relationship classification, impactSummaryOp for hotspot analysis, and CLI/MCP surface changes.", + "id": "CHG40", + "lifecycle": { + "introduced": true + }, + "name": "Enhance impact analysis for bidirectional traversal and polarity", + "scope": [ + "src/schema.ts", + "src/operations/infer-impact.ts", + "src/cli/commands/infer.ts", + "src/mcp/server.ts", + "src/index.ts", + "tests/infer-impact.unit.test.ts" + ], + "type": "change" + }, + { + "description": "Update endpoint validation rules, tests, and README guidance so SysProM better supports modelling product-system specification, design, and implementation provenance.", + "id": "CHG41", + "lifecycle": { + "implemented": "2026-03-31", + "proposed": "2026-03-31" + }, + "name": "Implement system provenance profile and broaden endpoint modelling", + "scope": [ + "src/endpoint-types.ts", + "tests/validate.unit.test.ts", + "tests/safety-guards.unit.test.ts", + "README.md" + ], + "type": "change" + }, + { + "description": "Removed the legacy plan-array task model and top-level task command, introduced lifecycle task transitions under plan commands, and derived blockage from depends_on and constrained_by gate readiness.", + "id": "CHG42", + "lifecycle": { + "complete": true + }, + "name": "Implement Graph-Native Task Lifecycle and Blockage Tracking", + "scope": [ + "CON2-CHANGE", + "CAP4", + "PROT2" + ], + "type": "change" + }, + { + "id": "VIEW1", + "includes": [ + "INT1", + "CON1", + "CON2", + "CON3", + "CON4", + "CON5", + "CON6", + "CON7", + "CON8", + "CAP1", + "CAP2", + "CAP3", + "CAP4", + "CAP5", + "CAP6", + "CAP7", + "CAP8", + "CAP9", + "CAP10", + "CAP11", + "PRIN1", + "PRIN2", + "PRIN3", + "PRIN4", + "PRIN5", + "INV1", + "INV2", + "INV3", + "INV4", + "INV5", + "INV6", + "INV7", + "INV8", + "INV9", + "INV10", + "INV11", + "INV12", + "INV13", + "INV14", + "INV15", + "INV16", + "INV17", + "INV18", + "INV19", + "INV20", + "INV21", + "INV22", + "INV28", + "INV29", + "INV30", + "INV31", + "INV32", + "POL1", + "POL2", + "POL3", + "POL4", + "POL5", + "POL6", + "POL7", + "POL8", + "POL9", + "POL10", + "POL11", + "POL12", + "POL13", + "POL14", + "POL15", + "POL16", + "POL17", + "POL18", + "POL19", + "POL20", + "ELEM1", + "ELEM2", + "ELEM3", + "ELEM4", + "ELEM5", + "ELEM6", + "ELEM7", + "ELEM8", + "ELEM9", + "ELEM10", + "REAL1", + "REAL2", + "REAL3", + "REAL4", + "REAL5", + "ART1", + "ART2", + "ART3" + ], + "name": "Domain View", + "type": "view" + }, + { + "id": "VIEW2", + "includes": [ + "PROT1", + "PROT2", + "PROT3", + "STG1-DEC-PROPOSED", + "STG2-DEC-ACCEPTED", + "STG3-DEC-IMPLEMENTED", + "STG4-DEC-ADOPTED", + "STG5-DEC-SUPERSEDED", + "STG6-DEC-ABANDONED", + "STG7-DEC-DEFERRED", + "STG8-CHG-DEFINED", + "STG9-CHG-INTRODUCED", + "STG10-CHG-IN_PROGRESS", + "STG11-CHG-COMPLETE", + "STG12-CHG-CONSOLIDATED", + "STG13-NODE-PROPOSED", + "STG14-NODE-ACTIVE", + "STG15-NODE-DEPRECATED", + "STG16-NODE-RETIRED" + ], + "name": "Process View", + "type": "view" + }, + { + "id": "VIEW3", + "includes": [ + "DEC1", + "DEC2", + "DEC3", + "DEC4", + "DEC5", + "DEC6", + "DEC7", + "DEC8", + "DEC9", + "DEC10", + "DEC11", + "DEC12", + "DEC13", + "DEC14", + "DEC15", + "CHG1", + "CHG2", + "CHG3", + "CHG4", + "CHG5", + "CHG6", + "CHG7", + "CHG8", + "CHG9", + "CHG10", + "CHG11", + "CHG12", + "CHG13" + ], + "name": "Evolution View", + "type": "view" + }, + { + "context": "Several update and add commands had undocumented or missing CLI flags that users expected to be available, forcing them to edit JSON directly.", + "id": "DEC45", + "name": "CLI field coverage for update/add commands", + "options": [ + { + "description": "Systematically add all missing fields to CLI commands as they become needed", + "id": "OPT-A" + } + ], + "rationale": "Improves UX by closing gaps between schema capabilities and CLI exposure; reduces need for manual JSON editing", + "selected": "OPT-A", + "type": "decision" + }, + { + "context": "Incrementally resolving open GitHub issues by adding missing CLI flags.", + "id": "CHG43", + "name": "Add missing CLI flags for update/add commands", + "scope": [ + "CLI commands: update node, add realisation, add change" + ], + "type": "change" + }, + { + "context": "Issue #19 reported that users have no way to discover valid relationship endpoint types from help text. They only learn what is valid after attempting to add a relationship and getting an error.", + "id": "DEC46", + "name": "CLI: expose relationship endpoint type discovery", + "options": [ + { + "description": "Create a new query subcommand to list valid endpoint types for all relationship types", + "id": "OPT-A" + }, + { + "description": "Add endpoint type documentation directly to the add-rel help output", + "id": "OPT-B" + } + ], + "rationale": "A new query subcommand is more discoverable via help text, provides structured data output, and follows the CLI's existing pattern of query subcommands for different node and relationship types.", + "selected": "OPT-A", + "type": "decision" + }, + { + "id": "CHG44", + "name": "Implement query relationship-types command", + "scope": [ + "src/operations/query-relationship-types.ts,src/cli/commands/query.ts" + ], + "type": "change" + }, + { + "context": "Issue #24 reported that the validator requires all decisions to have a selected option, but some decisions are intentionally undecided (proposed, experimental, deferred states).", + "id": "DEC47", + "name": "Validator: allow intentionally undecided decisions", + "options": [ + { + "description": "Make selected option requirement lifecycle-aware — only require it for decided states (accepted, implemented, adopted)", + "id": "OPT-A" + }, + { + "description": "Add a new field to mark decisions as intentionally undecided", + "id": "OPT-B" + } + ], + "rationale": "Lifecycle-aware validation is simpler, requires no schema changes, and aligns with the semantic meaning of undecided lifecycle states.", + "selected": "OPT-A", + "type": "decision" + }, + { + "id": "CHG45", + "name": "Make decision validation lifecycle-aware", + "scope": [ + "src/operations/validate.ts,tests/validate.unit.test.ts" + ], + "type": "change" + }, + { + "context": "Issue #22 reported that users must edit JSON directly to manage external references. These references are critical for linking nodes to source documents, ADRs, standards, and code files.", + "id": "DEC48", + "name": "CLI: manage external references", + "options": [ + { + "description": "Add update add-ref and update remove-ref subcommands", + "id": "OPT-A" + }, + { + "description": "Create a new manage-refs command", + "id": "OPT-B" + } + ], + "rationale": "Adding subcommands follows the existing update command pattern, is simpler, and maintains consistency with other mutation operations.", + "selected": "OPT-A", + "type": "decision" + }, + { + "id": "CHG46", + "name": "Add external reference management commands", + "scope": [ + "src/operations/add-external-reference.ts,src/operations/remove-external-reference.ts,src/cli/commands/update.ts" + ], + "type": "change" + }, + { + "context": "Issues #23 and #25 reported missing node type support in relationship definitions. Milestones couldn't use depends_on, and roles couldn't use constrained_by or governed_by.", + "id": "DEC49", + "name": "Expand relationship endpoint type support", + "options": [ + { + "description": "Add milestone to depends_on sources, and role to constrained_by and governed_by sources", + "id": "OPT-A" + }, + { + "description": "Create a mapping configuration for endpoint types", + "id": "OPT-B" + } + ], + "rationale": "Direct schema updates are simple, maintainable, and follow the existing pattern of endpoint type validation.", + "selected": "OPT-A", + "type": "decision" + }, + { + "id": "CHG47", + "name": "Extend endpoint type support for relationships", + "scope": [ + "src/endpoint-types.ts" + ], + "type": "change" + }, + { + "context": "Issue #26 reported that milestones were isolated from the graph, only supporting precedes relationships with other milestones. This prevented meaningful connections to concepts, capabilities, intents, and other system elements.", + "id": "DEC50", + "name": "Milestone relationship type integration", + "options": [ + { + "description": "Extend milestone support across multiple relationship types", + "id": "OPT-A" + }, + { + "description": "Create a separate milestone-specific relationship type", + "id": "OPT-B" + } + ], + "rationale": "Extending existing relationship types is more consistent, leverages semantic meaning already defined, and provides maximum expressivity without introducing new abstractions.", + "selected": "OPT-A", + "type": "decision" + }, + { + "id": "CHG48", + "name": "Integrate milestones into full relationship type ecosystem", + "scope": [ + "src/endpoint-types.ts" + ], + "type": "change" + }, + { + "context": "Mermaid supports click directives that make diagram nodes into hyperlinks. SysProM generates diagrams in two contexts: embedded in Markdown and standalone. Users want clickable nodes to navigate documentation.", + "id": "DEC51", + "name": "Mermaid click directives for diagram hyperlinks", + "options": [ + { + "description": "Add click directives with two modes — anchor-based for embedded diagrams and external-reference-based for standalone", + "id": "OPT-A" + }, + { + "description": "Always link to external references only", + "id": "OPT-B" + } + ], + "rationale": "Anchor-based links provide the best UX for embedded diagrams since clicking navigates directly to the node definition. External reference links serve standalone use cases.", + "selected": "OPT-A", + "type": "decision" + }, + { + "id": "CHG49", + "name": "Implement Mermaid click directive support", + "scope": [ + "src/operations/graph-shared.ts,src/operations/graph.ts,src/json-to-md.ts,src/cli/commands/graph.ts,src/cli/commands/json2md.ts" + ], + "type": "change" + }, + { + "context": "We want a GitHub Pages-hosted web app for visualising SysProM documents. The single sysprom package cannot be imported by a browser app because the library modules call node:fs, node:path, and node:crypto directly (io.ts, sync.ts, the multi-doc conversions, three speckit ops), and the operations barrel re-exports syncDocumentsOp, dragging node:fs into every barrel import.", + "id": "DEC52", + "name": "Adopt pnpm workspace monorepo with a browser-safe core and a Vite viewer", + "options": [ + { + "description": "Convert to a pnpm workspace; extract a browser-safe @sysprom/core; keep filesystem access in @sysprom/node; preserve the sysprom package via a cli re-export; add an unpublished Vite viewer", + "id": "MONO" + }, + { + "description": "Keep sysprom as-is; build the viewer in a second repo that imports sysprom from npm", + "id": "SEPARATE" + }, + { + "description": "Add the viewer as a subpath of the current single package with node:fs polyfills for the browser", + "id": "SINGLE" + } + ], + "rationale": "A workspace lets the viewer consume core source directly via workspace links with no npm publish lag, and forces the filesystem isolation the library already needs so domain logic is isomorphic behind a storage boundary. A separate repo adds a publish lag for every core change the UI needs. A single-package browser entry needs node:fs and node:path polyfills rather than fixing the boundary. pnpm and Turbo are already in use, so the workspace conversion cost is low; all packages ship together, so unified versioning applies.", + "selected": "MONO", + "type": "decision" + }, + { + "id": "CHG50", + "lifecycle": { + "introduced": true + }, + "name": "Convert to monorepo; extract browser-safe core; add Vite viewer", + "scope": [ + "packages/core", + "packages/node", + "packages/cli", + "packages/mcp", + "packages/web", + "build", + "ci" + ], + "type": "change" + }, + { + "description": "The published sysprom package contract is preserved across the monorepo split: the sysprom, spm, and sysprom-mcp CLI binaries continue to install and run, and every name currently importable from sysprom remains exported with its existing signature. Existing Node consumers see no change.", + "id": "INV33", + "name": "Published Package Contract Stability", + "type": "invariant" + } + ], + "relationships": [ + { + "from": "CON1", + "to": "INT1", + "type": "refines" + }, + { + "from": "CON2", + "to": "INT1", + "type": "refines" + }, + { + "from": "CON3", + "to": "INT1", + "type": "refines" + }, + { + "from": "CON4", + "to": "INT1", + "type": "refines" + }, + { + "from": "CON5", + "to": "INT1", + "type": "refines" + }, + { + "from": "CON6", + "to": "INT1", + "type": "refines" + }, + { + "from": "CON7", + "to": "INT1", + "type": "refines" + }, + { + "from": "CON8", + "to": "INT1", + "type": "refines" + }, + { + "from": "CON9", + "to": "INT1", + "type": "refines" + }, + { + "from": "CAP1", + "to": "CON1", + "type": "refines" + }, + { + "from": "CAP2", + "to": "CON2", + "type": "refines" + }, + { + "from": "CAP3", + "to": "CON2", + "type": "refines" + }, + { + "from": "CAP4", + "to": "CON3", + "type": "refines" + }, + { + "from": "CAP5", + "to": "CON4", + "type": "refines" + }, + { + "from": "CAP6", + "to": "CON5", + "type": "refines" + }, + { + "from": "CAP7", + "to": "CON6", + "type": "refines" + }, + { + "from": "CAP8", + "to": "CON7", + "type": "refines" + }, + { + "from": "CAP9", + "to": "CON4", + "type": "refines" + }, + { + "from": "CAP10", + "to": "CON4", + "type": "refines" + }, + { + "from": "CAP11", + "to": "CON3", + "type": "refines" + }, + { + "from": "CAP12", + "to": "CON9", + "type": "refines" + }, + { + "from": "CAP12", + "to": "ART4", + "type": "produces" + }, + { + "from": "INV28", + "to": "CON8", + "type": "constrained_by" + }, + { + "from": "INV29", + "to": "CON8", + "type": "constrained_by" + }, + { + "from": "INV30", + "to": "CON8", + "type": "constrained_by" + }, + { + "from": "INV31", + "to": "CON8", + "type": "constrained_by" + }, + { + "from": "INV32", + "to": "CON8", + "type": "constrained_by" + }, + { + "from": "POL1", + "to": "INV5", + "type": "governed_by" + }, + { + "from": "POL2", + "to": "PRIN2", + "type": "governed_by" + }, + { + "from": "POL3", + "to": "INV3", + "type": "governed_by" + }, + { + "from": "POL4", + "to": "INV9", + "type": "governed_by" + }, + { + "from": "POL5", + "to": "INV9", + "type": "governed_by" + }, + { + "from": "POL6", + "to": "CON7", + "type": "governed_by" + }, + { + "from": "POL10", + "to": "ELEM8", + "type": "part_of" + }, + { + "from": "POL11", + "to": "ELEM8", + "type": "part_of" + }, + { + "from": "POL12", + "to": "ELEM8", + "type": "part_of" + }, + { + "from": "POL13", + "to": "ELEM8", + "type": "part_of" + }, + { + "from": "POL14", + "to": "ELEM8", + "type": "part_of" + }, + { + "from": "POL15", + "to": "ELEM8", + "type": "part_of" + }, + { + "from": "POL16", + "to": "ELEM8", + "type": "part_of" + }, + { + "from": "POL17", + "to": "ELEM8", + "type": "part_of" + }, + { + "from": "POL18", + "to": "ELEM8", + "type": "part_of" + }, + { + "from": "ELEM1", + "to": "CAP1", + "type": "realises" + }, + { + "from": "ELEM2", + "to": "CAP6", + "type": "realises" + }, + { + "from": "ELEM3", + "to": "CAP6", + "type": "realises" + }, + { + "from": "ELEM4", + "to": "CAP2", + "type": "realises" + }, + { + "from": "ELEM4", + "to": "CAP4", + "type": "realises" + }, + { + "from": "ELEM6", + "to": "CAP1", + "type": "realises" + }, + { + "from": "ELEM7", + "to": "CAP8", + "type": "realises" + }, + { + "from": "ELEM8", + "to": "CAP7", + "type": "realises" + }, + { + "from": "ELEM9", + "to": "CAP9", + "type": "realises" + }, + { + "from": "ELEM9", + "to": "CAP10", + "type": "realises" + }, + { + "from": "ELEM9", + "to": "CAP11", + "type": "realises" + }, + { + "from": "ELEM10", + "to": "CAP7", + "type": "realises" + }, + { + "from": "ELEM10", + "to": "INV18", + "type": "constrained_by" + }, + { + "from": "REAL1", + "to": "ELEM1", + "type": "implements" + }, + { + "from": "REAL1", + "to": "ELEM2", + "type": "implements" + }, + { + "from": "REAL1", + "to": "ELEM3", + "type": "implements" + }, + { + "from": "REAL1", + "to": "ELEM4", + "type": "implements" + }, + { + "from": "REAL2", + "to": "ELEM8", + "type": "implements" + }, + { + "from": "REAL3", + "to": "ELEM8", + "type": "implements" + }, + { + "from": "REAL4", + "to": "ELEM8", + "type": "implements" + }, + { + "from": "REAL5", + "to": "ELEM1", + "type": "implements" + }, + { + "from": "REAL5", + "to": "ELEM2", + "type": "implements" + }, + { + "from": "REAL5", + "to": "ELEM3", + "type": "implements" + }, + { + "from": "REAL5", + "to": "ELEM4", + "type": "implements" + }, + { + "from": "STG1-DEC-PROPOSED", + "to": "PROT1", + "type": "part_of" + }, + { + "from": "STG2-DEC-ACCEPTED", + "to": "PROT1", + "type": "part_of" + }, + { + "from": "STG2-DEC-ACCEPTED", + "to": "STG1-DEC-PROPOSED", + "type": "must_follow" + }, + { + "from": "STG3-DEC-IMPLEMENTED", + "to": "PROT1", + "type": "part_of" + }, + { + "from": "STG3-DEC-IMPLEMENTED", + "to": "STG2-DEC-ACCEPTED", + "type": "must_follow" + }, + { + "from": "STG4-DEC-ADOPTED", + "to": "PROT1", + "type": "part_of" + }, + { + "from": "STG4-DEC-ADOPTED", + "to": "STG3-DEC-IMPLEMENTED", + "type": "must_follow" + }, + { + "from": "STG5-DEC-SUPERSEDED", + "to": "PROT1", + "type": "part_of" + }, + { + "from": "STG5-DEC-SUPERSEDED", + "to": "STG4-DEC-ADOPTED", + "type": "precedes" + }, + { + "from": "STG6-DEC-ABANDONED", + "to": "PROT1", + "type": "part_of" + }, + { + "from": "STG6-DEC-ABANDONED", + "to": "STG2-DEC-ACCEPTED", + "type": "precedes" + }, + { + "from": "STG7-DEC-DEFERRED", + "to": "PROT1", + "type": "part_of" + }, + { + "from": "STG7-DEC-DEFERRED", + "to": "STG2-DEC-ACCEPTED", + "type": "precedes" + }, + { + "from": "STG8-CHG-DEFINED", + "to": "PROT2", + "type": "part_of" + }, + { + "from": "STG9-CHG-INTRODUCED", + "to": "PROT2", + "type": "part_of" + }, + { + "from": "STG9-CHG-INTRODUCED", + "to": "STG8-CHG-DEFINED", + "type": "must_follow" + }, + { + "from": "STG10-CHG-IN_PROGRESS", + "to": "PROT2", + "type": "part_of" + }, + { + "from": "STG10-CHG-IN_PROGRESS", + "to": "STG9-CHG-INTRODUCED", + "type": "must_follow" + }, + { + "from": "STG11-CHG-COMPLETE", + "to": "PROT2", + "type": "part_of" + }, + { + "from": "STG11-CHG-COMPLETE", + "to": "STG10-CHG-IN_PROGRESS", + "type": "must_follow" + }, + { + "from": "STG12-CHG-CONSOLIDATED", + "to": "PROT2", + "type": "part_of" + }, + { + "from": "STG12-CHG-CONSOLIDATED", + "to": "STG11-CHG-COMPLETE", + "type": "must_follow" + }, + { + "from": "STG13-NODE-PROPOSED", + "to": "PROT3", + "type": "part_of" + }, + { + "from": "STG14-NODE-ACTIVE", + "to": "PROT3", + "type": "part_of" + }, + { + "from": "STG14-NODE-ACTIVE", + "to": "STG13-NODE-PROPOSED", + "type": "must_follow" + }, + { + "from": "STG15-NODE-DEPRECATED", + "to": "PROT3", + "type": "part_of" + }, + { + "from": "STG15-NODE-DEPRECATED", + "to": "STG14-NODE-ACTIVE", + "type": "must_follow" + }, + { + "from": "STG16-NODE-RETIRED", + "to": "PROT3", + "type": "part_of" + }, + { + "from": "STG16-NODE-RETIRED", + "to": "STG15-NODE-DEPRECATED", + "type": "must_follow" + }, + { + "from": "ART1", + "to": "DEC4", + "type": "affects" + }, + { + "from": "DEC1", + "to": "ELEM1", + "type": "affects" + }, + { + "from": "DEC1", + "to": "ELEM2", + "type": "affects" + }, + { + "from": "DEC1", + "to": "ELEM4", + "type": "affects" + }, + { + "from": "DEC1", + "to": "INV1", + "type": "must_preserve" + }, + { + "from": "DEC2", + "to": "ELEM4", + "type": "affects" + }, + { + "from": "DEC2", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC2", + "to": "INV3", + "type": "must_preserve" + }, + { + "from": "DEC3", + "to": "INV1", + "type": "affects" + }, + { + "from": "DEC3", + "to": "PRIN1", + "type": "affects" + }, + { + "from": "DEC3", + "to": "POL1", + "type": "affects" + }, + { + "from": "DEC3", + "to": "INV3", + "type": "must_preserve" + }, + { + "from": "DEC4", + "to": "ELEM2", + "type": "affects" + }, + { + "from": "DEC4", + "to": "ELEM3", + "type": "affects" + }, + { + "from": "DEC4", + "to": "INV4", + "type": "must_preserve" + }, + { + "from": "DEC5", + "to": "REAL1", + "type": "affects" + }, + { + "from": "DEC5", + "to": "INV4", + "type": "must_preserve" + }, + { + "from": "DEC6", + "to": "REAL4", + "type": "affects" + }, + { + "from": "DEC6", + "to": "INV4", + "type": "must_preserve" + }, + { + "from": "DEC7", + "to": "INV5", + "type": "must_preserve" + }, + { + "from": "DEC8", + "to": "ELEM7", + "type": "affects" + }, + { + "from": "DEC8", + "to": "REAL5", + "type": "affects" + }, + { + "from": "DEC8", + "to": "REAL1", + "type": "affects" + }, + { + "from": "DEC8", + "to": "INV3", + "type": "affects" + }, + { + "from": "DEC8", + "to": "ART1", + "type": "affects" + }, + { + "from": "DEC8", + "to": "ART2", + "type": "affects" + }, + { + "from": "DEC8", + "to": "ART3", + "type": "affects" + }, + { + "from": "DEC8", + "to": "INV19", + "type": "must_preserve" + }, + { + "from": "DEC8", + "to": "INV20", + "type": "must_preserve" + }, + { + "from": "DEC8", + "to": "INV21", + "type": "must_preserve" + }, + { + "from": "DEC8", + "to": "INV22", + "type": "must_preserve" + }, + { + "from": "DEC8", + "to": "INV18", + "type": "must_preserve" + }, + { + "from": "DEC8", + "to": "POL19", + "type": "must_preserve" + }, + { + "from": "DEC8", + "to": "INV3", + "type": "must_preserve" + }, + { + "from": "DEC8", + "to": "POL20", + "type": "must_preserve" + }, + { + "from": "DEC9", + "to": "REAL5", + "type": "affects" + }, + { + "from": "DEC9", + "to": "REAL1", + "type": "affects" + }, + { + "from": "DEC9", + "to": "INV21", + "type": "must_preserve" + }, + { + "from": "DEC10", + "to": "REAL5", + "type": "affects" + }, + { + "from": "DEC10", + "to": "INV22", + "type": "must_preserve" + }, + { + "from": "DEC10", + "to": "INV18", + "type": "must_preserve" + }, + { + "from": "DEC11", + "to": "REAL1", + "type": "affects" + }, + { + "from": "DEC11", + "to": "POL19", + "type": "must_preserve" + }, + { + "from": "DEC12", + "to": "REAL1", + "type": "affects" + }, + { + "from": "DEC13", + "to": "INV3", + "type": "affects" + }, + { + "from": "DEC13", + "to": "INV3", + "type": "must_preserve" + }, + { + "from": "DEC14", + "to": "ART1", + "type": "affects" + }, + { + "from": "DEC14", + "to": "ART2", + "type": "affects" + }, + { + "from": "DEC14", + "to": "ART3", + "type": "affects" + }, + { + "from": "DEC15", + "to": "REAL1", + "type": "affects" + }, + { + "from": "DEC15", + "to": "POL20", + "type": "must_preserve" + }, + { + "from": "DEC16", + "to": "ELEM3", + "type": "affects" + }, + { + "from": "DEC16", + "to": "INV4", + "type": "must_preserve" + }, + { + "from": "DEC16", + "to": "INV5", + "type": "must_preserve" + }, + { + "from": "DEC16", + "to": "INV6", + "type": "must_preserve" + }, + { + "from": "DEC16", + "to": "INV21", + "type": "must_preserve" + }, + { + "from": "DEC16", + "to": "INV22", + "type": "must_preserve" + }, + { + "from": "DEC17", + "to": "CHG14", + "type": "affects" + }, + { + "from": "DEC18", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC19", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC20", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC21", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC22", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC23", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC24", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC25", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC26", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC27", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC28", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC29", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC33", + "to": "CHG14", + "type": "supersedes" + }, + { + "from": "DEC33", + "to": "CON6", + "type": "must_preserve" + }, + { + "from": "DEC34", + "to": "INV23", + "type": "must_preserve" + }, + { + "from": "DEC35", + "to": "INV24", + "type": "must_preserve" + }, + { + "from": "DEC35", + "to": "INV25", + "type": "must_preserve" + }, + { + "from": "DEC35", + "to": "INV26", + "type": "must_preserve" + }, + { + "from": "DEC41", + "to": "INV1", + "type": "must_preserve" + }, + { + "from": "DEC41", + "to": "INV2", + "type": "must_preserve" + }, + { + "from": "DEC43", + "to": "INV25", + "type": "affects" + }, + { + "from": "DEC43", + "to": "CAP12", + "type": "affects" + }, + { + "from": "DEC43", + "to": "INV18", + "type": "must_preserve" + }, + { + "from": "CHG1", + "to": "DEC1", + "type": "affects" + }, + { + "from": "CHG1", + "to": "DEC2", + "type": "affects" + }, + { + "from": "CHG1", + "to": "DEC7", + "type": "affects" + }, + { + "from": "CHG2", + "to": "DEC3", + "type": "affects" + }, + { + "from": "CHG2", + "to": "DEC4", + "type": "affects" + }, + { + "from": "CHG3", + "to": "DEC5", + "type": "affects" + }, + { + "from": "CHG3", + "to": "DEC6", + "type": "affects" + }, + { + "from": "CHG4", + "to": "DEC8", + "type": "affects" + }, + { + "from": "CHG4", + "to": "DEC4", + "type": "affects" + }, + { + "from": "CHG4", + "to": "DEC2", + "type": "affects" + }, + { + "from": "CHG5", + "to": "DEC4", + "type": "affects" + }, + { + "from": "CHG6", + "to": "DEC2", + "type": "affects" + }, + { + "from": "CHG7", + "to": "DEC9", + "type": "affects" + }, + { + "from": "CHG8", + "to": "DEC10", + "type": "affects" + }, + { + "from": "CHG9", + "to": "DEC11", + "type": "affects" + }, + { + "from": "CHG10", + "to": "DEC12", + "type": "affects" + }, + { + "from": "CHG11", + "to": "DEC13", + "type": "affects" + }, + { + "from": "CHG12", + "to": "DEC14", + "type": "affects" + }, + { + "from": "CHG13", + "to": "DEC15", + "type": "affects" + }, + { + "from": "CHG14", + "to": "DEC16", + "type": "affects" + }, + { + "from": "CHG15", + "to": "DEC17", + "type": "affects" + }, + { + "from": "CHG16", + "to": "DEC18", + "type": "implements" + }, + { + "from": "CHG17", + "to": "DEC19", + "type": "implements" + }, + { + "from": "CHG18", + "to": "DEC20", + "type": "implements" + }, + { + "from": "CHG19", + "to": "DEC21", + "type": "implements" + }, + { + "from": "CHG20", + "to": "DEC22", + "type": "implements" + }, + { + "from": "CHG21", + "to": "DEC23", + "type": "implements" + }, + { + "from": "CHG22", + "to": "DEC24", + "type": "implements" + }, + { + "from": "CHG23", + "to": "DEC25", + "type": "implements" + }, + { + "from": "CHG24", + "to": "DEC26", + "type": "implements" + }, + { + "from": "CHG25", + "to": "DEC27", + "type": "implements" + }, + { + "from": "CHG26", + "to": "DEC28", + "type": "implements" + }, + { + "from": "CHG27", + "to": "DEC29", + "type": "implements" + }, + { + "from": "CHG28", + "to": "DEC30", + "type": "implements" + }, + { + "from": "CHG29", + "to": "DEC31", + "type": "implements" + }, + { + "from": "CHG30", + "to": "DEC32", + "type": "implements" + }, + { + "from": "CHG31", + "to": "DEC33", + "type": "implements" + }, + { + "from": "CHG32", + "to": "DEC34", + "type": "implements" + }, + { + "from": "CHG33", + "to": "DEC35", + "type": "implements" + }, + { + "from": "CHG33", + "to": "CHG32", + "type": "depends_on" + }, + { + "from": "CHG34", + "to": "DEC36", + "type": "implements" + }, + { + "from": "CHG35", + "to": "DEC37", + "type": "implements" + }, + { + "from": "CHG36", + "to": "DEC38", + "type": "implements" + }, + { + "from": "CHG37", + "to": "DEC39", + "type": "implements" + }, + { + "from": "CHG38", + "to": "DEC40", + "type": "implements" + }, + { + "from": "CHG39", + "to": "DEC41", + "type": "implements" + }, + { + "from": "CHG40", + "to": "DEC42", + "type": "implements" + }, + { + "from": "CHG41", + "to": "DEC43", + "type": "implements" + }, + { + "from": "CHG41", + "to": "CAP12", + "type": "modifies" + }, + { + "from": "CHG41", + "to": "ART4", + "type": "modifies" + }, + { + "from": "CHG42", + "to": "DEC44", + "type": "implements" + }, + { + "from": "CHG43", + "to": "DEC45", + "type": "implements" + }, + { + "from": "CHG44", + "to": "DEC46", + "type": "implements" + }, + { + "from": "CHG45", + "to": "DEC47", + "type": "implements" + }, + { + "from": "CHG46", + "to": "DEC48", + "type": "implements" + }, + { + "from": "CHG47", + "to": "DEC49", + "type": "implements" + }, + { + "from": "CHG48", + "to": "DEC50", + "type": "implements" + }, + { + "from": "CHG49", + "to": "DEC51", + "type": "implements" + }, + { + "from": "CHG50", + "to": "DEC52", + "type": "implements" + }, + { + "from": "DEC52", + "to": "INV33", + "type": "must_preserve" + } + ] +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx new file mode 100644 index 0000000..c4ef5ac --- /dev/null +++ b/packages/web/src/App.tsx @@ -0,0 +1,237 @@ +import React, { useCallback, useRef, useState } from "react"; +import * as Tabs from "@radix-ui/react-tabs"; +import { type SysProMDocument as SysProMDocumentType } from "@sysprom/core"; +import { + loadJsonFile, + loadMarkdownFile, + loadMultiDoc, + readFileText, + type LoadResult, +} from "./load"; +import { StatsTab } from "./tabs/StatsTab"; +import { NodesTab } from "./tabs/NodesTab"; +import { RelationshipsTab } from "./tabs/RelationshipsTab"; +import { GraphsTab } from "./tabs/GraphsTab"; +import { TraceTab } from "./tabs/TraceTab"; +import { + appShell, + header, + title, + controls, + button, + primaryButton, + dropZone, + errorBox, + tabsRoot, + tabsList, + tabsTrigger, + muted, +} from "./styles.css"; + +const SAMPLE_PATH = "/sample.SysProM.json"; + +export function App(): React.ReactElement { + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [dragging, setDragging] = useState(false); + const fileInputRef = useRef(null); + + const handleFiles = useCallback(async (files: FileList | File[]) => { + setError(null); + const fileArray = Array.from(files); + if (fileArray.length === 0) return; + + try { + if (fileArray.length === 1) { + const file = fileArray[0]; + const text = await readFileText(file); + if (file.name.endsWith(".json")) { + setResult(loadJsonFile(text, file.name)); + } else if (file.name.endsWith(".md")) { + setResult(loadMarkdownFile(text, file.name)); + } else { + setError(`Unrecognised file type: ${file.name}`); + } + } else { + // Multiple .md files → multi-doc parse + const allMd = fileArray.every((f) => f.name.endsWith(".md")); + if (!allMd) { + setError( + "When loading multiple files, all must be .md (multi-doc format).", + ); + return; + } + const entries = await Promise.all( + fileArray.map(async (f) => [f.name, await readFileText(f)] as const), + ); + const fileMap = Object.fromEntries(entries); + setResult(loadMultiDoc(fileMap, `${String(fileArray.length)} files`)); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setResult(null); + } + }, []); + + const loadSample = useCallback(async () => { + setError(null); + try { + const res = await fetch(SAMPLE_PATH); + if (!res.ok) { + throw new Error(`Failed to fetch sample: HTTP ${String(res.status)}`); + } + const text = await res.text(); + setResult(loadJsonFile(text, "sample.SysProM.json")); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, []); + + const exportDoc = useCallback(() => { + if (!result) return; + const json = canonicalJson(result.doc); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "export.SysProM.json"; + a.click(); + URL.revokeObjectURL(url); + }, [result]); + + const onDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + if (e.dataTransfer.files.length > 0) { + void handleFiles(e.dataTransfer.files); + } + }, + [handleFiles], + ); + + const doc: SysProMDocumentType | null = result?.doc ?? null; + + return ( +
+
+

SysProM Viewer

+
+ + + {result && ( + + )} + { + if (e.target.files) void handleFiles(e.target.files); + e.target.value = ""; + }} + /> +
+
+ + {!doc && ( +
{ + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => { + setDragging(false); + }} + onDrop={onDrop} + > +

+ Drop a .SysProM.json or .SysProM.md file + here, or use the controls above. +

+

+ For multi-document folders, drop multiple .md files at + once. +

+
+ )} + + {error &&
{error}
} + + {doc && result && ( + <> + {result.validation.issues.length > 0 && ( +
+ + Validation issues ({result.validation.issues.length}): + + {"\n"} + {result.validation.issues.join("\n")} +
+ )} + + + + Stats + + + Nodes + + + Relationships + + + Graphs + + + Trace + + + + + + + + + + + + + + + + + + + + )} +
+ ); +} + +/** Deterministic JSON serialisation for export (2-space indent, sorted keys). */ +function canonicalJson(doc: SysProMDocumentType): string { + return JSON.stringify(doc, null, 2); +} diff --git a/packages/web/src/load.ts b/packages/web/src/load.ts new file mode 100644 index 0000000..52b000f --- /dev/null +++ b/packages/web/src/load.ts @@ -0,0 +1,52 @@ +import { + SysProMDocument, + markdownSingleToJson, + parseMultiDoc, + validateOp, + type ValidationResult, + type SysProMDocument as SysProMDocumentType, +} from "@sysprom/core"; + +export interface LoadResult { + doc: SysProMDocumentType; + validation: ValidationResult; + source: string; +} + +/** Parse and validate a single `.SysProM.json` file. */ +export function loadJsonFile(content: string, filename: string): LoadResult { + const raw: unknown = JSON.parse(content); + const doc = SysProMDocument.parse(raw); + return finalise(doc, filename); +} + +/** Parse and validate a single `.SysProM.md` file. */ +export function loadMarkdownFile( + content: string, + filename: string, +): LoadResult { + const doc = markdownSingleToJson(content); + return finalise(doc, filename); +} + +/** Parse and validate multiple `.SysProM.md` files (keyed by filename). */ +export function loadMultiDoc( + files: Record, + label: string, +): LoadResult { + const doc = parseMultiDoc(files); + return finalise(doc, label); +} + +function finalise(doc: SysProMDocumentType, source: string): LoadResult { + const validation = validateOp({ doc }); + return { doc, validation, source }; +} + +/** + * Read a File object in the browser. Returns its text content. + * Uses the File.text() method available in all modern browsers. + */ +export function readFileText(file: File): Promise { + return file.text(); +} From c5a770ab5e56de2903a671717a62f5e588c1ae8d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 17:03:19 +0100 Subject: [PATCH 09/28] feat(web): render stats, nodes, relationships panels Stats tab shows node/relationship counts, subsystem depth, views, and external references from statsOp. Nodes tab uses queryNodesOp with type and text filters. Relationships tab uses queryRelationshipsOp with type/from/to filters. --- packages/web/src/tabs/NodesTab.tsx | 108 +++++++++++++++++++++ packages/web/src/tabs/RelationshipsTab.tsx | 93 ++++++++++++++++++ packages/web/src/tabs/StatsTab.tsx | 86 ++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 packages/web/src/tabs/NodesTab.tsx create mode 100644 packages/web/src/tabs/RelationshipsTab.tsx create mode 100644 packages/web/src/tabs/StatsTab.tsx diff --git a/packages/web/src/tabs/NodesTab.tsx b/packages/web/src/tabs/NodesTab.tsx new file mode 100644 index 0000000..d2b9ae7 --- /dev/null +++ b/packages/web/src/tabs/NodesTab.tsx @@ -0,0 +1,108 @@ +import React, { useMemo, useState } from "react"; +import { + queryNodesOp, + NODE_LABEL_TO_TYPE, + type SysProMDocument, +} from "@sysprom/core"; +import { + table, + filterRow, + select, + input, + badge, + monospace, +} from "../styles.css"; + +const TYPE_OPTIONS = Object.keys(NODE_LABEL_TO_TYPE); + +export function NodesTab({ + doc, +}: { + readonly doc: SysProMDocument; +}): React.ReactElement { + const [typeFilter, setTypeFilter] = useState(""); + const [textFilter, setTextFilter] = useState(""); + + const nodes = useMemo(() => { + const result = queryNodesOp({ + doc, + ...(typeFilter ? { type: typeFilter } : {}), + }); + if (textFilter.trim() === "") return result; + const needle = textFilter.toLowerCase(); + return result.filter( + (n) => + n.id.toLowerCase().includes(needle) || + n.name.toLowerCase().includes(needle), + ); + }, [doc, typeFilter, textFilter]); + + return ( +
+
+ + { + setTextFilter(e.target.value); + }} + /> + + {nodes.length} node{nodes.length === 1 ? "" : "s"} + +
+ + + + + + + + + + + {nodes.map((n) => { + const status = n.lifecycle + ? Object.entries(n.lifecycle) + .filter(([, v]) => v === true || typeof v === "string") + .map(([k]) => k) + .join(", ") + : "—"; + return ( + + + + + + + ); + })} + +
IDTypeNameStatus
{n.id} + {n.type} + {n.name} + {status} +
+
+ ); +} diff --git a/packages/web/src/tabs/RelationshipsTab.tsx b/packages/web/src/tabs/RelationshipsTab.tsx new file mode 100644 index 0000000..ca9cf01 --- /dev/null +++ b/packages/web/src/tabs/RelationshipsTab.tsx @@ -0,0 +1,93 @@ +import React, { useMemo, useState } from "react"; +import { + queryRelationshipsOp, + RELATIONSHIP_LABEL_TO_TYPE, + type SysProMDocument, +} from "@sysprom/core"; +import { table, filterRow, select, monospace, badge } from "../styles.css"; + +const REL_LABELS = Object.keys(RELATIONSHIP_LABEL_TO_TYPE); + +export function RelationshipsTab({ + doc, +}: { + readonly doc: SysProMDocument; +}): React.ReactElement { + const [typeFilter, setTypeFilter] = useState(""); + const [fromFilter, setFromFilter] = useState(""); + const [toFilter, setToFilter] = useState(""); + + const rels = useMemo(() => { + return queryRelationshipsOp({ + doc, + ...(typeFilter ? { type: typeFilter } : {}), + ...(fromFilter ? { from: fromFilter } : {}), + ...(toFilter ? { to: toFilter } : {}), + }); + }, [doc, typeFilter, fromFilter, toFilter]); + + return ( +
+
+ + { + setFromFilter(e.target.value); + }} + /> + { + setToFilter(e.target.value); + }} + /> + + {rels.length} relationship{rels.length === 1 ? "" : "s"} + +
+ + + + + + + + + + {rels.map((r, i) => ( + + + + + + ))} + +
FromTypeTo
{r.from} + {r.type} + {r.to}
+
+ ); +} diff --git a/packages/web/src/tabs/StatsTab.tsx b/packages/web/src/tabs/StatsTab.tsx new file mode 100644 index 0000000..0714db6 --- /dev/null +++ b/packages/web/src/tabs/StatsTab.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { statsOp, type SysProMDocument } from "@sysprom/core"; +import { + theme, + statGrid, + statCard, + statLabel, + statValue, + statSub, + sectionTitle, + badge, +} from "../styles.css"; + +export function StatsTab({ + doc, +}: { + readonly doc: SysProMDocument; +}): React.ReactElement { + const stats = statsOp({ doc }); + + const typeEntries = Object.entries(stats.nodesByType).sort( + (a, b) => b[1] - a[1], + ); + const relEntries = Object.entries(stats.relationshipsByType).sort( + (a, b) => b[1] - a[1], + ); + + return ( +
+
+
+
Title
+
+ {stats.title} +
+
+
+
Nodes
+
{stats.totalNodes}
+
+
+
Relationships
+
{stats.totalRelationships}
+
+
+
Subsystems
+
{stats.subsystemCount}
+
max depth {stats.maxSubsystemDepth}
+
+
+
Views
+
{stats.viewCount}
+
+
+
External References
+
{stats.externalReferenceCount}
+
+
+ +
Nodes by type
+
+ {typeEntries.map(([type, count]) => ( +
+
{type}
+
{count}
+
+ ))} +
+ + {relEntries.length > 0 && ( + <> +
Relationships by type
+
+ {relEntries.map(([type, count]) => ( + + {type}: {count} + + ))} +
+ + )} +
+ ); +} From bd2c30b78edc22300aa24639b8539c02b9a67816 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 17:03:30 +0100 Subject: [PATCH 10/28] feat(web): render Mermaid graphs and trace Graphs tab renders the four diagram kinds (relationship, refinement, decision, dependency) from graphOp output via mermaid.render. Includes a diagram selector and friendly/compact label toggle. Per-diagram layout defaults match the CLI (TD for relationship/refinement/decision, LR for dependency). Trace tab uses traceFromNodeOp to render the refinement tree from a selected node. --- packages/web/src/tabs/GraphsTab.tsx | 177 ++++++++++++++++++++++++++++ packages/web/src/tabs/TraceTab.tsx | 90 ++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 packages/web/src/tabs/GraphsTab.tsx create mode 100644 packages/web/src/tabs/TraceTab.tsx diff --git a/packages/web/src/tabs/GraphsTab.tsx b/packages/web/src/tabs/GraphsTab.tsx new file mode 100644 index 0000000..13eaecf --- /dev/null +++ b/packages/web/src/tabs/GraphsTab.tsx @@ -0,0 +1,177 @@ +import React, { useEffect, useRef, useState } from "react"; +import mermaid from "mermaid"; +import { graphOp, type SysProMDocument } from "@sysprom/core"; +import { filterRow, select, graphContainer, button } from "../styles.css"; + +mermaid.initialize({ + startOnLoad: false, + theme: "default", + securityLevel: "loose", + flowchart: { useMaxWidth: true }, +}); + +type DiagramKind = "relationship" | "refinement" | "decision" | "dependency"; + +const DIAGRAM_LABELS: Record = { + relationship: "Relationship Graph", + refinement: "Refinement Chain", + decision: "Decision Map", + dependency: "Dependency Graph", +}; + +// Per-diagram layout defaults matching the CLI json2md behaviour. +const DIAGRAM_LAYOUT: Record = { + relationship: "TD", + refinement: "TD", + decision: "TD", + dependency: "LR", +}; + +interface GraphKindConfig { + typeFilter?: string; + relTypes?: string[]; +} + +const DIAGRAM_KINDS = [ + "relationship", + "refinement", + "decision", + "dependency", +] as const; + +// The four diagram kinds the CLI produces. Each uses a different +// relationship-type filter so the generated graph is focused. +const KIND_CONFIG: Record = { + relationship: {}, + refinement: { relTypes: ["refines"] }, + decision: { + relTypes: ["affects", "must_preserve", "supersedes"], + }, + dependency: { relTypes: ["depends_on", "part_of"] }, +}; + +function isDiagramKind(value: string): value is DiagramKind { + return value in DIAGRAM_LABELS; +} + +function isLabelMode(value: string): value is "friendly" | "compact" { + return value === "friendly" || value === "compact"; +} + +let renderCounter = 0; + +export function GraphsTab({ + doc, +}: { + readonly doc: SysProMDocument; +}): React.ReactElement { + const [kind, setKind] = useState("relationship"); + const [labelMode, setLabelMode] = useState<"friendly" | "compact">( + "friendly", + ); + const containerRef = useRef(null); + const [error, setError] = useState(null); + + const diagram = React.useMemo(() => { + const config = KIND_CONFIG[kind]; + return graphOp({ + doc, + format: "mermaid", + layout: DIAGRAM_LAYOUT[kind], + cluster: true, + labelMode, + ...(config.relTypes ? { relTypes: config.relTypes } : {}), + ...(config.typeFilter ? { typeFilter: config.typeFilter } : {}), + }); + }, [doc, kind, labelMode]); + + useEffect(() => { + let cancelled = false; + const render = async (): Promise => { + const container = containerRef.current; + if (!container) return; + setError(null); + container.innerHTML = ""; + const id = `mmd-${String(renderCounter++)}`; + try { + const { svg } = await mermaid.render(id, diagram); + if (cancelled) return; + container.innerHTML = svg; + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + } + }; + void render(); + return () => { + cancelled = true; + }; + }, [diagram]); + + return ( +
+
+ + +
+ {error && ( +
+ {error} +
+ )} +
+
+ + Show Mermaid source + +
+					{diagram}
+				
+
+
+ ); +} diff --git a/packages/web/src/tabs/TraceTab.tsx b/packages/web/src/tabs/TraceTab.tsx new file mode 100644 index 0000000..5382350 --- /dev/null +++ b/packages/web/src/tabs/TraceTab.tsx @@ -0,0 +1,90 @@ +import React, { useMemo, useState } from "react"; +import { + traceFromNodeOp, + type SysProMDocument, + type TraceNode, +} from "@sysprom/core"; +import { + filterRow, + select, + traceTree, + monospace, + badge, + muted, +} from "../styles.css"; + +export function TraceTab({ + doc, +}: { + readonly doc: SysProMDocument; +}): React.ReactElement { + const nodeIds = useMemo( + () => doc.nodes.map((n) => n.id).sort((a, b) => a.localeCompare(b)), + [doc], + ); + const [selected, setSelected] = useState(nodeIds[0] ?? ""); + + const trace = useMemo(() => { + if (!selected) return null; + return traceFromNodeOp({ doc, startId: selected }); + }, [doc, selected]); + + return ( +
+
+ +
+ {trace && ( +
+ +
+ )} + {trace?.children.length === 0 && ( +

+ No refinement chain from this node (no refines/realises/implements + relationships point to it). +

+ )} +
+ ); +} + +function TraceBranch({ + node, + depth, +}: { + readonly node: TraceNode; + readonly depth: number; +}): React.ReactElement | null { + const indent = " ".repeat(depth); + const label = node.node ? node.node.name : "(unknown)"; + const type = node.node?.type; + return ( +
+
+ {indent} + {node.id}{" "} + {type && {type}} {label} +
+ {node.children.map((child, i) => ( + + ))} +
+ ); +} From 299a9de8846df6bda4464b19d4275c9f757135bb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 17:15:06 +0100 Subject: [PATCH 11/28] ci(pages): deploy the web viewer at root with TypeDoc docs at /docs The site job builds the web app (via turbo, which builds @sysprom/core first), generates the TypeDoc HTML, places it under packages/web/dist/docs, and uploads packages/web/dist as the Pages artifact. The viewer is served at the site root and the API docs at /docs; deploy runs on push to main. --- .github/workflows/ci.yml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c62572e..672cf55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,8 +33,8 @@ jobs: turbo-quality-${{ runner.os }}- - run: npx turbo run _typecheck _test - docs: - name: Docs + site: + name: Build site runs-on: ubuntu-latest needs: quality steps: @@ -47,19 +47,25 @@ jobs: - uses: actions/cache@v5 with: path: .turbo - key: turbo-docs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} + key: turbo-site-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} restore-keys: | - turbo-docs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- - turbo-docs-${{ runner.os }}- + turbo-site-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- + turbo-site-${{ runner.os }}- + # Web app at the site root (builds @sysprom/core first via the workspace dep) + - run: npx turbo run build --filter=web + # TypeDoc HTML API docs -> site/ - run: npx turbo run _docs:cli _docs:api:html + # Serve docs under /docs alongside the viewer at / + - name: Place docs under the web build + run: cp -r site packages/web/dist/docs - uses: actions/upload-pages-artifact@v4 with: - path: site/ + path: packages/web/dist pages: name: Deploy Pages if: github.ref == 'refs/heads/main' && github.event_name == 'push' - needs: docs + needs: site runs-on: ubuntu-latest environment: name: github-pages From 213fa452e45bf89f429c490c234d472fa5ef4a62 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 18:12:49 +0100 Subject: [PATCH 12/28] fix(core): declare SysProMDocument and Node as named recursive types to stop declaration elision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsc cannot serialise Zod's anonymous inferred mutually-recursive types into .d.ts files, so it emitted `/*elided*/ any` for the `subsystem` and `nodes` fields of the recursive schemas. Downstream packages inherited the elision, losing type safety on the two most central types. Declare `SysProMDocument` and `Node` as explicit named recursive interfaces (the public type contract) and annotate the recursive schema consts as `z.ZodType` so every downstream use — the `.is()` guard, `z.array(...)`, `.optional()`, `defineSchema(...)` — resolves to the clean named interface instead of the unserialisable anonymous type. The raw schema expressions (`_rawSysProMDocumentSchema`, `NodeBase`) are left unannotated so their true inferred output can be captured for a compile-time drift check against the interfaces. The check uses `null satisfies Cond ? null : never` in four directions (forward and backward for each type); any field type or optionality divergence resolves the conditional to `never` and `null satisfies never` fails the build. `Node` carries the `[x: string]: unknown` index signature, mirroring Zod 4's inferred output for `z.looseObject`; `SysProMDocument` (from `z.object`) has no index signature, matching the strict object output. Runtime behaviour is unchanged: `schema.json` regenerates byte-identically, the `.is()` guards parse the same inputs, and `spm validate` reports the self-describing document valid. --- packages/core/src/schema.ts | 147 +++++++++++++++++++++++++++--------- 1 file changed, 113 insertions(+), 34 deletions(-) diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 570ca86..9c0d325 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -310,35 +310,88 @@ export type Relationship = z.infer; // --------------------------------------------------------------------------- // Recursive schemas — defined raw, then wrapped with defineSchema after both // exist so TypeScript can resolve the circular type inference. +// +// `tsc` cannot serialise Zod's anonymous inferred recursive types into `.d.ts` +// (it emits `/*elided*/ any` for the recursive fields), so the public types +// `SysProMDocument` and `Node` are declared below as explicit named recursive +// `interface`s. The raw schema expressions (`_rawSysProMDocumentSchema`, +// `NodeBase`) are left UNANNOTATED so their true inferred output can be +// captured for a compile-time drift check against the interfaces; the +// recursive consts actually consumed (`SysProMDocumentSchema`, `NodeSchema`) +// are then annotated as `z.ZodType` so every downstream use (the +// `.is()` guard, `z.array(...)`, `.optional()`, `defineSchema(...)`) names the +// clean interface instead of the unserialisable anonymous recursive type. // --------------------------------------------------------------------------- -const SysProMDocumentSchema = z - .object({ - $schema: z - .string() - .describe("Schema URI for self-identification.") - .optional(), - metadata: Metadata.optional(), - get nodes(): z.ZodArray { - return z.array(NodeSchema).describe("All nodes in the graph."); - }, - relationships: z - .array(Relationship) - .describe("Typed, directed connections between nodes.") - .optional(), - external_references: z - .array(ExternalReference) - .describe( - "References to resources outside the graph, declared at system level.", - ) - .optional(), - }) - .meta({ - id: "SysProM", - title: "SysProM: System Provenance Model", - description: - "JSON Schema for SysProM — a recursive, decision-driven model for recording system provenance.", - }); +/** + * A complete SysProM document with metadata, nodes, relationships, and + * external references. + * + * Strict object (from `z.object`): no string index signature. + */ +export interface SysProMDocument { + $schema?: string; + metadata?: Metadata; + nodes: Node[]; + relationships?: Relationship[]; + external_references?: ExternalReference[]; +} + +/** + * A uniquely identifiable entity within the SysProM graph. + * + * Loose object (from `z.looseObject`): carries a string index signature + * `[x: string]: unknown`, mirroring Zod's inferred output for loose objects. + */ +export interface Node { + [x: string]: unknown; + id: string; + type: NodeType; + name: string; + description?: Text; + // `status?: never` mirrors `z.never().optional()`: the field may be absent + // or `undefined`, never any other value. Using `never` (rather than the + // redundant `?: undefined`, which the linter rejects) is bidirectionally + // assignable to the schema's inferred output and keeps the drift check green. + status?: never; + lifecycle?: Record; + context?: Text; + options?: Option[]; + selected?: string; + rationale?: Text; + scope?: string[]; + operations?: Operation[]; + propagation?: Record; + includes?: string[]; + external_references?: ExternalReference[]; + subsystem?: SysProMDocument; +} + +// Raw, unannotated schema expressions. Their inferred types are the ground +// truth for the drift checks below — do NOT annotate these or the check +// becomes tautological. `NodeBase` is exported because `update-node.ts` calls +// `.partial()` on it; it remains the unannotated raw loose object so the drift +// check sees the schema's true inferred output. +const _rawSysProMDocumentSchema = z.object({ + $schema: z + .string() + .describe("Schema URI for self-identification.") + .optional(), + metadata: Metadata.optional(), + get nodes(): z.ZodArray { + return z.array(NodeSchema).describe("All nodes in the graph."); + }, + relationships: z + .array(Relationship) + .describe("Typed, directed connections between nodes.") + .optional(), + external_references: z + .array(ExternalReference) + .describe( + "References to resources outside the graph, declared at system level.", + ) + .optional(), +}); /** Base node object schema without ID-prefix refinement. Supports .partial(). */ export const NodeBase = z @@ -403,7 +456,39 @@ export const NodeBase = z }) .describe("A uniquely identifiable entity within the system."); -const NodeSchema = NodeBase.superRefine((node, ctx) => { +// Compile-time drift check: the named interfaces must be bidirectionally +// assignable to the schemas' true inferred output. `satisfies` evaluates the +// conditional type: when the assignability holds it resolves to `null` (and +// `null satisfies null` passes); on any divergence it resolves to `never` (and +// `null satisfies never` errors, failing the build). Forward checks +// (interface <: output) catch missing required fields; backward checks +// (output <: interface) catch extra required fields or narrower optionality. +// Not re-exported from index.ts, so it stays out of the public API surface. +type RawDocOutput = z.infer; +type RawNodeOutput = z.infer; +export const _schemaDriftChecks = { + docForward: null satisfies SysProMDocument extends RawDocOutput + ? null + : never, + docBackward: null satisfies RawDocOutput extends SysProMDocument + ? null + : never, + nodeForward: null satisfies Node extends RawNodeOutput ? null : never, + nodeBackward: null satisfies RawNodeOutput extends Node ? null : never, +}; + +// Public recursive schema consts, annotated so `z.infer` and the `.is()` guard +// resolve to the named interfaces. `.meta`/`.describe`/`.superRefine` are all +// available on `z.ZodType`. +const SysProMDocumentSchema: z.ZodType = + _rawSysProMDocumentSchema.meta({ + id: "SysProM", + title: "SysProM: System Provenance Model", + description: + "JSON Schema for SysProM — a recursive, decision-driven model for recording system provenance.", + }); + +const NodeSchema: z.ZodType = NodeBase.superRefine((node, ctx) => { const prefix = NODE_ID_PREFIX[node.type]; if (!prefix) return; // Unknown type — skip validation const pattern = new RegExp(`^${prefix}\\d+(-[A-Z][A-Z0-9_]*)*$`); @@ -425,9 +510,6 @@ const NodeSchema = NodeBase.superRefine((node, ctx) => { */ export const SysProMDocument = defineSchema(SysProMDocumentSchema); -/** A complete SysProM document with metadata, nodes, relationships, and external references. */ -export type SysProMDocument = z.infer; - /** * Zod schema for a single node in the SysProM graph. Nodes are typed entities * with optional lifecycle, decisions, operations, and recursive subsystems. @@ -435,9 +517,6 @@ export type SysProMDocument = z.infer; */ export const Node = defineSchema(NodeSchema); -/** A uniquely identifiable entity within the SysProM graph. */ -export type Node = z.infer; - // --------------------------------------------------------------------------- // Domain constants // --------------------------------------------------------------------------- From 48738579329e04b624a3f549c79e16c59cfb2993 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 18:13:02 +0100 Subject: [PATCH 13/28] test(core): add regression test for recursive schema declaration elision Asserts that the built `dist/schema.d.ts` contains no `/*elided*/` markers. The test skips when dist is absent (so `pnpm test` without a build is not blocked) and runs in CI where build precedes test. --- packages/core/tests/declaration-emit.test.ts | 33 ++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 packages/core/tests/declaration-emit.test.ts diff --git a/packages/core/tests/declaration-emit.test.ts b/packages/core/tests/declaration-emit.test.ts new file mode 100644 index 0000000..a3da3fa --- /dev/null +++ b/packages/core/tests/declaration-emit.test.ts @@ -0,0 +1,33 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); + +// Regression test for the declaration-emit bug where mutually-recursive Zod +// schemas (SysProMDocumentSchema and NodeSchema) caused tsc to serialise the +// recursive fields as elided-any in the emitted .d.ts. The fix declares +// SysProMDocument and Node as explicit named recursive interfaces and annotates +// the schema consts as z.ZodType. This test guards against future +// regression by asserting the built dist/schema.d.ts contains no elision +// markers. +describe("declaration emit", () => { + it("dist/schema.d.ts contains no elided types", () => { + const dts = join(here, "..", "dist", "schema.d.ts"); + if (!existsSync(dts)) { + // Build has not run; skip rather than fail, so `pnpm test` (which does + // not build) is not blocked. CI runs build then test. + return; + } + const source = readFileSync(dts, "utf8"); + const marker = "elided"; + const matches = source.match(new RegExp(`/\\*${marker}\\*/`, "g")); + assert.equal( + matches, + null, + `dist/schema.d.ts must not contain elision markers (found ${matches?.length ?? 0}). Mutually-recursive schemas must be declared as named interfaces.`, + ); + }); +}); From c30b41861549bfbaade9f74821ff035e034c170d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 18:45:43 +0100 Subject: [PATCH 14/28] feat(web): interactive Cytoscape graph with ELK, fcose, and trace layouts Replace the static Mermaid-only Graphs tab with an interactive Cytoscape.js graph that builds elements directly from doc.nodes and doc.relationships (no Mermaid string parsing). Three layout modes: ELK layered (hierarchical, mapping SysProM abstraction layers), fcose (force-directed overview), and Cytoscape breadthfirst (trace from a selected node). Node click highlights the neighbourhood (dimming non-neighbours) and shows a details side-panel with id, type, name, status, lifecycle, description, and incoming/outgoing relationship counts. Subsystem-bearing nodes get a double border and the panel lists their contents. Type and status checkboxes filter the graph live. A legend maps colours and shapes. Mermaid rendering is kept as a secondary 'View as Mermaid' toggle for export and embedding. The Cytoscape instance is created once on mount and updated via the API (elements, visibility, layout) rather than being torn down on each render, keeping the 223-node sample document responsive. Dependencies: cytoscape 3.34.0, cytoscape-fcose 2.2.0, elkjs 0.11.1 (all published >=7 days ago). --- packages/web/package.json | 5 +- packages/web/src/graph/CytoscapeGraph.tsx | 214 +++++++++++++++ packages/web/src/graph/GraphFilters.tsx | 102 +++++++ packages/web/src/graph/Legend.tsx | 91 +++++++ packages/web/src/graph/NodeDetails.tsx | 189 +++++++++++++ packages/web/src/graph/elements.ts | 153 +++++++++++ packages/web/src/graph/ext.d.ts | 14 + packages/web/src/graph/layouts.ts | 156 +++++++++++ packages/web/src/graph/stylesheets.ts | 232 ++++++++++++++++ packages/web/src/styles.css.ts | 123 +++++++++ packages/web/src/tabs/GraphsTab.tsx | 313 ++++++++++++++++++---- pnpm-lock.yaml | 14 + 12 files changed, 1552 insertions(+), 54 deletions(-) create mode 100644 packages/web/src/graph/CytoscapeGraph.tsx create mode 100644 packages/web/src/graph/GraphFilters.tsx create mode 100644 packages/web/src/graph/Legend.tsx create mode 100644 packages/web/src/graph/NodeDetails.tsx create mode 100644 packages/web/src/graph/elements.ts create mode 100644 packages/web/src/graph/ext.d.ts create mode 100644 packages/web/src/graph/layouts.ts create mode 100644 packages/web/src/graph/stylesheets.ts diff --git a/packages/web/package.json b/packages/web/package.json index e996550..27c1c86 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -10,11 +10,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@sysprom/core": "workspace:*", "@radix-ui/react-dialog": "1.1.17", "@radix-ui/react-select": "2.3.1", "@radix-ui/react-tabs": "1.1.15", "@radix-ui/react-tooltip": "1.2.10", + "@sysprom/core": "workspace:*", + "cytoscape": "3.34.0", + "cytoscape-fcose": "2.2.0", + "elkjs": "0.11.1", "mermaid": "11.15.0", "react": "19.2.7", "react-dom": "19.2.7" diff --git a/packages/web/src/graph/CytoscapeGraph.tsx b/packages/web/src/graph/CytoscapeGraph.tsx new file mode 100644 index 0000000..2795a55 --- /dev/null +++ b/packages/web/src/graph/CytoscapeGraph.tsx @@ -0,0 +1,214 @@ +/** + * Imperative Cytoscape.js React component. + * + * Creates a single Cytoscape instance on mount (preserved across re-renders), + * then updates elements, layouts, and highlight state via the Cytoscape API. + * This avoids the cost of tearing down and rebuilding the renderer on every + * prop change — essential for the 223-node sample document. + */ +import React, { useEffect, useRef } from "react"; +import cytoscape, { + type Core, + type EventObject, + type NodeSingular, +} from "cytoscape"; +import fcose from "cytoscape-fcose"; +import type { SysProMDocument, Node } from "@sysprom/core"; +import { buildElements, neighbourhoodElementIds } from "./elements"; +import { buildStylesheet } from "./stylesheets"; +import { + computeElkPositions, + buildPresetLayout, + buildOverviewLayoutOptions, + buildTraceLayoutOptions, + toLayoutOptions, + type LayoutMode, +} from "./layouts"; + +// Register the fcose layout extension once. +cytoscape.use(fcose); + +/** + * Type guard narrowing an `EventObject.target` (typed as `any` by Cytoscape) + * to a `NodeSingular`. Used by the delegated `tap` handler. + */ +function isNodeSingular(target: unknown): target is NodeSingular { + if (typeof target !== "object" || target === null) return false; + if (!("isNode" in target)) return false; + const fn = target.isNode; + if (typeof fn !== "function") return false; + return Boolean(fn.call(target)); +} + +export interface CytoscapeGraphHandle { + /** The underlying Cytoscape core instance. */ + readonly cy: Core; +} + +export interface GraphSelection { + readonly nodeId: string | null; +} + +export interface CytoscapeGraphProps { + /** The full SysProM document — source of truth for nodes and edges. */ + readonly doc: SysProMDocument; + /** Active layout mode. */ + readonly layout: LayoutMode; + /** Root node ID for the Trace layout (ignored otherwise). */ + readonly traceRootId: string | null; + /** Set of node IDs currently visible after filtering. */ + readonly visibleNodeIds: ReadonlySet; + /** Called when the user selects a node (or null on background click). */ + readonly onSelect: (nodeId: string | null) => void; +} + +/** + * Render an interactive Cytoscape graph. The instance is created once and kept + * in a ref; subsequent prop changes update elements, visibility, layout, and + * highlight through the Cytoscape API. + */ +export function CytoscapeGraph({ + doc, + layout, + traceRootId, + visibleNodeIds, + onSelect, +}: CytoscapeGraphProps): React.ReactElement { + const containerRef = useRef(null); + const cyRef = useRef(null); + const onSelectRef = useRef(onSelect); + onSelectRef.current = onSelect; + + // Initialise the Cytoscape instance exactly once. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const stylesheet = buildStylesheet(); + const cy = cytoscape({ + container, + elements: [], + style: stylesheet, + wheelSensitivity: 0.2, + minZoom: 0.05, + maxZoom: 4, + pixelRatio: 1.5, + textureOnViewport: true, + hideEdgesOnViewport: false, + hideLabelsOnViewport: true, + }); + cyRef.current = cy; + + // Node tap: select and highlight the node's neighbourhood. + const handleNodeTap = (event: EventObject): void => { + const target: unknown = event.target; + if (!isNodeSingular(target)) return; + highlightNeighbourhood(cy, target.id()); + onSelectRef.current(target.id()); + }; + + // Background tap (no selector → fires on core): clear selection. + const handleBackgroundTap = (): void => { + clearHighlight(cy); + onSelectRef.current(null); + }; + + cy.on("tap", "node", handleNodeTap); + cy.on("tap", handleBackgroundTap); + + return () => { + cy.destroy(); + cyRef.current = null; + }; + }, []); + + // Update the full element set when the document changes. + useEffect(() => { + const cy = cyRef.current; + if (!cy) return; + const elements = buildElements(doc); + cy.elements().remove(); + cy.add(elements); + }, [doc]); + + // Apply visibility filtering. + useEffect(() => { + const cy = cyRef.current; + if (!cy) return; + cy.nodes().forEach((node) => { + const visible = visibleNodeIds.has(node.id()); + if (visible) { + node.removeClass("hidden"); + node.style("display", "element"); + } else { + node.addClass("hidden"); + node.style("display", "none"); + } + }); + // Hide edges whose endpoints are not both visible. + cy.edges().forEach((edge) => { + const sourceVisible = visibleNodeIds.has(edge.source().id()); + const targetVisible = visibleNodeIds.has(edge.target().id()); + if (sourceVisible && targetVisible) { + edge.style("display", "element"); + } else { + edge.style("display", "none"); + } + }); + }, [visibleNodeIds]); + + // Apply the active layout. + useEffect(() => { + const cy = cyRef.current; + if (!cy) return; + + if (layout === "layered") { + void runElkLayered(cy, doc); + } else if (layout === "overview") { + cy.layout(toLayoutOptions(buildOverviewLayoutOptions())).run(); + } else { + // layout === "trace" + if (traceRootId) { + cy.layout(toLayoutOptions(buildTraceLayoutOptions(traceRootId))).run(); + } + } + }, [layout, traceRootId, doc]); + + return
; +} + +/** + * Run the ELK layered layout asynchronously, then apply the computed positions + * via Cytoscape's preset layout. + */ +async function runElkLayered(cy: Core, doc: SysProMDocument): Promise { + const visibleNodes = cy.nodes(":visible"); + const visibleNodeIds = new Set(visibleNodes.map((n) => n.id())); + const nodes: Node[] = doc.nodes.filter((node) => visibleNodeIds.has(node.id)); + const edges = doc.relationships ?? []; + const visibleEdges = edges + .filter((rel) => visibleNodeIds.has(rel.from) && visibleNodeIds.has(rel.to)) + .map((rel) => ({ source: rel.from, target: rel.to })); + + const positions = await computeElkPositions(nodes, visibleEdges); + cy.layout(toLayoutOptions(buildPresetLayout(positions))).run(); +} + +/** Highlight a node's neighbourhood and dim everything else. */ +function highlightNeighbourhood(cy: Core, nodeId: string): void { + const neighbours = neighbourhoodElementIds(cy, nodeId); + cy.elements().forEach((ele) => { + if (neighbours.has(ele.id())) { + ele.removeClass("dimmed"); + ele.addClass("highlighted"); + } else { + ele.removeClass("highlighted"); + ele.addClass("dimmed"); + } + }); +} + +/** Clear all highlight/dim classes. */ +function clearHighlight(cy: Core): void { + cy.elements().removeClass("dimmed highlighted"); +} diff --git a/packages/web/src/graph/GraphFilters.tsx b/packages/web/src/graph/GraphFilters.tsx new file mode 100644 index 0000000..4858790 --- /dev/null +++ b/packages/web/src/graph/GraphFilters.tsx @@ -0,0 +1,102 @@ +/** + * Type and status filters for the interactive graph. Updates are live — the + * parent recomputes the visible-node set on every change. + */ +import React, { useMemo } from "react"; +import type { SysProMDocument, NodeType } from "@sysprom/core"; +import { NODE_TYPE_LABELS } from "@sysprom/core"; +import { + filterGroup, + filterGroupLabel, + filterCheckboxRow, +} from "../styles.css"; + +export interface FilterState { + readonly visibleTypes: ReadonlySet; + readonly visibleStatuses: ReadonlySet; +} + +export function GraphFilters({ + doc, + state, + onChange, +}: { + readonly doc: SysProMDocument; + readonly state: FilterState; + readonly onChange: (next: FilterState) => void; +}): React.ReactElement { + // Distinct types and statuses actually present in the document, so the + // checkbox lists are minimal and relevant. + const presentTypes = useMemo(() => { + const set = new Set(); + for (const node of doc.nodes) set.add(node.type); + return set; + }, [doc]); + + const presentStatuses = useMemo(() => { + const set = new Set(); + for (const node of doc.nodes) { + for (const key of Object.keys(node.lifecycle ?? {})) { + set.add(key); + } + } + return set; + }, [doc]); + + const toggleType = (type: NodeType): void => { + const next = new Set(state.visibleTypes); + if (next.has(type)) next.delete(type); + else next.add(type); + onChange({ ...state, visibleTypes: next }); + }; + + const toggleStatus = (status: string): void => { + const next = new Set(state.visibleStatuses); + if (next.has(status)) next.delete(status); + else next.add(status); + onChange({ ...state, visibleStatuses: next }); + }; + + return ( +
+
+ Node types + {[...presentTypes] + .sort((a, b) => a.localeCompare(b)) + .map((type) => ( + + ))} +
+
+ Statuses + {[...presentStatuses] + .sort((a, b) => a.localeCompare(b)) + .map((status) => ( + + ))} +
+
+ ); +} + +function typeStyleLabel(type: NodeType): string { + return NODE_TYPE_LABELS[type]; +} diff --git a/packages/web/src/graph/Legend.tsx b/packages/web/src/graph/Legend.tsx new file mode 100644 index 0000000..d23f449 --- /dev/null +++ b/packages/web/src/graph/Legend.tsx @@ -0,0 +1,91 @@ +/** + * Static legend mapping node-type colours/shapes and relationship-type colours + * to the Cytoscape stylesheet so users can interpret the graph. + */ +import React from "react"; +import { RELATIONSHIP_TYPE_LABELS } from "@sysprom/core"; +import { typeStyle, relStyle } from "./stylesheets"; +import { + legendGrid, + legendItem, + legendSwatch, + sectionTitle, +} from "../styles.css"; + +const NODE_TYPES_IN_LEGEND = [ + "intent", + "concept", + "capability", + "element", + "realisation", + "artefact", + "invariant", + "decision", + "change", +] as const; + +export function Legend(): React.ReactElement { + return ( +
+ + Legend + +
+
+ Node types + {NODE_TYPES_IN_LEGEND.map((type) => { + const style = typeStyle(type); + return ( +
+ + {label(type)} +
+ ); + })} +
+
+ Relationship types + {Object.entries(RELATIONSHIP_TYPE_LABELS).map(([key, label]) => { + const style = relStyle(key); + return ( +
+ + {label} +
+ ); + })} +
+
+
+ ); +} + +function label(type: string): string { + const labels: Readonly> = { + intent: "Intent", + concept: "Concept", + capability: "Capability", + element: "Element", + realisation: "Realisation", + artefact: "Artefact", + invariant: "Invariant", + decision: "Decision", + change: "Change", + }; + return labels[type] ?? type; +} diff --git a/packages/web/src/graph/NodeDetails.tsx b/packages/web/src/graph/NodeDetails.tsx new file mode 100644 index 0000000..e0913d7 --- /dev/null +++ b/packages/web/src/graph/NodeDetails.tsx @@ -0,0 +1,189 @@ +/** + * Side panel showing details for the currently selected graph node. + */ +import React from "react"; +import type { SysProMDocument, Relationship, NodeType } from "@sysprom/core"; +import { NODE_TYPE_LABELS } from "@sysprom/core"; +import { + detailsPanel, + detailsField, + detailsLabel, + detailsValue, + badge, + sectionTitle, + muted, +} from "../styles.css"; + +export function NodeDetails({ + nodeId, + doc, +}: { + readonly nodeId: string | null; + readonly doc: SysProMDocument; +}): React.ReactElement { + if (!nodeId) { + return ( + + ); + } + + const node = doc.nodes.find((n) => n.id === nodeId); + if (!node) { + return ( + + ); + } + + const relationships = doc.relationships ?? []; + const incoming = relationships.filter((r) => r.to === nodeId); + const outgoing = relationships.filter((r) => r.from === nodeId); + const description = + typeof node.description === "string" ? node.description : undefined; + const subsystem = node.subsystem; + + return ( + + ); +} + +function RelationshipList({ + title, + rels, + doc, +}: { + readonly title: string; + readonly rels: readonly Relationship[]; + readonly doc: SysProMDocument; +}): React.ReactElement { + return ( +
+
{title}
+
+ {rels.map((rel, index) => { + const otherId = title === "Incoming" ? rel.from : rel.to; + const other = doc.nodes.find((n) => n.id === otherId); + return ( +
+ {rel.type}{" "} + + {otherId} + + {other ? `: ${other.name}` : ""} +
+ ); + })} +
+
+ ); +} + +function typeLabel(type: NodeType): string { + return NODE_TYPE_LABELS[type]; +} + +/** Render a human-readable summary of a recursive subsystem's contents. */ +function subsystemSummary(subsystem: SysProMDocument): string { + const nodeCount = subsystem.nodes.length; + const relCount = subsystem.relationships?.length ?? 0; + const nodePart = `${String(nodeCount)} node${plural(nodeCount)}`; + const relPart = + relCount > 0 ? `, ${String(relCount)} relationship${plural(relCount)}` : ""; + return `Contains ${nodePart}${relPart}.`; +} + +function plural(count: number): string { + return count === 1 ? "" : "s"; +} + +function primaryStatus( + lifecycle: Record | undefined, +): string | undefined { + if (!lifecycle) return undefined; + // Return the first truthy key. + for (const [key, value] of Object.entries(lifecycle)) { + if (value === true || typeof value === "string") return key; + } + return undefined; +} + +function lifecycleList( + lifecycle: Record | undefined, +): string[] { + if (!lifecycle) return []; + return Object.entries(lifecycle) + .filter(([, value]) => value === true || typeof value === "string") + .map(([key]) => key); +} diff --git a/packages/web/src/graph/elements.ts b/packages/web/src/graph/elements.ts new file mode 100644 index 0000000..332fb4f --- /dev/null +++ b/packages/web/src/graph/elements.ts @@ -0,0 +1,153 @@ +/** + * Build Cytoscape element descriptors directly from a SysProM document. + * + * The document is the single source of truth — we never parse a Mermaid string. + * Nodes map to Cytoscape nodes, relationships map to Cytoscape edges, with + * deduplication by a composite key. + */ +import type { Core, ElementDefinition } from "cytoscape"; +import type { SysProMDocument } from "@sysprom/core"; +import { primaryLifecycleState } from "@sysprom/core"; + +/** The SysProM abstraction layers, in canonical refinement order. */ +export const ABSTRACTION_LAYERS = [ + "intent", + "concept", + "capability", + "element", + "realisation", + "artefact", +] as const; + +/** + * A numeric rank for each node type, used by the ELK layered layout to group + * nodes by abstraction layer. Types not in the canonical chain are ordered + * after it but kept stable. + */ +const LAYER_RANK: Readonly> = { + intent: 0, + concept: 1, + capability: 2, + element: 3, + realisation: 4, + artefact: 5, + invariant: 6, + principle: 7, + policy: 8, + protocol: 9, + stage: 10, + role: 11, + gate: 12, + mode: 13, + decision: 14, + change: 15, + view: 16, + milestone: 17, +}; + +/** Map a node type to its ELK layered-layout rank. Lower = earlier layer. */ +export function layerRank(type: string): number { + return LAYER_RANK[type] ?? 99; +} + +/** Determine whether a node status indicates incompleteness (dashed style). */ +export function isPendingStatus(status: string | undefined): boolean { + return ( + status === "proposed" || status === "deferred" || status === "experimental" + ); +} + +/** Determine whether a node status indicates deprecation (dimmed style). */ +export function isDeprecatedStatus(status: string | undefined): boolean { + return ( + status === "deprecated" || + status === "retired" || + status === "superseded" || + status === "abandoned" + ); +} + +/** Extra data carried on each Cytoscape node element. */ +export interface SyspromNodeData { + id: string; + type: string; + name: string; + status: string | undefined; + lifecycle: Record | undefined; + description: string | undefined; + hasSubsystem: boolean; + subsystemNodeCount: number; + layerRank: number; +} + +/** Extra data carried on each Cytoscape edge element. */ +export interface SyspromEdgeData { + id: string; + source: string; + target: string; + type: string; + polarity: string | undefined; + strength: number | undefined; +} + +/** Build Cytoscape element definitions from a parsed SysProM document. */ +export function buildElements(doc: SysProMDocument): ElementDefinition[] { + const elements: ElementDefinition[] = []; + const seenEdges = new Set(); + + for (const node of doc.nodes) { + const status = primaryLifecycleState(node); + const subsystem = node.subsystem; + const description = + typeof node.description === "string" ? node.description : undefined; + const subsystemNodeCount = subsystem ? subsystem.nodes.length : 0; + const data: SyspromNodeData = { + id: node.id, + type: node.type, + name: node.name, + status, + lifecycle: node.lifecycle, + description, + hasSubsystem: subsystem !== undefined, + subsystemNodeCount, + layerRank: layerRank(node.type), + }; + elements.push({ group: "nodes", data }); + } + + const relationships = doc.relationships ?? []; + for (const rel of relationships) { + const id = `${rel.from}->${rel.to}:${rel.type}`; + if (seenEdges.has(id)) continue; + seenEdges.add(id); + const data: SyspromEdgeData = { + id, + source: rel.from, + target: rel.to, + type: rel.type, + polarity: rel.polarity, + strength: rel.strength, + }; + elements.push({ group: "edges", data }); + } + + return elements; +} + +/** + * Return the set of element IDs adjacent to a given node ID in a Cytoscape + * instance (the node, its connected edges, and the edges' other endpoints). + * Used for neighbourhood highlighting. + */ +export function neighbourhoodElementIds(cy: Core, nodeId: string): Set { + const result = new Set(); + const node = cy.getElementById(nodeId); + if (node.empty()) return result; + result.add(nodeId); + node.connectedEdges().forEach((edge) => { + result.add(edge.id()); + result.add(edge.source().id()); + result.add(edge.target().id()); + }); + return result; +} diff --git a/packages/web/src/graph/ext.d.ts b/packages/web/src/graph/ext.d.ts new file mode 100644 index 0000000..2326566 --- /dev/null +++ b/packages/web/src/graph/ext.d.ts @@ -0,0 +1,14 @@ +/** + * Ambient type declarations for Cytoscape extensions that ship without + * TypeScript definitions. + */ + +declare module "cytoscape-fcose" { + import type { Ext } from "cytoscape"; + /** + * The fCoSE layout extension registration function. Call + * `cytoscape.use(fcose)` once to register the `fcose` layout name. + */ + const fcose: Ext; + export default fcose; +} diff --git a/packages/web/src/graph/layouts.ts b/packages/web/src/graph/layouts.ts new file mode 100644 index 0000000..3ebf7c5 --- /dev/null +++ b/packages/web/src/graph/layouts.ts @@ -0,0 +1,156 @@ +/** + * Layout configurations for the three interactive graph modes. + * + * - **Layered** — ELK layered/hierarchical, mapping SysProM abstraction layers + * (intent->concept->...->artefact) into ELK layer direction. + * - **Overview** — fcose (force-directed) for a holistic picture. + * - **Trace** — Cytoscape built-in breadthfirst from a selected node. + */ +import ELK from "elkjs"; +import type { + LayoutOptions, + ShapedLayoutOptions, + PresetLayoutOptions, + BreadthFirstLayoutOptions, + Position, +} from "cytoscape"; +import type { Node } from "@sysprom/core"; +import { layerRank } from "./elements"; + +export type LayoutMode = "layered" | "overview" | "trace"; + +/** + * Options for the fcose layout extension. fcose has no bundled TypeScript + * declarations, so this interface mirrors its documented option set. At + * runtime the values are passed straight through to Cytoscape. + */ +interface FcoseLayoutOptions extends ShapedLayoutOptions { + name: "fcose"; + randomize?: boolean; + nodeRepulsion?: number; + idealEdgeLength?: number; + edgeElasticity?: number; + gravity?: number; + numIter?: number; + tile?: boolean; + packComponents?: boolean; +} + +/** + * Run ELK layered layout asynchronously against the visible nodes and edges and + * resolve with a map of node ID -> {x, y}. The component applies the result via + * Cytoscape's `preset` layout. + * + * The SysProM abstraction-layer rank (intent=0, concept=1, ...) is fed to ELK + * as `elk.layered.priority.direction` so earlier layers settle above later ones + * while still respecting the actual edges. + */ +export async function computeElkPositions( + nodes: readonly Node[], + edges: readonly { readonly source: string; readonly target: string }[], +): Promise> { + const elk = new ELK(); + const elkNodes = nodes.map((node) => ({ + id: node.id, + width: 40, + height: 40, + layoutOptions: { + "elk.layered.priority.direction": String(layerRank(node.type)), + }, + })); + const elkEdges = edges.map((edge, index) => ({ + id: `e${String(index)}`, + sources: [edge.source], + targets: [edge.target], + })); + const elkGraph = { + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "DOWN", + "elk.layered.spacing.nodeNodeBetweenLayers": "70", + "elk.spacing.nodeNode": "50", + "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", + "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", + }, + children: elkNodes, + edges: elkEdges, + }; + const result = await elk.layout(elkGraph); + const positions = new Map< + string, + { readonly x: number; readonly y: number } + >(); + for (const child of result.children ?? []) { + const x = child.x ?? 0; + const y = child.y ?? 0; + positions.set(child.id, { x: x + 20, y: y + 20 }); + } + return positions; +} + +/** Build a Cytoscape `preset` layout that applies the given positions. */ +export function buildPresetLayout( + positions: ReadonlyMap, +): PresetLayoutOptions { + const positionFunction = (nodeId: string): Position => { + const pos = positions.get(nodeId); + return pos ?? { x: 0, y: 0 }; + }; + return { + name: "preset", + animate: true, + animationDuration: 400, + fit: true, + padding: 40, + positions: positionFunction, + }; +} + +/** Build the fcose (force-directed) layout options for the Overview mode. */ +export function buildOverviewLayoutOptions(): FcoseLayoutOptions { + return { + name: "fcose", + animate: true, + animationDuration: 600, + animationEasing: "ease-out", + fit: true, + padding: 40, + randomize: true, + nodeRepulsion: 8000, + idealEdgeLength: 100, + edgeElasticity: 0.45, + gravity: 0.25, + numIter: 2500, + tile: true, + packComponents: true, + }; +} + +/** Build breadthfirst layout options rooted at the given node ID (Trace mode). */ +export function buildTraceLayoutOptions( + rootId: string, +): BreadthFirstLayoutOptions { + return { + name: "breadthfirst", + animate: true, + animationDuration: 400, + fit: true, + padding: 40, + roots: [rootId], + directed: true, + circle: false, + spacingFactor: 1.25, + maximal: false, + }; +} + +/** + * Coerce a specific layout-options object into the `LayoutOptions` union + * accepted by `cy.layout()`. Each concrete options interface is a member of + * the union, so this is a widening (always safe) — modelled as a function to + * avoid `as` assertions. + */ +export function toLayoutOptions(options: LayoutOptions): LayoutOptions { + return options; +} diff --git a/packages/web/src/graph/stylesheets.ts b/packages/web/src/graph/stylesheets.ts new file mode 100644 index 0000000..3a30dc6 --- /dev/null +++ b/packages/web/src/graph/stylesheets.ts @@ -0,0 +1,232 @@ +/** + * Cytoscape stylesheet definitions driven by the app's vanilla-extract theme + * tokens. Node fill/shape is keyed by SysProM node `type`; edge line-colour + * and width by relationship `type`; dashed/border styles carry semantic + * meaning (pending, deprecated, subsystem-bearing). + */ +import type { StylesheetJson, StylesheetJsonBlock, Css } from "cytoscape"; +import { theme } from "../styles.css"; + +/** Node shape literal union from Cytoscape. */ +type NodeShape = Css.NodeShape; + +// Per-type palette. Fills are distinct hues so the 18 node types are visually +// separable in a dense graph. Each pairs a fill with a darker border. +interface TypeStyle { + readonly fill: string; + readonly border: string; + readonly shape: NodeShape; +} + +const TYPE_STYLES: Readonly> = { + intent: { fill: "#f59f00", border: "#e67700", shape: "round-rectangle" }, + concept: { fill: "#f783ac", border: "#c2255c", shape: "round-rectangle" }, + capability: { + fill: "#69db7c", + border: "#2b8a3e", + shape: "round-rectangle", + }, + element: { fill: "#74c0fc", border: "#1971c2", shape: "rectangle" }, + realisation: { + fill: "#ffc078", + border: "#d9480f", + shape: "rectangle", + }, + artefact: { fill: "#b197fc", border: "#5f3dc4", shape: "hexagon" }, + invariant: { fill: "#ffa8a8", border: "#c92a2a", shape: "diamond" }, + principle: { fill: "#ffa8a8", border: "#c92a2a", shape: "diamond" }, + policy: { fill: "#ffa8a8", border: "#c92a2a", shape: "diamond" }, + protocol: { fill: "#63e6be", border: "#0ca678", shape: "vee" }, + stage: { fill: "#63e6be", border: "#0ca678", shape: "vee" }, + role: { fill: "#a5d8ff", border: "#1971c2", shape: "ellipse" }, + gate: { fill: "#ffec99", border: "#f08c00", shape: "octagon" }, + mode: { fill: "#ffc9c9", border: "#c92a2a", shape: "tag" }, + decision: { fill: "#d0bfff", border: "#5f3dc4", shape: "round-diamond" }, + change: { fill: "#d3f9d8", border: "#2b8a3e", shape: "round-tag" }, + view: { fill: "#e9dbff", border: "#6741d9", shape: "round-triangle" }, + milestone: { fill: "#fff3bf", border: "#f08c00", shape: "star" }, +}; + +function typeStyle(type: string): TypeStyle { + return ( + TYPE_STYLES[type] ?? { + fill: theme.color.textMuted, + border: theme.color.text, + shape: "ellipse", + } + ); +} + +// Per-relationship-type colour and width. Refinement-chain types are green; +// dependency/structure types blue; constraint/governance red; temporal grey. +interface RelStyle { + readonly colour: string; + readonly width: number; +} + +const REL_STYLES: Readonly> = { + refines: { colour: "#2b8a3e", width: 2 }, + realises: { colour: "#2b8a3e", width: 2 }, + implements: { colour: "#2b8a3e", width: 1.5 }, + depends_on: { colour: "#1971c2", width: 1.5 }, + part_of: { colour: "#1971c2", width: 1.5 }, + constrained_by: { colour: "#c92a2a", width: 1.5 }, + governed_by: { colour: "#c92a2a", width: 1.5 }, + affects: { colour: "#e8590c", width: 1.5 }, + must_preserve: { colour: "#c92a2a", width: 2 }, + supersedes: { colour: "#e8590c", width: 1.5 }, + precedes: { colour: "#868e96", width: 1 }, + must_follow: { colour: "#868e96", width: 1 }, + produces: { colour: "#5f3dc4", width: 1.5 }, + modifies: { colour: "#5f3dc4", width: 1.5 }, +}; + +function relStyle(type: string): RelStyle { + return REL_STYLES[type] ?? { colour: theme.color.textMuted, width: 1 }; +} + +/** + * Build the Cytoscape stylesheet for the graph. Uses `mapData` selectors to + * drive style from the element data fields, so one stylesheet handles every + * node/edge type without conditional logic. + */ +export function buildStylesheet(): StylesheetJson { + const base: StylesheetJsonBlock[] = [ + { + selector: "node", + style: { + label: "data(name)", + "font-size": "10px", + "font-family": theme.font.body, + color: theme.color.text, + "text-valign": "bottom", + "text-halign": "center", + "text-margin-y": 4, + "text-max-width": "80px", + "text-wrap": "wrap", + width: 28, + height: 28, + "border-width": 2, + "background-opacity": 0.9, + "transition-property": + "opacity, background-color, border-color, border-width", + "transition-duration": 150, + }, + }, + { + selector: "edge", + style: { + "curve-style": "bezier", + "target-arrow-shape": "triangle", + "arrow-scale": 0.9, + "line-color": theme.color.textMuted, + "target-arrow-color": theme.color.textMuted, + width: 1.5, + "text-rotation": "autorotate", + "font-size": "8px", + color: theme.color.textMuted, + opacity: 0.7, + "transition-property": "opacity, line-color, width", + "transition-duration": 150, + }, + }, + ]; + + // Per-type node styling. One selector per known type. + const typeSelectors: StylesheetJsonBlock[] = Object.entries(TYPE_STYLES).map( + ([type, style]) => ({ + selector: `node[type="${type}"]`, + style: { + "background-color": style.fill, + "border-color": style.border, + shape: style.shape, + }, + }), + ); + + // Per-relationship-type edge styling. + const relSelectors: StylesheetJsonBlock[] = Object.entries(REL_STYLES).map( + ([type, style]) => ({ + selector: `edge[type="${type}"]`, + style: { + "line-color": style.colour, + "target-arrow-color": style.colour, + width: style.width, + }, + }), + ); + + const semantic: StylesheetJsonBlock[] = [ + // Nodes carrying a recursive subsystem: thicker border + dashed ring. + { + selector: "node[hasSubsystem]", + style: { + "border-width": 3, + "border-style": "double", + }, + }, + // Pending nodes (proposed/deferred/experimental): dashed border. + { + selector: + "node[status = 'proposed'], node[status = 'deferred'], node[status = 'experimental']", + style: { + "border-style": "dashed", + }, + }, + // Deprecated nodes: dimmed fill. + { + selector: + "node[status = 'deprecated'], node[status = 'retired'], node[status = 'superseded'], node[status = 'abandoned']", + style: { + "background-opacity": 0.4, + }, + }, + // Positive-polarity edges: green tint. + { + selector: "edge[polarity = 'positive']", + style: { "line-color": "#2b8a3e", "target-arrow-color": "#2b8a3e" }, + }, + // Negative-polarity edges: red tint. + { + selector: "edge[polarity = 'negative']", + style: { "line-color": "#c92a2a", "target-arrow-color": "#c92a2a" }, + }, + // Strong edges: wider. + { + selector: "edge[strength >= 0.8]", + style: { width: 3 }, + }, + // Selection + hover states. + { + selector: "node:selected", + style: { + "border-width": 4, + "border-color": theme.color.accent, + "overlay-color": theme.color.accent, + "overlay-opacity": 0.2, + }, + }, + { + selector: "edge:selected", + style: { width: 3, opacity: 1 }, + }, + // Hidden nodes (filtered out). + { + selector: ".hidden", + style: { display: "none" }, + }, + // Dimmed (non-neighbour) state — applied via a class toggled on highlight. + { + selector: ".dimmed", + style: { opacity: 0.15 }, + }, + { + selector: ".highlighted", + style: { opacity: 1, "border-width": 4 }, + }, + ]; + + return [...base, ...typeSelectors, ...relSelectors, ...semantic]; +} + +export { typeStyle, relStyle, type TypeStyle, type RelStyle }; diff --git a/packages/web/src/styles.css.ts b/packages/web/src/styles.css.ts index 6a79136..1416c82 100644 --- a/packages/web/src/styles.css.ts +++ b/packages/web/src/styles.css.ts @@ -274,3 +274,126 @@ export const sectionTitle = style({ fontWeight: 600, margin: `${theme.space.lg} 0 ${theme.space.sm}`, }); + +// --- Interactive graph tab styles --- + +export const graphWorkspace = style({ + display: "flex", + gap: theme.space.md, + alignItems: "flex-start", + marginTop: theme.space.md, +}); + +export const graphCanvas = style({ + flex: 1, + height: "70vh", + minHeight: "480px", + backgroundColor: theme.color.surface, + border: `1px solid ${theme.color.border}`, + borderRadius: "8px", + overflow: "hidden", +}); + +export const detailsPanel = style({ + width: "300px", + flexShrink: 0, + backgroundColor: theme.color.surface, + border: `1px solid ${theme.color.border}`, + borderRadius: "8px", + padding: theme.space.md, + maxHeight: "70vh", + overflowY: "auto", + fontSize: "13px", +}); + +export const filterGroup = style({ + display: "flex", + flexDirection: "column", + gap: theme.space.xs, + padding: theme.space.sm, + border: `1px solid ${theme.color.border}`, + borderRadius: "6px", + minWidth: "180px", +}); + +export const filterGroupLabel = style({ + fontSize: "12px", + fontWeight: 600, + color: theme.color.textMuted, + textTransform: "uppercase", + letterSpacing: "0.04em", +}); + +export const filterCheckboxRow = style({ + display: "flex", + alignItems: "center", + gap: theme.space.xs, + fontSize: "12px", +}); + +export const legendGrid = style({ + display: "flex", + flexWrap: "wrap", + gap: theme.space.sm, + fontSize: "11px", + color: theme.color.textMuted, +}); + +export const legendItem = style({ + display: "flex", + alignItems: "center", + gap: theme.space.xs, +}); + +export const legendSwatch = style({ + display: "inline-block", + width: "14px", + height: "14px", + borderRadius: "3px", + border: "1px solid", + flexShrink: 0, +}); + +export const layoutControls = style({ + display: "flex", + gap: theme.space.xs, + flexWrap: "wrap", +}); + +export const layoutButton = style({ + fontFamily: theme.font.body, + fontSize: "12px", + padding: `${theme.space.xs} ${theme.space.sm}`, + border: `1px solid ${theme.color.border}`, + borderRadius: "6px", + backgroundColor: theme.color.surface, + color: theme.color.text, + cursor: "pointer", + selectors: { + '&[data-active="true"]': { + backgroundColor: theme.color.accent, + color: "#ffffff", + borderColor: theme.color.accent, + }, + "&:hover": { + borderColor: theme.color.accent, + }, + }, +}); + +export const detailsField = style({ + marginBottom: theme.space.sm, +}); + +export const detailsLabel = style({ + fontSize: "11px", + color: theme.color.textMuted, + textTransform: "uppercase", + letterSpacing: "0.04em", + marginBottom: "2px", +}); + +export const detailsValue = style({ + fontFamily: theme.font.body, + wordBreak: "break-word", +}); diff --git a/packages/web/src/tabs/GraphsTab.tsx b/packages/web/src/tabs/GraphsTab.tsx index 13eaecf..29100b8 100644 --- a/packages/web/src/tabs/GraphsTab.tsx +++ b/packages/web/src/tabs/GraphsTab.tsx @@ -1,7 +1,21 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import mermaid from "mermaid"; import { graphOp, type SysProMDocument } from "@sysprom/core"; -import { filterRow, select, graphContainer, button } from "../styles.css"; +import { CytoscapeGraph } from "../graph/CytoscapeGraph"; +import { GraphFilters, type FilterState } from "../graph/GraphFilters"; +import { NodeDetails } from "../graph/NodeDetails"; +import { Legend } from "../graph/Legend"; +import type { LayoutMode } from "../graph/layouts"; +import { + filterRow, + select, + button, + graphCanvas, + graphWorkspace, + layoutControls, + layoutButton, + muted, +} from "../styles.css"; mermaid.initialize({ startOnLoad: false, @@ -10,60 +24,178 @@ mermaid.initialize({ flowchart: { useMaxWidth: true }, }); -type DiagramKind = "relationship" | "refinement" | "decision" | "dependency"; +type View = "interactive" | "mermaid"; -const DIAGRAM_LABELS: Record = { - relationship: "Relationship Graph", - refinement: "Refinement Chain", - decision: "Decision Map", - dependency: "Dependency Graph", +const LAYOUT_LABELS: Readonly> = { + layered: "Layered (ELK)", + overview: "Overview (fCoSE)", + trace: "Trace from selected", }; -// Per-diagram layout defaults matching the CLI json2md behaviour. -const DIAGRAM_LAYOUT: Record = { - relationship: "TD", - refinement: "TD", - decision: "TD", - dependency: "LR", -}; +const LAYOUT_MODES: readonly LayoutMode[] = ["layered", "overview", "trace"]; -interface GraphKindConfig { - typeFilter?: string; - relTypes?: string[]; -} +export function GraphsTab({ + doc, +}: { + readonly doc: SysProMDocument; +}): React.ReactElement { + const [view, setView] = useState("interactive"); + const [layout, setLayout] = useState("overview"); + const [selectedId, setSelectedId] = useState(null); + const [showMermaidSource, setShowMermaidSource] = useState(false); -const DIAGRAM_KINDS = [ - "relationship", - "refinement", - "decision", - "dependency", -] as const; + // Filters default to everything visible. + const [filters, setFilters] = useState(() => + initialFilters(doc), + ); -// The four diagram kinds the CLI produces. Each uses a different -// relationship-type filter so the generated graph is focused. -const KIND_CONFIG: Record = { - relationship: {}, - refinement: { relTypes: ["refines"] }, - decision: { - relTypes: ["affects", "must_preserve", "supersedes"], - }, - dependency: { relTypes: ["depends_on", "part_of"] }, -}; + // Recompute filters when a new document is loaded. + useEffect(() => { + setFilters(initialFilters(doc)); + setSelectedId(null); + }, [doc]); -function isDiagramKind(value: string): value is DiagramKind { - return value in DIAGRAM_LABELS; -} + // Compute the visible node set from filters. + const visibleNodeIds = useMemo(() => { + const result = new Set(); + for (const node of doc.nodes) { + if (!filters.visibleTypes.has(node.type)) continue; + const statuses = Object.entries(node.lifecycle ?? {}) + .filter(([, value]) => value === true || typeof value === "string") + .map(([key]) => key); + // A node is visible if it has no lifecycle status set (so it is + // never filtered out by status) or at least one of its statuses is + // in the visible set. + const statusVisible = + statuses.length === 0 || + statuses.some((s) => filters.visibleStatuses.has(s)); + if (statusVisible) result.add(node.id); + } + return result; + }, [doc, filters]); -function isLabelMode(value: string): value is "friendly" | "compact" { - return value === "friendly" || value === "compact"; + return ( +
+
+ +
+ + {view === "interactive" ? ( + + ) : ( + + )} +
+ ); } -let renderCounter = 0; +function InteractiveView({ + doc, + layout, + onLayoutChange, + selectedId, + onSelect, + filters, + onFiltersChange, + visibleNodeIds, +}: { + readonly doc: SysProMDocument; + readonly layout: LayoutMode; + readonly onLayoutChange: (mode: LayoutMode) => void; + readonly selectedId: string | null; + readonly onSelect: (id: string | null) => void; + readonly filters: FilterState; + readonly onFiltersChange: (next: FilterState) => void; + readonly visibleNodeIds: ReadonlySet; +}): React.ReactElement { + return ( +
+
+ +
+
+ {LAYOUT_MODES.map((mode) => ( + + ))} +
+

+ {visibleNodeIds.size} of {doc.nodes.length} nodes visible + {layout === "trace" && selectedId + ? ` — tracing from ${selectedId}` + : ""} +

+
+
-export function GraphsTab({ +
+
+ +
+ +
+ +
+ +
+
+ ); +} + +function MermaidView({ doc, + showSource, + onToggleSource, }: { readonly doc: SysProMDocument; + readonly showSource: boolean; + readonly onToggleSource: (next: boolean) => void; }): React.ReactElement { const [kind, setKind] = useState("relationship"); const [labelMode, setLabelMode] = useState<"friendly" | "compact">( @@ -72,7 +204,7 @@ export function GraphsTab({ const containerRef = useRef(null); const [error, setError] = useState(null); - const diagram = React.useMemo(() => { + const diagram = useMemo(() => { const config = KIND_CONFIG[kind]; return graphOp({ doc, @@ -115,8 +247,7 @@ export function GraphsTab({ className={select} value={kind} onChange={(e) => { - const value = e.target.value; - if (isDiagramKind(value)) setKind(value); + if (isDiagramKind(e.target.value)) setKind(e.target.value); }} > {DIAGRAM_KINDS.map((k) => ( @@ -129,13 +260,21 @@ export function GraphsTab({ className={select} value={labelMode} onChange={(e) => { - const value = e.target.value; - if (isLabelMode(value)) setLabelMode(value); + if (isLabelMode(e.target.value)) setLabelMode(e.target.value); }} > +
{error && (
)} -
-
- - Show Mermaid source - +
+ {showSource && (
 					{diagram}
 				
-
+ )}
); } + +// --- Mermaid view configuration (preserved from the original GraphsTab) --- + +type DiagramKind = "relationship" | "refinement" | "decision" | "dependency"; + +const DIAGRAM_LABELS: Record = { + relationship: "Relationship Graph", + refinement: "Refinement Chain", + decision: "Decision Map", + dependency: "Dependency Graph", +}; + +const DIAGRAM_LAYOUT: Record = { + relationship: "TD", + refinement: "TD", + decision: "TD", + dependency: "LR", +}; + +interface GraphKindConfig { + readonly typeFilter?: string; + readonly relTypes?: string[]; +} + +const DIAGRAM_KINDS = [ + "relationship", + "refinement", + "decision", + "dependency", +] as const; + +const KIND_CONFIG: Record = { + relationship: {}, + refinement: { relTypes: ["refines"] }, + decision: { relTypes: ["affects", "must_preserve", "supersedes"] }, + dependency: { relTypes: ["depends_on", "part_of"] }, +}; + +function isDiagramKind(value: string): value is DiagramKind { + return value in DIAGRAM_LABELS; +} + +function isLabelMode(value: string): value is "friendly" | "compact" { + return value === "friendly" || value === "compact"; +} + +let renderCounter = 0; + +// --- Filter initialisation --- + +function initialFilters(doc: SysProMDocument): FilterState { + const types = new Set(); + const statuses = new Set(); + for (const node of doc.nodes) { + types.add(node.type); + for (const key of Object.keys(node.lifecycle ?? {})) { + statuses.add(key); + } + } + return { visibleTypes: types, visibleStatuses: statuses }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7b9b38..ebdd6a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,15 @@ importers: '@sysprom/core': specifier: workspace:* version: link:../core + cytoscape: + specifier: 3.34.0 + version: 3.34.0 + cytoscape-fcose: + specifier: 2.2.0 + version: 2.2.0(cytoscape@3.34.0) + elkjs: + specifier: 0.11.1 + version: 0.11.1 mermaid: specifier: 11.15.0 version: 11.15.0 @@ -2628,6 +2637,9 @@ packages: electron-to-chromium@1.5.376: resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -7375,6 +7387,8 @@ snapshots: electron-to-chromium@1.5.376: {} + elkjs@0.11.1: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} From d555a5cd5aab3f3b75964964fea26d48278e30d7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 18:56:42 +0100 Subject: [PATCH 15/28] fix(web): use literal theme tokens in Cytoscape canvas stylesheet Cytoscape renders on a canvas and cannot resolve CSS custom properties (var(--...)) produced by vanilla-extract's createTheme. Extract the raw token values into a shared themeTokens object consumed by both the vanilla-extract theme (for DOM) and the Cytoscape stylesheet (for canvas), keeping a single source of truth. Also remove the custom wheelSensitivity to silence Cytoscape's cross-hardware warning. --- packages/web/src/graph/CytoscapeGraph.tsx | 1 - packages/web/src/graph/stylesheets.ts | 22 +++++++++++----------- packages/web/src/styles.css.ts | 12 ++++++++++-- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/web/src/graph/CytoscapeGraph.tsx b/packages/web/src/graph/CytoscapeGraph.tsx index 2795a55..d68da06 100644 --- a/packages/web/src/graph/CytoscapeGraph.tsx +++ b/packages/web/src/graph/CytoscapeGraph.tsx @@ -89,7 +89,6 @@ export function CytoscapeGraph({ container, elements: [], style: stylesheet, - wheelSensitivity: 0.2, minZoom: 0.05, maxZoom: 4, pixelRatio: 1.5, diff --git a/packages/web/src/graph/stylesheets.ts b/packages/web/src/graph/stylesheets.ts index 3a30dc6..8c6979a 100644 --- a/packages/web/src/graph/stylesheets.ts +++ b/packages/web/src/graph/stylesheets.ts @@ -5,7 +5,7 @@ * meaning (pending, deprecated, subsystem-bearing). */ import type { StylesheetJson, StylesheetJsonBlock, Css } from "cytoscape"; -import { theme } from "../styles.css"; +import { themeTokens } from "../styles.css"; /** Node shape literal union from Cytoscape. */ type NodeShape = Css.NodeShape; @@ -50,8 +50,8 @@ const TYPE_STYLES: Readonly> = { function typeStyle(type: string): TypeStyle { return ( TYPE_STYLES[type] ?? { - fill: theme.color.textMuted, - border: theme.color.text, + fill: themeTokens.color.textMuted, + border: themeTokens.color.text, shape: "ellipse", } ); @@ -82,7 +82,7 @@ const REL_STYLES: Readonly> = { }; function relStyle(type: string): RelStyle { - return REL_STYLES[type] ?? { colour: theme.color.textMuted, width: 1 }; + return REL_STYLES[type] ?? { colour: themeTokens.color.textMuted, width: 1 }; } /** @@ -97,8 +97,8 @@ export function buildStylesheet(): StylesheetJson { style: { label: "data(name)", "font-size": "10px", - "font-family": theme.font.body, - color: theme.color.text, + "font-family": themeTokens.font.body, + color: themeTokens.color.text, "text-valign": "bottom", "text-halign": "center", "text-margin-y": 4, @@ -119,12 +119,12 @@ export function buildStylesheet(): StylesheetJson { "curve-style": "bezier", "target-arrow-shape": "triangle", "arrow-scale": 0.9, - "line-color": theme.color.textMuted, - "target-arrow-color": theme.color.textMuted, + "line-color": themeTokens.color.textMuted, + "target-arrow-color": themeTokens.color.textMuted, width: 1.5, "text-rotation": "autorotate", "font-size": "8px", - color: theme.color.textMuted, + color: themeTokens.color.textMuted, opacity: 0.7, "transition-property": "opacity, line-color, width", "transition-duration": 150, @@ -201,8 +201,8 @@ export function buildStylesheet(): StylesheetJson { selector: "node:selected", style: { "border-width": 4, - "border-color": theme.color.accent, - "overlay-color": theme.color.accent, + "border-color": themeTokens.color.accent, + "overlay-color": themeTokens.color.accent, "overlay-opacity": 0.2, }, }, diff --git a/packages/web/src/styles.css.ts b/packages/web/src/styles.css.ts index 1416c82..e0bbc11 100644 --- a/packages/web/src/styles.css.ts +++ b/packages/web/src/styles.css.ts @@ -1,6 +1,12 @@ import { globalStyle, style, createTheme } from "@vanilla-extract/css"; -export const [themeClass, theme] = createTheme({ +/** + * Raw theme token values — the single source of truth for colours, fonts, and + * spacing. Both the vanilla-extract theme (for DOM elements) and the Cytoscape + * stylesheet (for canvas rendering, which cannot resolve CSS custom properties) + * consume these literals. + */ +export const themeTokens = { color: { bg: "#fafafa", surface: "#ffffff", @@ -23,7 +29,9 @@ export const [themeClass, theme] = createTheme({ lg: "16px", xl: "24px", }, -}); +} as const; + +export const [themeClass, theme] = createTheme(themeTokens); globalStyle("*", { boxSizing: "border-box", From 4f0be18fdd00c0ebb2e6ad343e1afe5ab0ae9e0e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 19:21:41 +0100 Subject: [PATCH 16/28] perf(web): lazy-load mermaid and elkjs to shrink the initial bundle Extract MermaidView into its own component loaded via React.lazy + Suspense, and replace the static elkjs import with a dynamic await import() inside computeElkPositions. Both heavy dependencies now ship as separate lazy chunks fetched only when the user activates the Mermaid view or the Layered (ELK) layout. The default layout remains Overview (fCoSE) so the first paint needs neither chunk. --- packages/web/src/graph/layouts.ts | 4 +- packages/web/src/tabs/GraphsTab.tsx | 216 +++----------------------- packages/web/src/tabs/MermaidView.tsx | 213 +++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 197 deletions(-) create mode 100644 packages/web/src/tabs/MermaidView.tsx diff --git a/packages/web/src/graph/layouts.ts b/packages/web/src/graph/layouts.ts index 3ebf7c5..3d76a79 100644 --- a/packages/web/src/graph/layouts.ts +++ b/packages/web/src/graph/layouts.ts @@ -6,7 +6,6 @@ * - **Overview** — fcose (force-directed) for a holistic picture. * - **Trace** — Cytoscape built-in breadthfirst from a selected node. */ -import ELK from "elkjs"; import type { LayoutOptions, ShapedLayoutOptions, @@ -49,6 +48,9 @@ export async function computeElkPositions( nodes: readonly Node[], edges: readonly { readonly source: string; readonly target: string }[], ): Promise> { + // Dynamic import so ELK is loaded in its own chunk, only when the Layered + // layout runs. This keeps ELK out of the initial bundle. + const { default: ELK } = await import("elkjs"); const elk = new ELK(); const elkNodes = nodes.map((node) => ({ id: node.id, diff --git a/packages/web/src/tabs/GraphsTab.tsx b/packages/web/src/tabs/GraphsTab.tsx index 29100b8..8ad345c 100644 --- a/packages/web/src/tabs/GraphsTab.tsx +++ b/packages/web/src/tabs/GraphsTab.tsx @@ -1,6 +1,5 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import mermaid from "mermaid"; -import { graphOp, type SysProMDocument } from "@sysprom/core"; +import React, { Suspense, lazy, useEffect, useMemo, useState } from "react"; +import { type SysProMDocument } from "@sysprom/core"; import { CytoscapeGraph } from "../graph/CytoscapeGraph"; import { GraphFilters, type FilterState } from "../graph/GraphFilters"; import { NodeDetails } from "../graph/NodeDetails"; @@ -9,7 +8,6 @@ import type { LayoutMode } from "../graph/layouts"; import { filterRow, select, - button, graphCanvas, graphWorkspace, layoutControls, @@ -17,12 +15,11 @@ import { muted, } from "../styles.css"; -mermaid.initialize({ - startOnLoad: false, - theme: "default", - securityLevel: "loose", - flowchart: { useMaxWidth: true }, -}); +// Lazy-load the Mermaid view so the mermaid library is fetched in its own +// chunk only when the user activates "View as Mermaid". +const MermaidView = lazy(() => + import("./MermaidView").then((mod) => ({ default: mod.MermaidView })), +); type View = "interactive" | "mermaid"; @@ -101,11 +98,19 @@ export function GraphsTab({ visibleNodeIds={visibleNodeIds} /> ) : ( - + + Loading Mermaid… +
+ } + > + + )}
); @@ -188,187 +193,6 @@ function InteractiveView({ ); } -function MermaidView({ - doc, - showSource, - onToggleSource, -}: { - readonly doc: SysProMDocument; - readonly showSource: boolean; - readonly onToggleSource: (next: boolean) => void; -}): React.ReactElement { - const [kind, setKind] = useState("relationship"); - const [labelMode, setLabelMode] = useState<"friendly" | "compact">( - "friendly", - ); - const containerRef = useRef(null); - const [error, setError] = useState(null); - - const diagram = useMemo(() => { - const config = KIND_CONFIG[kind]; - return graphOp({ - doc, - format: "mermaid", - layout: DIAGRAM_LAYOUT[kind], - cluster: true, - labelMode, - ...(config.relTypes ? { relTypes: config.relTypes } : {}), - ...(config.typeFilter ? { typeFilter: config.typeFilter } : {}), - }); - }, [doc, kind, labelMode]); - - useEffect(() => { - let cancelled = false; - const render = async (): Promise => { - const container = containerRef.current; - if (!container) return; - setError(null); - container.innerHTML = ""; - const id = `mmd-${String(renderCounter++)}`; - try { - const { svg } = await mermaid.render(id, diagram); - if (cancelled) return; - container.innerHTML = svg; - } catch (err) { - if (cancelled) return; - setError(err instanceof Error ? err.message : String(err)); - } - }; - void render(); - return () => { - cancelled = true; - }; - }, [diagram]); - - return ( -
-
- - - -
- {error && ( -
- {error} -
- )} -
- {showSource && ( -
-					{diagram}
-				
- )} -
- ); -} - -// --- Mermaid view configuration (preserved from the original GraphsTab) --- - -type DiagramKind = "relationship" | "refinement" | "decision" | "dependency"; - -const DIAGRAM_LABELS: Record = { - relationship: "Relationship Graph", - refinement: "Refinement Chain", - decision: "Decision Map", - dependency: "Dependency Graph", -}; - -const DIAGRAM_LAYOUT: Record = { - relationship: "TD", - refinement: "TD", - decision: "TD", - dependency: "LR", -}; - -interface GraphKindConfig { - readonly typeFilter?: string; - readonly relTypes?: string[]; -} - -const DIAGRAM_KINDS = [ - "relationship", - "refinement", - "decision", - "dependency", -] as const; - -const KIND_CONFIG: Record = { - relationship: {}, - refinement: { relTypes: ["refines"] }, - decision: { relTypes: ["affects", "must_preserve", "supersedes"] }, - dependency: { relTypes: ["depends_on", "part_of"] }, -}; - -function isDiagramKind(value: string): value is DiagramKind { - return value in DIAGRAM_LABELS; -} - -function isLabelMode(value: string): value is "friendly" | "compact" { - return value === "friendly" || value === "compact"; -} - -let renderCounter = 0; - // --- Filter initialisation --- function initialFilters(doc: SysProMDocument): FilterState { diff --git a/packages/web/src/tabs/MermaidView.tsx b/packages/web/src/tabs/MermaidView.tsx new file mode 100644 index 0000000..c168690 --- /dev/null +++ b/packages/web/src/tabs/MermaidView.tsx @@ -0,0 +1,213 @@ +/** + * Mermaid diagram view — lazy-loaded so that the `mermaid` library is only + * fetched when the user activates "View as Mermaid". + */ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { graphOp, type SysProMDocument } from "@sysprom/core"; +import { button, filterRow, select } from "../styles.css"; + +let mermaidInitialised = false; + +interface MermaidApi { + render: (id: string, text: string) => Promise<{ svg: string }>; +} + +/** + * Dynamically import mermaid (moving it into its own chunk) and initialise it + * on first use. Subsequent calls return the cached module. + */ +async function loadMermaid(): Promise { + const { default: mermaid } = await import("mermaid"); + if (!mermaidInitialised) { + mermaid.initialize({ + startOnLoad: false, + theme: "default", + securityLevel: "loose", + flowchart: { useMaxWidth: true }, + }); + mermaidInitialised = true; + } + return mermaid; +} + +export function MermaidView({ + doc, + showSource, + onToggleSource, +}: { + readonly doc: SysProMDocument; + readonly showSource: boolean; + readonly onToggleSource: (next: boolean) => void; +}): React.ReactElement { + const [kind, setKind] = useState("relationship"); + const [labelMode, setLabelMode] = useState<"friendly" | "compact">( + "friendly", + ); + const containerRef = useRef(null); + const [error, setError] = useState(null); + + const diagram = useMemo(() => { + const config = KIND_CONFIG[kind]; + return graphOp({ + doc, + format: "mermaid", + layout: DIAGRAM_LAYOUT[kind], + cluster: true, + labelMode, + ...(config.relTypes ? { relTypes: config.relTypes } : {}), + ...(config.typeFilter ? { typeFilter: config.typeFilter } : {}), + }); + }, [doc, kind, labelMode]); + + useEffect(() => { + let cancelled = false; + const render = async (): Promise => { + const container = containerRef.current; + if (!container) return; + setError(null); + container.innerHTML = ""; + const id = `mmd-${String(renderCounter++)}`; + try { + const mermaid = await loadMermaid(); + const { svg } = await mermaid.render(id, diagram); + if (cancelled) return; + container.innerHTML = svg; + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + } + }; + void render(); + return () => { + cancelled = true; + }; + }, [diagram]); + + return ( +
+
+ + + +
+ {error && ( +
+ {error} +
+ )} +
+ {showSource && ( +
+					{diagram}
+				
+ )} +
+ ); +} + +// --- Mermaid view configuration (preserved from the original GraphsTab) --- + +type DiagramKind = "relationship" | "refinement" | "decision" | "dependency"; + +const DIAGRAM_LABELS: Record = { + relationship: "Relationship Graph", + refinement: "Refinement Chain", + decision: "Decision Map", + dependency: "Dependency Graph", +}; + +const DIAGRAM_LAYOUT: Record = { + relationship: "TD", + refinement: "TD", + decision: "TD", + dependency: "LR", +}; + +interface GraphKindConfig { + readonly typeFilter?: string; + readonly relTypes?: string[]; +} + +const DIAGRAM_KINDS = [ + "relationship", + "refinement", + "decision", + "dependency", +] as const; + +const KIND_CONFIG: Record = { + relationship: {}, + refinement: { relTypes: ["refines"] }, + decision: { relTypes: ["affects", "must_preserve", "supersedes"] }, + dependency: { relTypes: ["depends_on", "part_of"] }, +}; + +function isDiagramKind(value: string): value is DiagramKind { + return value in DIAGRAM_LABELS; +} + +function isLabelMode(value: string): value is "friendly" | "compact" { + return value === "friendly" || value === "compact"; +} + +let renderCounter = 0; From 3edb724c1fd95c97b277ebee3f2d7af18edbd5a9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 19:27:34 +0100 Subject: [PATCH 17/28] fix(web): declare @vanilla-extract/css as a direct dependency The vanilla-extract compiler imports @vanilla-extract/css/adapter when transforming .css.ts files. It was only transitive (via the vite-plugin's compiler), which resolves under local hoisting but not in CI's stricter pnpm resolution. Declaring it directly on packages/web fixes the "Cannot find package '@vanilla-extract/css/adapter'" build-site failure. --- packages/web/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/web/package.json b/packages/web/package.json index 27c1c86..225d8d4 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-tabs": "1.1.15", "@radix-ui/react-tooltip": "1.2.10", "@sysprom/core": "workspace:*", + "@vanilla-extract/css": "1.20.1", "cytoscape": "3.34.0", "cytoscape-fcose": "2.2.0", "elkjs": "0.11.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebdd6a1..c496bb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: '@sysprom/core': specifier: workspace:* version: link:../core + '@vanilla-extract/css': + specifier: 1.20.1 + version: 1.20.1 cytoscape: specifier: 3.34.0 version: 3.34.0 From abea68ca3d5792421bec919756ff38ef6c68c1c3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 20:37:12 +0100 Subject: [PATCH 18/28] feat(web): add refinement, emergent, and subsystem graph layouts Replace the single ELK layered layout (broken in-browser: elkjs worker 404s under Vite) with five switchable layouts driven by the relationship backbone: - Refinement hierarchy (default): dagre top-down DAG over the structural backbone edges (refines, part_of, realises, implements, precedes, must_follow). Cross-cutting edges are hidden during layout and restored as overlays so they never drive the ranking. Direction emerges from the model, not a node-type taxonomy. - Emergent topology: fCoSE over the same backbone edges; clusters surface from connectivity with no imposed direction. - By subsystem: fCoSE compound layout grouping nodes by the recursive subsystem they belong to (flattened from node.subsystem.nodes). - Overview: fCoSE over all edges (unchanged). - Trace: breadthfirst from the selected node (unchanged). elkjs is removed; cytoscape-dagre replaces it (main-thread, no worker, bundles dagre). The columnar abstraction-layer rank code is removed. Also fixes a pre-existing selection bug: the core tap handler fired on every tap (including node taps) and immediately cleared the selection the node-tap handler had just set, so Trace (which needs a selected node) was unreachable in the browser. Guard the background handler on the tap target being the core. --- packages/web/package.json | 2 +- packages/web/src/graph/CytoscapeGraph.tsx | 99 ++++++--- packages/web/src/graph/elements.ts | 251 ++++++++++++++++++---- packages/web/src/graph/ext.d.ts | 3 +- packages/web/src/graph/layouts.ts | 205 +++++++++++------- packages/web/src/tabs/GraphsTab.tsx | 14 +- pnpm-lock.yaml | 20 +- 7 files changed, 440 insertions(+), 154 deletions(-) diff --git a/packages/web/package.json b/packages/web/package.json index 225d8d4..3c8cca4 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -17,8 +17,8 @@ "@sysprom/core": "workspace:*", "@vanilla-extract/css": "1.20.1", "cytoscape": "3.34.0", + "cytoscape-dagre": "4.0.0", "cytoscape-fcose": "2.2.0", - "elkjs": "0.11.1", "mermaid": "11.15.0", "react": "19.2.7", "react-dom": "19.2.7" diff --git a/packages/web/src/graph/CytoscapeGraph.tsx b/packages/web/src/graph/CytoscapeGraph.tsx index d68da06..6072f4d 100644 --- a/packages/web/src/graph/CytoscapeGraph.tsx +++ b/packages/web/src/graph/CytoscapeGraph.tsx @@ -13,20 +13,30 @@ import cytoscape, { type NodeSingular, } from "cytoscape"; import fcose from "cytoscape-fcose"; -import type { SysProMDocument, Node } from "@sysprom/core"; -import { buildElements, neighbourhoodElementIds } from "./elements"; +import dagre from "cytoscape-dagre"; +import type { SysProMDocument } from "@sysprom/core"; +import { + buildElements, + buildSubsystemElements, + neighbourhoodElementIds, +} from "./elements"; import { buildStylesheet } from "./stylesheets"; import { - computeElkPositions, - buildPresetLayout, + backboneSubgraph, + crossCuttingEdges, + buildRefinementLayoutOptions, + buildEmergentLayoutOptions, + buildSubsystemLayoutOptions, buildOverviewLayoutOptions, buildTraceLayoutOptions, toLayoutOptions, type LayoutMode, + type BackboneLayoutOptions, } from "./layouts"; -// Register the fcose layout extension once. +// Register the layout extensions once. cytoscape.use(fcose); +cytoscape.use(dagre); /** * Type guard narrowing an `EventObject.target` (typed as `any` by Cytoscape) @@ -106,8 +116,12 @@ export function CytoscapeGraph({ onSelectRef.current(target.id()); }; - // Background tap (no selector → fires on core): clear selection. - const handleBackgroundTap = (): void => { + // Background tap: clear selection. A core `tap` fires for every tap + // (including on nodes/edges), so guard on the target being the core + // itself — otherwise this handler would immediately undo the node-tap + // selection above. + const handleBackgroundTap = (event: EventObject): void => { + if (event.target !== cy) return; clearHighlight(cy); onSelectRef.current(null); }; @@ -121,20 +135,32 @@ export function CytoscapeGraph({ }; }, []); - // Update the full element set when the document changes. + // Rebuild the element set when the document changes, or when switching + // between the flat and subsystem (compound) element sets. The subsystem + // layout flattens the recursive document tree into compound clusters and + // needs a different element set; the other four layouts share the flat set. + const useSubsystemElements = layout === "subsystem"; useEffect(() => { const cy = cyRef.current; if (!cy) return; - const elements = buildElements(doc); + const elements = useSubsystemElements + ? buildSubsystemElements(doc) + : buildElements(doc); cy.elements().remove(); cy.add(elements); - }, [doc]); + }, [doc, useSubsystemElements]); - // Apply visibility filtering. + // Apply visibility filtering. Compound cluster parents (type "cluster") are + // always shown; child nodes follow the filter state. useEffect(() => { const cy = cyRef.current; if (!cy) return; cy.nodes().forEach((node) => { + if (node.data("type") === "cluster") { + node.removeClass("hidden"); + node.style("display", "element"); + return; + } const visible = visibleNodeIds.has(node.id()); if (visible) { node.removeClass("hidden"); @@ -156,13 +182,20 @@ export function CytoscapeGraph({ }); }, [visibleNodeIds]); - // Apply the active layout. + // Apply the active layout. Refinement and Emergent rank on the backbone + // edges only: cross-cutting edges are hidden during layout and restored as + // overlays once positions have settled. Subsystem uses a compound fcose. + // Overview runs fcose on all edges. Trace runs breadthfirst from a node. useEffect(() => { const cy = cyRef.current; if (!cy) return; - if (layout === "layered") { - void runElkLayered(cy, doc); + if (layout === "refinement") { + runBackboneLayout(cy, buildRefinementLayoutOptions()); + } else if (layout === "emergent") { + runBackboneLayout(cy, buildEmergentLayoutOptions()); + } else if (layout === "subsystem") { + cy.layout(toLayoutOptions(buildSubsystemLayoutOptions())).run(); } else if (layout === "overview") { cy.layout(toLayoutOptions(buildOverviewLayoutOptions())).run(); } else { @@ -171,26 +204,36 @@ export function CytoscapeGraph({ cy.layout(toLayoutOptions(buildTraceLayoutOptions(traceRootId))).run(); } } - }, [layout, traceRootId, doc]); + }, [layout, traceRootId, doc, useSubsystemElements]); return
; } /** - * Run the ELK layered layout asynchronously, then apply the computed positions - * via Cytoscape's preset layout. + * Run a layout that must rank on the backbone edges only. Cross-cutting edges + * are hidden before layout and restored afterwards, so they render as overlays + * between already-positioned nodes without influencing the ranking. + * + * The layout runs against the backbone subgraph (all visible nodes + backbone + * edges) via `Collection.layout`, which positions every node in the collection + * — including nodes connected only by cross-cutting edges (they appear as + * isolated nodes in the backbone subgraph and are packed by the layout). */ -async function runElkLayered(cy: Core, doc: SysProMDocument): Promise { - const visibleNodes = cy.nodes(":visible"); - const visibleNodeIds = new Set(visibleNodes.map((n) => n.id())); - const nodes: Node[] = doc.nodes.filter((node) => visibleNodeIds.has(node.id)); - const edges = doc.relationships ?? []; - const visibleEdges = edges - .filter((rel) => visibleNodeIds.has(rel.from) && visibleNodeIds.has(rel.to)) - .map((rel) => ({ source: rel.from, target: rel.to })); - - const positions = await computeElkPositions(nodes, visibleEdges); - cy.layout(toLayoutOptions(buildPresetLayout(positions))).run(); +function runBackboneLayout( + cy: Core, + backboneOptions: BackboneLayoutOptions, +): void { + const overlays = crossCuttingEdges(cy); + overlays.style("display", "none"); + const subgraph = backboneSubgraph(cy); + const layout = subgraph.layout(toLayoutOptions(backboneOptions)); + layout.one("layoutstop", () => { + // Restore cross-cutting overlays now that nodes have settled; positions + // are kept, so the overlays render between the already-placed nodes. + overlays.style("display", "element"); + cy.fit(undefined, 40); + }); + layout.run(); } /** Highlight a node's neighbourhood and dim everything else. */ diff --git a/packages/web/src/graph/elements.ts b/packages/web/src/graph/elements.ts index 332fb4f..195342a 100644 --- a/packages/web/src/graph/elements.ts +++ b/packages/web/src/graph/elements.ts @@ -4,50 +4,55 @@ * The document is the single source of truth — we never parse a Mermaid string. * Nodes map to Cytoscape nodes, relationships map to Cytoscape edges, with * deduplication by a composite key. + * + * Relationship types are split into two families so the layout engines can + * rank on the structural backbone (refinement / decomposition / realisation) + * and treat the cross-cutting edges (governance, constraint, impact) as + * overlays that never drive the ranking. */ import type { Core, ElementDefinition } from "cytoscape"; -import type { SysProMDocument } from "@sysprom/core"; +import type { SysProMDocument, Node, Relationship } from "@sysprom/core"; import { primaryLifecycleState } from "@sysprom/core"; -/** The SysProM abstraction layers, in canonical refinement order. */ -export const ABSTRACTION_LAYERS = [ - "intent", - "concept", - "capability", - "element", - "realisation", - "artefact", -] as const; +/** + * Backbone relationship types — the structural refinement / decomposition / + * realisation edges. These define the direction of the model: intents sit at + * the top, realisations and artefacts at the leaves, purely because the edges + * flow that way (not from a node-type taxonomy). + */ +export const BACKBONE_REL_TYPES: ReadonlySet = new Set([ + "refines", + "part_of", + "realises", + "implements", + "precedes", + "must_follow", +]); /** - * A numeric rank for each node type, used by the ELK layered layout to group - * nodes by abstraction layer. Types not in the canonical chain are ordered - * after it but kept stable. + * Cross-cutting relationship types — governance, constraint, dependency, and + * impact edges that span the refinement hierarchy. They are drawn as overlays + * between already-positioned nodes and must never drive the ranking. */ -const LAYER_RANK: Readonly> = { - intent: 0, - concept: 1, - capability: 2, - element: 3, - realisation: 4, - artefact: 5, - invariant: 6, - principle: 7, - policy: 8, - protocol: 9, - stage: 10, - role: 11, - gate: 12, - mode: 13, - decision: 14, - change: 15, - view: 16, - milestone: 17, -}; - -/** Map a node type to its ELK layered-layout rank. Lower = earlier layer. */ -export function layerRank(type: string): number { - return LAYER_RANK[type] ?? 99; +export const CROSS_CUTTING_REL_TYPES: ReadonlySet = new Set([ + "affects", + "must_preserve", + "depends_on", + "constrained_by", + "governed_by", + "supersedes", + "modifies", + "produces", +]); + +/** True if a relationship type belongs to the structural backbone. */ +export function isBackboneRelationship(type: string): boolean { + return BACKBONE_REL_TYPES.has(type); +} + +/** True if a relationship type is cross-cutting (governance / constraint / impact). */ +export function isCrossCuttingRelationship(type: string): boolean { + return CROSS_CUTTING_REL_TYPES.has(type); } /** Determine whether a node status indicates incompleteness (dashed style). */ @@ -77,7 +82,11 @@ export interface SyspromNodeData { description: string | undefined; hasSubsystem: boolean; subsystemNodeCount: number; - layerRank: number; + /** + * ID of the compound parent for the "By subsystem" layout, or undefined for + * top-level nodes. Populated by `buildSubsystemElements`. + */ + parent: string | undefined; } /** Extra data carried on each Cytoscape edge element. */ @@ -110,7 +119,7 @@ export function buildElements(doc: SysProMDocument): ElementDefinition[] { description, hasSubsystem: subsystem !== undefined, subsystemNodeCount, - layerRank: layerRank(node.type), + parent: undefined, }; elements.push({ group: "nodes", data }); } @@ -151,3 +160,167 @@ export function neighbourhoodElementIds(cy: Core, nodeId: string): Set { }); return result; } + +// --------------------------------------------------------------------------- +// Subsystem clustering — flatten the recursive document tree into a compound +// graph for the "By subsystem" layout. +// --------------------------------------------------------------------------- + +/** A node paired with the ID of the subsystem cluster that owns it. */ +export interface ClusteredNode { + readonly node: Node; + /** Cluster parent ID: the owning subsystem node's ID, or "root" for top level. */ + readonly clusterId: string; +} + +/** Cluster (compound parent) descriptor for the subsystem layout. */ +export interface SubsystemCluster { + readonly id: string; + readonly name: string; + readonly nodeCount: number; +} + +/** Result of flattening a document into clustered nodes plus cluster metadata. */ +export interface FlattenedDocument { + readonly clusteredNodes: readonly ClusteredNode[]; + readonly clusters: readonly SubsystemCluster[]; + /** All relationships across all levels of the tree. */ + readonly relationships: readonly Relationship[]; +} + +/** + * Flatten the recursive SysProM document tree into a single list of nodes + * tagged with the ID of the subsystem cluster they belong to. Top-level nodes + * belong to the synthetic "root" cluster; nodes inside a node's `subsystem` + * belong to that owner's cluster. Relationships are gathered from every level. + * + * Nesting deeper than one level is supported: a node inside a nested subsystem + * is assigned to its immediate parent cluster. + */ +export function flattenWithSubsystem(doc: SysProMDocument): FlattenedDocument { + const clusteredNodes: ClusteredNode[] = []; + const relationships: Relationship[] = []; + const clusterCounts = new Map(); + + const visit = (nodes: readonly Node[], clusterId: string): void => { + for (const node of nodes) { + clusteredNodes.push({ node, clusterId }); + clusterCounts.set(clusterId, (clusterCounts.get(clusterId) ?? 0) + 1); + const subsystem = node.subsystem; + if (subsystem) { + if (subsystem.relationships) { + relationships.push(...subsystem.relationships); + } + visit(subsystem.nodes, node.id); + } + } + }; + + if (doc.relationships) { + relationships.push(...doc.relationships); + } + visit(doc.nodes, "root"); + + const ownerName = new Map(); + for (const cn of clusteredNodes) { + ownerName.set(cn.node.id, cn.node.name); + } + + const clusters: SubsystemCluster[] = [...clusterCounts.entries()] + .map(([id, count]) => ({ + id, + name: clusterDisplayName(id, ownerName), + nodeCount: count, + })) + .sort(compareClusters); + + return { clusteredNodes, clusters, relationships }; +} + +/** Human-readable name for a cluster: "Top level" for root, else the owner node's name. */ +function clusterDisplayName( + id: string, + ownerName: ReadonlyMap, +): string { + if (id === "root") return "Top level"; + return ownerName.get(id) ?? id; +} + +/** Sort clusters: root first, then the rest alphabetically by name. */ +function compareClusters(a: SubsystemCluster, b: SubsystemCluster): number { + if (a.id === "root") return -1; + if (b.id === "root") return 1; + return a.name.localeCompare(b.name); +} + +/** + * Build Cytoscape element definitions for the "By subsystem" compound layout: + * one compound parent per cluster, child nodes nested under their cluster, and + * edges from every level of the tree. Compound parents render as labelled + * bounding boxes around their children when fcose runs in compound mode. + */ +export function buildSubsystemElements( + doc: SysProMDocument, +): ElementDefinition[] { + const { clusteredNodes, clusters, relationships } = flattenWithSubsystem(doc); + const elements: ElementDefinition[] = []; + const seenEdges = new Set(); + + // Compound parent nodes — one per cluster. + for (const cluster of clusters) { + elements.push({ + group: "nodes", + data: { + id: clusterId(cluster.id), + name: `${cluster.name} (${String(cluster.nodeCount)})`, + type: "cluster", + }, + }); + } + + // Child nodes, each pointing at its compound parent. + for (const { node, clusterId: owner } of clusteredNodes) { + const status = primaryLifecycleState(node); + const subsystem = node.subsystem; + const description = + typeof node.description === "string" ? node.description : undefined; + const data: SyspromNodeData = { + id: node.id, + type: node.type, + name: node.name, + status, + lifecycle: node.lifecycle, + description, + hasSubsystem: subsystem !== undefined, + subsystemNodeCount: subsystem ? subsystem.nodes.length : 0, + parent: clusterId(owner), + }; + elements.push({ group: "nodes", data }); + } + + // Edges across all levels. Compound parents are never edge endpoints. + for (const rel of relationships) { + const id = `${rel.from}->${rel.to}:${rel.type}`; + if (seenEdges.has(id)) continue; + seenEdges.add(id); + const data: SyspromEdgeData = { + id, + source: rel.from, + target: rel.to, + type: rel.type, + polarity: rel.polarity, + strength: rel.strength, + }; + elements.push({ group: "edges", data }); + } + + return elements; +} + +/** + * Compound parent ID for a cluster. Prefixed so it cannot collide with real + * node IDs (which match type-prefix patterns like `INT1`, `ELEM5`). + */ +function clusterId(ownerId: string): string { + return `cluster:${ownerId}`; +} diff --git a/packages/web/src/graph/ext.d.ts b/packages/web/src/graph/ext.d.ts index 2326566..9e6d214 100644 --- a/packages/web/src/graph/ext.d.ts +++ b/packages/web/src/graph/ext.d.ts @@ -1,6 +1,7 @@ /** * Ambient type declarations for Cytoscape extensions that ship without - * TypeScript definitions. + * TypeScript definitions. cytoscape-dagre ships its own index.d.ts and + * augments the `cytoscape` namespace directly, so it needs no entry here. */ declare module "cytoscape-fcose" { diff --git a/packages/web/src/graph/layouts.ts b/packages/web/src/graph/layouts.ts index 3d76a79..19b3d09 100644 --- a/packages/web/src/graph/layouts.ts +++ b/packages/web/src/graph/layouts.ts @@ -1,22 +1,35 @@ /** - * Layout configurations for the three interactive graph modes. + * Layout configurations for the five interactive graph modes. * - * - **Layered** — ELK layered/hierarchical, mapping SysProM abstraction layers - * (intent->concept->...->artefact) into ELK layer direction. - * - **Overview** — fcose (force-directed) for a holistic picture. - * - **Trace** — Cytoscape built-in breadthfirst from a selected node. + * The relationship graph is split into a structural backbone (refines, + * part_of, realises, implements, precedes, must_follow) and cross-cutting + * overlays (affects, must_preserve, depends_on, constrained_by, governed_by, + * supersedes, modifies, produces). The Refinement hierarchy and Emergent + * topology layouts rank on the backbone only; cross-cutting edges are hidden + * during layout and restored afterwards so they render as overlays between + * already-positioned nodes. + * + * - **Refinement hierarchy** (default) — dagre top-down DAG over backbone edges. + * - **Emergent topology** — fcose over backbone edges; clusters surface from connectivity. + * - **By subsystem** — fcose compound layout grouping nodes by recursive subsystem. + * - **Overview** — fcose over all edges. + * - **Trace** — Cytoscape breadthfirst from a selected node. */ import type { LayoutOptions, ShapedLayoutOptions, - PresetLayoutOptions, BreadthFirstLayoutOptions, - Position, + Core, + Collection, } from "cytoscape"; -import type { Node } from "@sysprom/core"; -import { layerRank } from "./elements"; +import { BACKBONE_REL_TYPES, CROSS_CUTTING_REL_TYPES } from "./elements"; -export type LayoutMode = "layered" | "overview" | "trace"; +export type LayoutMode = + | "refinement" + | "emergent" + | "subsystem" + | "overview" + | "trace"; /** * Options for the fcose layout extension. fcose has no bundled TypeScript @@ -26,86 +39,130 @@ export type LayoutMode = "layered" | "overview" | "trace"; interface FcoseLayoutOptions extends ShapedLayoutOptions { name: "fcose"; randomize?: boolean; - nodeRepulsion?: number; - idealEdgeLength?: number; - edgeElasticity?: number; + nodeRepulsion?: number | ((node: unknown) => number); + idealEdgeLength?: number | ((edge: unknown) => number); + edgeElasticity?: number | ((edge: unknown) => number); gravity?: number; numIter?: number; tile?: boolean; packComponents?: boolean; + quality?: "draft" | "default"; } /** - * Run ELK layered layout asynchronously against the visible nodes and edges and - * resolve with a map of node ID -> {x, y}. The component applies the result via - * Cytoscape's `preset` layout. - * - * The SysProM abstraction-layer rank (intent=0, concept=1, ...) is fed to ELK - * as `elk.layered.priority.direction` so earlier layers settle above later ones - * while still respecting the actual edges. + * Options for the dagre layout extension (cytoscape-dagre). Declared inline + * because cytoscape-dagre's own `.d.ts` lives in node_modules and is not + * exported as a value type we can extend here; this mirrors its documented + * option set and is passed straight through to Cytoscape at runtime. + */ +interface DagreLayoutOptions extends ShapedLayoutOptions { + name: "dagre"; + rankDir?: "TB" | "BT" | "LR" | "RL"; + nodeSep?: number; + edgeSep?: number; + rankSep?: number; + ranker?: "network-simplex" | "tight-tree" | "longest-path"; + acyclicer?: "greedy"; + fit?: boolean; + padding?: number; + spacingFactor?: number; +} + +/** + * Select the visible elements that drive a layout: all visible nodes plus only + * the edges whose type is in the backbone set. Cross-cutting edges are + * excluded so they cannot influence the ranking/cluster computation. */ -export async function computeElkPositions( - nodes: readonly Node[], - edges: readonly { readonly source: string; readonly target: string }[], -): Promise> { - // Dynamic import so ELK is loaded in its own chunk, only when the Layered - // layout runs. This keeps ELK out of the initial bundle. - const { default: ELK } = await import("elkjs"); - const elk = new ELK(); - const elkNodes = nodes.map((node) => ({ - id: node.id, - width: 40, - height: 40, - layoutOptions: { - "elk.layered.priority.direction": String(layerRank(node.type)), - }, - })); - const elkEdges = edges.map((edge, index) => ({ - id: `e${String(index)}`, - sources: [edge.source], - targets: [edge.target], - })); - const elkGraph = { - id: "root", - layoutOptions: { - "elk.algorithm": "layered", - "elk.direction": "DOWN", - "elk.layered.spacing.nodeNodeBetweenLayers": "70", - "elk.spacing.nodeNode": "50", - "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", - "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", - }, - children: elkNodes, - edges: elkEdges, +export function backboneSubgraph(cy: Core): Collection { + const visibleNodes = cy.nodes(":visible"); + const backboneEdges = cy + .edges(":visible") + .filter((edge) => isBackboneType(edge.data("type"))); + return visibleNodes.union(backboneEdges); +} + +/** + * Select cross-cutting edges (visible) for the hide/restore overlay dance. + */ +export function crossCuttingEdges(cy: Core): Collection { + return cy + .edges(":visible") + .filter((edge) => isCrossCuttingType(edge.data("type"))); +} + +function isBackboneType(type: unknown): boolean { + return typeof type === "string" && BACKBONE_REL_TYPES.has(type); +} + +function isCrossCuttingType(type: unknown): boolean { + return typeof type === "string" && CROSS_CUTTING_REL_TYPES.has(type); +} + +/** Build the dagre options for the Refinement hierarchy (top-down DAG). */ +export function buildRefinementLayoutOptions(): DagreLayoutOptions { + return { + name: "dagre", + rankDir: "TB", + nodeSep: 50, + edgeSep: 20, + rankSep: 70, + ranker: "network-simplex", + acyclicer: "greedy", + animate: true, + animationDuration: 500, + animationEasing: "ease-out", + fit: true, + padding: 40, + spacingFactor: 1.1, }; - const result = await elk.layout(elkGraph); - const positions = new Map< - string, - { readonly x: number; readonly y: number } - >(); - for (const child of result.children ?? []) { - const x = child.x ?? 0; - const y = child.y ?? 0; - positions.set(child.id, { x: x + 20, y: y + 20 }); - } - return positions; } -/** Build a Cytoscape `preset` layout that applies the given positions. */ -export function buildPresetLayout( - positions: ReadonlyMap, -): PresetLayoutOptions { - const positionFunction = (nodeId: string): Position => { - const pos = positions.get(nodeId); - return pos ?? { x: 0, y: 0 }; +/** + * Union of the layout-option shapes that can drive a backbone-ranking layout + * (dagre for the hierarchy, fcose for the emergent topology). Used as the + * parameter type of `runBackboneLayout` in CytoscapeGraph. + */ +export type BackboneLayoutOptions = DagreLayoutOptions | FcoseLayoutOptions; + +/** Build the fcose options for the Emergent topology (backbone-driven clusters). */ +export function buildEmergentLayoutOptions(): FcoseLayoutOptions { + return { + name: "fcose", + animate: true, + animationDuration: 600, + animationEasing: "ease-out", + fit: true, + padding: 40, + randomize: true, + nodeRepulsion: 12000, + idealEdgeLength: 120, + edgeElasticity: 0.45, + gravity: 0.2, + numIter: 3000, + tile: true, + packComponents: true, + quality: "default", }; +} + +/** Build the fcose compound options for the By subsystem layout. */ +export function buildSubsystemLayoutOptions(): FcoseLayoutOptions { return { - name: "preset", + name: "fcose", animate: true, - animationDuration: 400, + animationDuration: 700, + animationEasing: "ease-out", fit: true, padding: 40, - positions: positionFunction, + randomize: true, + nodeRepulsion: 6000, + idealEdgeLength: 80, + edgeElasticity: 0.45, + gravity: 0.3, + numIter: 2500, + tile: true, + packComponents: true, + quality: "default", }; } diff --git a/packages/web/src/tabs/GraphsTab.tsx b/packages/web/src/tabs/GraphsTab.tsx index 8ad345c..9b1df71 100644 --- a/packages/web/src/tabs/GraphsTab.tsx +++ b/packages/web/src/tabs/GraphsTab.tsx @@ -24,12 +24,20 @@ const MermaidView = lazy(() => type View = "interactive" | "mermaid"; const LAYOUT_LABELS: Readonly> = { - layered: "Layered (ELK)", + refinement: "Refinement hierarchy", + emergent: "Emergent topology", + subsystem: "By subsystem", overview: "Overview (fCoSE)", trace: "Trace from selected", }; -const LAYOUT_MODES: readonly LayoutMode[] = ["layered", "overview", "trace"]; +const LAYOUT_MODES: readonly LayoutMode[] = [ + "refinement", + "emergent", + "subsystem", + "overview", + "trace", +]; export function GraphsTab({ doc, @@ -37,7 +45,7 @@ export function GraphsTab({ readonly doc: SysProMDocument; }): React.ReactElement { const [view, setView] = useState("interactive"); - const [layout, setLayout] = useState("overview"); + const [layout, setLayout] = useState("refinement"); const [selectedId, setSelectedId] = useState(null); const [showMermaidSource, setShowMermaidSource] = useState(false); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c496bb4..1eda72b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,12 +160,12 @@ importers: cytoscape: specifier: 3.34.0 version: 3.34.0 + cytoscape-dagre: + specifier: 4.0.0 + version: 4.0.0(cytoscape@3.34.0) cytoscape-fcose: specifier: 2.2.0 version: 2.2.0(cytoscape@3.34.0) - elkjs: - specifier: 0.11.1 - version: 0.11.1 mermaid: specifier: 11.15.0 version: 11.15.0 @@ -2395,6 +2395,11 @@ packages: peerDependencies: cytoscape: ^3.2.0 + cytoscape-dagre@4.0.0: + resolution: {integrity: sha512-wsDgi1hHpWfslQxe/Gd/xCCeg2YUDrzwulhHOSI7fMfll92FFVY2AWhSRulLiOVcyDE4zz0rRd/+1TBTw+ew5Q==} + peerDependencies: + cytoscape: ^3.2.22 + cytoscape-fcose@2.2.0: resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} peerDependencies: @@ -2640,9 +2645,6 @@ packages: electron-to-chromium@1.5.376: resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} - elkjs@0.11.1: - resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} - emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -7145,6 +7147,10 @@ snapshots: cose-base: 1.0.3 cytoscape: 3.34.0 + cytoscape-dagre@4.0.0(cytoscape@3.34.0): + dependencies: + cytoscape: 3.34.0 + cytoscape-fcose@2.2.0(cytoscape@3.34.0): dependencies: cose-base: 2.2.0 @@ -7390,8 +7396,6 @@ snapshots: electron-to-chromium@1.5.376: {} - elkjs@0.11.1: {} - emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} From 43b5c8abe21fd3ef5e482f682301bb639c1d68d6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 21:06:56 +0100 Subject: [PATCH 19/28] fix(web): include governance edges in emergent topology so nodes cluster instead of gridding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Emergent topology layout previously ran fCoSE on only the backbone edges (refines, part_of, realises, implements, precedes, must_follow), hiding the entire cross-cutting set during layout. Those cross-cutting edges are precisely what connect decisions, changes, invariants, principles, and policies to the rest of the model, so the nodes with only governance/impact edges had no edges in the layout subgraph and were packed by fCoSE into a disconnected grid block at the bottom of the Emergent view. Define EMERGENT_REL_TYPES = backbone plus {affects, must_preserve, depends_on, constrained_by, governed_by, modifies, produces} — every relationship type except supersedes (pure historical replacement that adds noise without contributing to clustering). The Emergent layout now ranks on this broader set, so decisions cluster near the capabilities they affect, invariants near the nodes they constrain, and changes near the nodes they modify. Only supersedes edges are hidden during layout and restored as overlays. Parameterise the layout runner: Refinement still uses the strict backbone (clean top-down tree via dagre), Emergent uses the emergent edge set (fCoSE). Tune fCoSE for the denser graph: higher node repulsion, longer ideal edges, packComponents disabled. --- packages/web/src/graph/CytoscapeGraph.tsx | 63 +++++++++++++------ packages/web/src/graph/elements.ts | 32 ++++++++++ packages/web/src/graph/layouts.ts | 77 ++++++++++++++++++----- 3 files changed, 138 insertions(+), 34 deletions(-) diff --git a/packages/web/src/graph/CytoscapeGraph.tsx b/packages/web/src/graph/CytoscapeGraph.tsx index 6072f4d..5e5d43f 100644 --- a/packages/web/src/graph/CytoscapeGraph.tsx +++ b/packages/web/src/graph/CytoscapeGraph.tsx @@ -9,6 +9,7 @@ import React, { useEffect, useRef } from "react"; import cytoscape, { type Core, + type Collection, type EventObject, type NodeSingular, } from "cytoscape"; @@ -24,6 +25,8 @@ import { buildStylesheet } from "./stylesheets"; import { backboneSubgraph, crossCuttingEdges, + emergentSubgraph, + nonEmergentEdges, buildRefinementLayoutOptions, buildEmergentLayoutOptions, buildSubsystemLayoutOptions, @@ -182,18 +185,31 @@ export function CytoscapeGraph({ }); }, [visibleNodeIds]); - // Apply the active layout. Refinement and Emergent rank on the backbone - // edges only: cross-cutting edges are hidden during layout and restored as - // overlays once positions have settled. Subsystem uses a compound fcose. - // Overview runs fcose on all edges. Trace runs breadthfirst from a node. + // Apply the active layout. Refinement ranks on the strict backbone edges + // (a clean top-down DAG); Emergent ranks on the broader emergent edge set + // (backbone plus governance / impact) so decisions, changes, invariants, + // and policies cluster near their targets. In both modes the edges not + // used for ranking are hidden during layout and restored as overlays once + // positions have settled. Subsystem uses a compound fcose. Overview runs + // fcose on all edges. Trace runs breadthfirst from a node. useEffect(() => { const cy = cyRef.current; if (!cy) return; if (layout === "refinement") { - runBackboneLayout(cy, buildRefinementLayoutOptions()); + runRankedLayout( + cy, + buildRefinementLayoutOptions(), + backboneSubgraph, + crossCuttingEdges, + ); } else if (layout === "emergent") { - runBackboneLayout(cy, buildEmergentLayoutOptions()); + runRankedLayout( + cy, + buildEmergentLayoutOptions(), + emergentSubgraph, + nonEmergentEdges, + ); } else if (layout === "subsystem") { cy.layout(toLayoutOptions(buildSubsystemLayoutOptions())).run(); } else if (layout === "overview") { @@ -210,26 +226,33 @@ export function CytoscapeGraph({ } /** - * Run a layout that must rank on the backbone edges only. Cross-cutting edges - * are hidden before layout and restored afterwards, so they render as overlays - * between already-positioned nodes without influencing the ranking. + * Run a layout that ranks on a selected subgraph of edges. The edges not used + * for ranking are hidden before layout and restored afterwards, so they render + * as overlays between already-positioned nodes without influencing the + * ranking. * - * The layout runs against the backbone subgraph (all visible nodes + backbone - * edges) via `Collection.layout`, which positions every node in the collection - * — including nodes connected only by cross-cutting edges (they appear as - * isolated nodes in the backbone subgraph and are packed by the layout). + * - Refinement passes `backboneSubgraph` + `crossCuttingEdges` (strict + * backbone ranking; all governance/impact edges are overlays). + * - Emergent passes `emergentSubgraph` + `nonEmergentEdges` (backbone plus + * governance/impact ranking; only `supersedes` edges are overlays). + * + * The layout runs against the subgraph (all visible nodes + the selected + * edges) via `Collection.layout`, which positions every node in the + * collection — including any nodes connected only by overlay edges. */ -function runBackboneLayout( +function runRankedLayout( cy: Core, - backboneOptions: BackboneLayoutOptions, + options: BackboneLayoutOptions, + selectSubgraph: (cy: Core) => Collection, + selectOverlays: (cy: Core) => Collection, ): void { - const overlays = crossCuttingEdges(cy); + const overlays = selectOverlays(cy); overlays.style("display", "none"); - const subgraph = backboneSubgraph(cy); - const layout = subgraph.layout(toLayoutOptions(backboneOptions)); + const subgraph = selectSubgraph(cy); + const layout = subgraph.layout(toLayoutOptions(options)); layout.one("layoutstop", () => { - // Restore cross-cutting overlays now that nodes have settled; positions - // are kept, so the overlays render between the already-placed nodes. + // Restore overlay edges now that nodes have settled; positions are + // kept, so the overlays render between the already-placed nodes. overlays.style("display", "element"); cy.fit(undefined, 40); }); diff --git a/packages/web/src/graph/elements.ts b/packages/web/src/graph/elements.ts index 195342a..ad998be 100644 --- a/packages/web/src/graph/elements.ts +++ b/packages/web/src/graph/elements.ts @@ -45,6 +45,30 @@ export const CROSS_CUTTING_REL_TYPES: ReadonlySet = new Set([ "produces", ]); +/** + * Emergent topology relationship types — the backbone plus the governance / + * constraint / dependency / impact edges that connect decisions, changes, + * invariants, principles, and policies to the rest of the model. Every + * relationship type is included *except* `supersedes`, which is pure + * historical replacement and adds noise without contributing to clustering. + * + * The Refinement hierarchy uses the strict backbone (a clean top-down tree); + * the Emergent topology uses this broader set so that decisions cluster near + * the capabilities they affect, invariants near the nodes they constrain, and + * changes near the nodes they modify — instead of being packed into a + * disconnected grid block. + */ +export const EMERGENT_REL_TYPES: ReadonlySet = new Set([ + ...BACKBONE_REL_TYPES, + "affects", + "must_preserve", + "depends_on", + "constrained_by", + "governed_by", + "modifies", + "produces", +]); + /** True if a relationship type belongs to the structural backbone. */ export function isBackboneRelationship(type: string): boolean { return BACKBONE_REL_TYPES.has(type); @@ -55,6 +79,14 @@ export function isCrossCuttingRelationship(type: string): boolean { return CROSS_CUTTING_REL_TYPES.has(type); } +/** + * True if a relationship type drives the Emergent topology layout — the + * backbone plus governance / impact edges (everything except `supersedes`). + */ +export function isEmergentRelationship(type: string): boolean { + return EMERGENT_REL_TYPES.has(type); +} + /** Determine whether a node status indicates incompleteness (dashed style). */ export function isPendingStatus(status: string | undefined): boolean { return ( diff --git a/packages/web/src/graph/layouts.ts b/packages/web/src/graph/layouts.ts index 19b3d09..9f6b3a4 100644 --- a/packages/web/src/graph/layouts.ts +++ b/packages/web/src/graph/layouts.ts @@ -4,13 +4,19 @@ * The relationship graph is split into a structural backbone (refines, * part_of, realises, implements, precedes, must_follow) and cross-cutting * overlays (affects, must_preserve, depends_on, constrained_by, governed_by, - * supersedes, modifies, produces). The Refinement hierarchy and Emergent - * topology layouts rank on the backbone only; cross-cutting edges are hidden - * during layout and restored afterwards so they render as overlays between - * already-positioned nodes. + * supersedes, modifies, produces). + * + * The Refinement hierarchy ranks on the backbone only; the Emergent topology + * ranks on the backbone *plus* the governance / impact edges + * (`EMERGENT_REL_TYPES` — everything except `supersedes`), so that decisions, + * changes, invariants, principles, and policies cluster near the nodes they + * affect or constrain rather than being packed into a disconnected grid. In + * both modes the edges not used for ranking are hidden during layout and + * restored afterwards so they render as overlays between already-positioned + * nodes. * * - **Refinement hierarchy** (default) — dagre top-down DAG over backbone edges. - * - **Emergent topology** — fcose over backbone edges; clusters surface from connectivity. + * - **Emergent topology** — fcose over emergent edges; clusters surface from connectivity. * - **By subsystem** — fcose compound layout grouping nodes by recursive subsystem. * - **Overview** — fcose over all edges. * - **Trace** — Cytoscape breadthfirst from a selected node. @@ -22,7 +28,11 @@ import type { Core, Collection, } from "cytoscape"; -import { BACKBONE_REL_TYPES, CROSS_CUTTING_REL_TYPES } from "./elements"; +import { + BACKBONE_REL_TYPES, + CROSS_CUTTING_REL_TYPES, + EMERGENT_REL_TYPES, +} from "./elements"; export type LayoutMode = | "refinement" @@ -82,7 +92,23 @@ export function backboneSubgraph(cy: Core): Collection { } /** - * Select cross-cutting edges (visible) for the hide/restore overlay dance. + * Select the visible elements that drive the Emergent topology layout: all + * visible nodes plus the edges whose type is in the emergent set (backbone + * plus governance / impact — everything except `supersedes`). This keeps + * decisions, changes, invariants, and policies connected to their targets so + * they cluster naturally instead of being packed into a disconnected grid. + */ +export function emergentSubgraph(cy: Core): Collection { + const visibleNodes = cy.nodes(":visible"); + const emergentEdges = cy + .edges(":visible") + .filter((edge) => isEmergentType(edge.data("type"))); + return visibleNodes.union(emergentEdges); +} + +/** + * Select cross-cutting edges (visible) for the Refinement hide/restore overlay + * dance — the full set of non-backbone types. */ export function crossCuttingEdges(cy: Core): Collection { return cy @@ -90,6 +116,17 @@ export function crossCuttingEdges(cy: Core): Collection { .filter((edge) => isCrossCuttingType(edge.data("type"))); } +/** + * Select the edges hidden during the Emergent layout: only `supersedes`, the + * single relationship type excluded from `EMERGENT_REL_TYPES`. These are + * restored afterwards so they render as overlays between positioned nodes. + */ +export function nonEmergentEdges(cy: Core): Collection { + return cy + .edges(":visible") + .filter((edge) => !isEmergentType(edge.data("type"))); +} + function isBackboneType(type: unknown): boolean { return typeof type === "string" && BACKBONE_REL_TYPES.has(type); } @@ -98,6 +135,10 @@ function isCrossCuttingType(type: unknown): boolean { return typeof type === "string" && CROSS_CUTTING_REL_TYPES.has(type); } +function isEmergentType(type: unknown): boolean { + return typeof type === "string" && EMERGENT_REL_TYPES.has(type); +} + /** Build the dagre options for the Refinement hierarchy (top-down DAG). */ export function buildRefinementLayoutOptions(): DagreLayoutOptions { return { @@ -124,7 +165,15 @@ export function buildRefinementLayoutOptions(): DagreLayoutOptions { */ export type BackboneLayoutOptions = DagreLayoutOptions | FcoseLayoutOptions; -/** Build the fcose options for the Emergent topology (backbone-driven clusters). */ +/** + * Build the fcose options for the Emergent topology. Ranks on the emergent + * edge set (backbone plus governance / impact), so far more edges are present + * than in the Refinement hierarchy. Tuned for clean cluster separation: + * higher node repulsion and longer ideal edges keep decisions, invariants, + * and policies from collapsing onto their targets, while `packComponents` is + * disabled — the broader edge set leaves almost no isolated components, so + * packing would only add an unnecessary grid pass on the handful that remain. + */ export function buildEmergentLayoutOptions(): FcoseLayoutOptions { return { name: "fcose", @@ -134,13 +183,13 @@ export function buildEmergentLayoutOptions(): FcoseLayoutOptions { fit: true, padding: 40, randomize: true, - nodeRepulsion: 12000, - idealEdgeLength: 120, - edgeElasticity: 0.45, - gravity: 0.2, - numIter: 3000, + nodeRepulsion: 45000, + idealEdgeLength: 180, + edgeElasticity: 0.4, + gravity: 0.15, + numIter: 4000, tile: true, - packComponents: true, + packComponents: false, quality: "default", }; } From 126faf7566055b11f99ddfdab65bbea10609c188 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 21:23:01 +0100 Subject: [PATCH 20/28] fix(web): stop node selection re-running non-trace graph layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit traceRootId follows the current selection (GraphsTab passes traceRootId={selectedId}), so listing it in the shared layout effect's dependencies re-ran whichever layout was active every time a node was clicked — e.g. Emergent topology re-laid-out on each selection. Split the effect: the structural layouts (refinement, emergent, subsystem, overview) depend only on layout/doc/useSubsystemElements, and a dedicated trace-only effect depends on traceRootId. Selecting a node now only highlights its neighbourhood; only Trace re-runs when its root changes. --- packages/web/src/graph/CytoscapeGraph.tsx | 24 ++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/web/src/graph/CytoscapeGraph.tsx b/packages/web/src/graph/CytoscapeGraph.tsx index 5e5d43f..4f1f10f 100644 --- a/packages/web/src/graph/CytoscapeGraph.tsx +++ b/packages/web/src/graph/CytoscapeGraph.tsx @@ -192,10 +192,14 @@ export function CytoscapeGraph({ // used for ranking are hidden during layout and restored as overlays once // positions have settled. Subsystem uses a compound fcose. Overview runs // fcose on all edges. Trace runs breadthfirst from a node. + // Structural layouts — refinement, emergent, subsystem, overview. This + // effect deliberately does NOT depend on `traceRootId`: `traceRootId` + // follows the current selection, so depending on it here would re-run the + // layout every time a node is clicked. Selecting a node must only highlight + // its neighbourhood, not re-arrange the graph. useEffect(() => { const cy = cyRef.current; if (!cy) return; - if (layout === "refinement") { runRankedLayout( cy, @@ -214,13 +218,19 @@ export function CytoscapeGraph({ cy.layout(toLayoutOptions(buildSubsystemLayoutOptions())).run(); } else if (layout === "overview") { cy.layout(toLayoutOptions(buildOverviewLayoutOptions())).run(); - } else { - // layout === "trace" - if (traceRootId) { - cy.layout(toLayoutOptions(buildTraceLayoutOptions(traceRootId))).run(); - } } - }, [layout, traceRootId, doc, useSubsystemElements]); + // layout === "trace" is handled by the dedicated trace effect below. + }, [layout, doc, useSubsystemElements]); + + // Trace layout — the only layout keyed on `traceRootId`, so the graph + // re-traces when the selected root changes. Guarded to trace mode so that + // selecting a node in any other layout never triggers a re-layout. + useEffect(() => { + const cy = cyRef.current; + if (!cy) return; + if (layout !== "trace" || !traceRootId) return; + cy.layout(toLayoutOptions(buildTraceLayoutOptions(traceRootId))).run(); + }, [layout, traceRootId]); return
; } From 6a84e9e80fc6f51146b448428ddaba5fc4046411 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 22:10:27 +0100 Subject: [PATCH 21/28] fix(web): place orphan nodes around the periphery instead of a grid square Disconnected nodes (no relationship edges in the layout subgraph) were being tiled into a dense grid block by fCoSE and dagre. Two changes: - After the primary layout settles, nodes with zero incident layout edges are repositioned into a loose horizontal arc along the bottom periphery of the connected mass, reading as an unlinked cluster. - View nodes (which link to members via the includes data field, not relationships) are repositioned to the centroid of their visible members after layout, placing them among their group rather than in the orphan block. fCoSE tile/packComponents disabled on Emergent, Subsystem, and Overview layouts since orphan placement now handles disconnected nodes explicitly. Applies to all structural layouts: Refinement (dagre), Emergent (fCoSE), Subsystem (fCoSE compound), and Overview (fCoSE). --- packages/web/package.json | 3 +- packages/web/src/graph/CytoscapeGraph.tsx | 56 ++++- packages/web/src/graph/layouts.ts | 36 ++- packages/web/src/graph/orphanPlacement.ts | 293 ++++++++++++++++++++++ packages/web/tsconfig.json | 2 +- 5 files changed, 374 insertions(+), 16 deletions(-) create mode 100644 packages/web/src/graph/orphanPlacement.ts diff --git a/packages/web/package.json b/packages/web/package.json index 3c8cca4..572ff60 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "tsx --test tests/*.test.ts" }, "dependencies": { "@radix-ui/react-dialog": "1.1.17", diff --git a/packages/web/src/graph/CytoscapeGraph.tsx b/packages/web/src/graph/CytoscapeGraph.tsx index 4f1f10f..f087669 100644 --- a/packages/web/src/graph/CytoscapeGraph.tsx +++ b/packages/web/src/graph/CytoscapeGraph.tsx @@ -36,6 +36,7 @@ import { type LayoutMode, type BackboneLayoutOptions, } from "./layouts"; +import { placeOrphansAfterLayout } from "./orphanPlacement"; // Register the layout extensions once. cytoscape.use(fcose); @@ -203,6 +204,7 @@ export function CytoscapeGraph({ if (layout === "refinement") { runRankedLayout( cy, + doc, buildRefinementLayoutOptions(), backboneSubgraph, crossCuttingEdges, @@ -210,14 +212,15 @@ export function CytoscapeGraph({ } else if (layout === "emergent") { runRankedLayout( cy, + doc, buildEmergentLayoutOptions(), emergentSubgraph, nonEmergentEdges, ); } else if (layout === "subsystem") { - cy.layout(toLayoutOptions(buildSubsystemLayoutOptions())).run(); + runFcoseLayout(cy, doc, buildSubsystemLayoutOptions()); } else if (layout === "overview") { - cy.layout(toLayoutOptions(buildOverviewLayoutOptions())).run(); + runFcoseLayout(cy, doc, buildOverviewLayoutOptions()); } // layout === "trace" is handled by the dedicated trace effect below. }, [layout, doc, useSubsystemElements]); @@ -241,6 +244,11 @@ export function CytoscapeGraph({ * as overlays between already-positioned nodes without influencing the * ranking. * + * After the layout settles, `placeOrphansAfterLayout` repositions view nodes + * to the centroid of their `includes` members and places truly-orphan nodes + * (no incident layout edges, not in any view) in a loose arc along the + * periphery, so no disconnected block forms. + * * - Refinement passes `backboneSubgraph` + `crossCuttingEdges` (strict * backbone ranking; all governance/impact edges are overlays). * - Emergent passes `emergentSubgraph` + `nonEmergentEdges` (backbone plus @@ -248,10 +256,11 @@ export function CytoscapeGraph({ * * The layout runs against the subgraph (all visible nodes + the selected * edges) via `Collection.layout`, which positions every node in the - * collection — including any nodes connected only by overlay edges. + * collection. */ function runRankedLayout( cy: Core, + doc: SysProMDocument, options: BackboneLayoutOptions, selectSubgraph: (cy: Core) => Collection, selectOverlays: (cy: Core) => Collection, @@ -264,7 +273,46 @@ function runRankedLayout( // Restore overlay edges now that nodes have settled; positions are // kept, so the overlays render between the already-placed nodes. overlays.style("display", "element"); - cy.fit(undefined, 40); + + // Collect the layout edge IDs for orphan detection. + const layoutEdgeIds = new Set(); + subgraph.edges().forEach((edge) => { + layoutEdgeIds.add(edge.id()); + }); + + // Place orphans peripherally and views at their members' centroid. + // Deferred to the next frame so positions are final after animation. + setTimeout(() => { + placeOrphansAfterLayout(cy, doc, layoutEdgeIds); + cy.fit(undefined, 40); + }, 0); + }); + layout.run(); +} + +/** + * Run a fcose layout (Subsystem, Overview) with peripheral orphan placement. + * + * After the layout settles, `placeOrphansAfterLayout` repositions view nodes + * to the centroid of their `includes` members and places truly-orphan nodes + * in a loose arc along the periphery. + */ +function runFcoseLayout( + cy: Core, + doc: SysProMDocument, + options: BackboneLayoutOptions, +): void { + const layout = cy.layout(toLayoutOptions(options)); + layout.one("layoutstop", () => { + const layoutEdgeIds = new Set(); + cy.edges(":visible").forEach((edge) => { + layoutEdgeIds.add(edge.id()); + }); + // Deferred to the next frame so positions are final after animation. + setTimeout(() => { + placeOrphansAfterLayout(cy, doc, layoutEdgeIds); + cy.fit(undefined, 40); + }, 0); }); layout.run(); } diff --git a/packages/web/src/graph/layouts.ts b/packages/web/src/graph/layouts.ts index 9f6b3a4..66a629d 100644 --- a/packages/web/src/graph/layouts.ts +++ b/packages/web/src/graph/layouts.ts @@ -170,9 +170,13 @@ export type BackboneLayoutOptions = DagreLayoutOptions | FcoseLayoutOptions; * edge set (backbone plus governance / impact), so far more edges are present * than in the Refinement hierarchy. Tuned for clean cluster separation: * higher node repulsion and longer ideal edges keep decisions, invariants, - * and policies from collapsing onto their targets, while `packComponents` is - * disabled — the broader edge set leaves almost no isolated components, so - * packing would only add an unnecessary grid pass on the handful that remain. + * and policies from collapsing onto their targets. + * + * `tile` and `packComponents` are disabled: disconnected components would + * otherwise be packed into a dense grid square. Instead, the remaining + * orphans (nodes with no incident layout edges) are placed peripherally by + * `placeOrphansAfterLayout` after the primary layout settles, so they read + * as an "unlinked" cluster rather than a grid block. */ export function buildEmergentLayoutOptions(): FcoseLayoutOptions { return { @@ -188,13 +192,19 @@ export function buildEmergentLayoutOptions(): FcoseLayoutOptions { edgeElasticity: 0.4, gravity: 0.15, numIter: 4000, - tile: true, + tile: false, packComponents: false, quality: "default", }; } -/** Build the fcose compound options for the By subsystem layout. */ +/** + * Build the fcose compound options for the By subsystem layout. + * + * `tile` and `packComponents` are disabled: orphan nodes (those with no + * relationship edges) are placed peripherally by `placeOrphansAfterLayout` + * after the layout settles, instead of being packed into a grid square. + */ export function buildSubsystemLayoutOptions(): FcoseLayoutOptions { return { name: "fcose", @@ -209,13 +219,19 @@ export function buildSubsystemLayoutOptions(): FcoseLayoutOptions { edgeElasticity: 0.45, gravity: 0.3, numIter: 2500, - tile: true, - packComponents: true, + tile: false, + packComponents: false, quality: "default", }; } -/** Build the fcose (force-directed) layout options for the Overview mode. */ +/** + * Build the fcose (force-directed) layout options for the Overview mode. + * + * `tile` and `packComponents` are disabled: orphan nodes are placed + * peripherally by `placeOrphansAfterLayout` after the layout settles, + * instead of being packed into a grid square. + */ export function buildOverviewLayoutOptions(): FcoseLayoutOptions { return { name: "fcose", @@ -230,8 +246,8 @@ export function buildOverviewLayoutOptions(): FcoseLayoutOptions { edgeElasticity: 0.45, gravity: 0.25, numIter: 2500, - tile: true, - packComponents: true, + tile: false, + packComponents: false, }; } diff --git a/packages/web/src/graph/orphanPlacement.ts b/packages/web/src/graph/orphanPlacement.ts new file mode 100644 index 0000000..671f091 --- /dev/null +++ b/packages/web/src/graph/orphanPlacement.ts @@ -0,0 +1,293 @@ +/** + * Orphan node placement — prevents disconnected nodes from tiling into a dense + * grid square after the primary layout settles. + * + * Two concerns are handled here: + * + * 1. **View nodes** — view nodes link to their members via the `includes` + * data field (an array of node IDs), not via relationships. After the + * primary layout settles, each view node is repositioned to the centroid of + * its includes members that are visible and already positioned. This places + * the view near the centre of its members without distorting the layout + * (synthetic edges would create a star topology that hierarchical and + * force-directed layouts handle poorly when a view includes many nodes). + * + * 2. **Truly-orphan nodes** — nodes with no relationship edges AND not + * included in any view. After the primary layout settles we identify these + * nodes (they have zero incident edges in the layout subgraph) and + * reposition them in a loose arc around the periphery of the laid-out + * bounding box, so they read as "unlinked" rather than forming a dense + * block. + * + * The arc and centroid placement are pure functions, so they can be + * unit-tested without a Cytoscape instance. + */ +import type { Core, NodeCollection, Position } from "cytoscape"; + +import type { SysProMDocument, Node } from "@sysprom/core"; + +/** + * A positioned node: ID plus x/y coordinates. The minimal shape needed to + * compute peripheral orphan placement. + */ +export interface PositionedNode { + readonly id: string; + readonly x: number; + readonly y: number; +} + +/** + * An axis-aligned bounding box in layout coordinate space. + */ +export interface BoundingBox { + readonly x1: number; + readonly y1: number; + readonly x2: number; + readonly y2: number; +} + +/** + * Compute the bounding box of a set of positioned nodes. Returns `undefined` + * if the set is empty. + */ +export function boundingBox( + nodes: readonly PositionedNode[], +): BoundingBox | undefined { + if (nodes.length === 0) return undefined; + const first = nodes[0]; + let x1 = first.x; + let y1 = first.y; + let x2 = first.x; + let y2 = first.y; + for (const node of nodes) { + if (node.x < x1) x1 = node.x; + if (node.y < y1) y1 = node.y; + if (node.x > x2) x2 = node.x; + if (node.y > y2) y2 = node.y; + } + return { x1, y1, x2, y2 }; +} + +/** + * Compute the centroid (arithmetic mean position) of a set of positioned + * nodes. Returns `undefined` if the set is empty. + * + * Used to place a view node at the centre of its `includes` members after + * the primary layout has positioned them. + */ +export function centroid( + nodes: readonly PositionedNode[], +): Position | undefined { + if (nodes.length === 0) return undefined; + let sx = 0; + let sy = 0; + for (const node of nodes) { + sx += node.x; + sy += node.y; + } + return { x: sx / nodes.length, y: sy / nodes.length }; +} + +/** + * Compute positions for a set of orphan nodes arranged in a loose arc along + * the bottom edge of the bounding box, spread across its full width. + * + * The arc sits below the main mass with a vertical gap proportional to the + * box height, so orphans read as a separate "unlinked" cluster rather than + * overlapping positioned nodes. Nodes are spaced evenly left-to-right with a + * per-node gap derived from the box width. + * + * Returns a map from node ID to `{ x, y }` position. + */ +export function placeOrphansInArc( + orphanIds: readonly string[], + box: BoundingBox, +): Map { + const positions = new Map(); + if (orphanIds.length === 0) return positions; + + const width = box.x2 - box.x1; + const height = box.y2 - box.y1; + + // Vertical gap below the main mass: at least 150px, scales with height. + const gap = Math.max(150, height * 0.25); + const arcY = box.y2 + gap; + + // Horizontal padding so orphans don't start exactly at the left edge. + const sidePadding = Math.max(80, width * 0.05); + const usableWidth = width + sidePadding * 2; + + // Spread orphans evenly across the width. For a single orphan, centre it. + if (orphanIds.length === 1) { + const cx = (box.x1 + box.x2) / 2; + positions.set(orphanIds[0], { x: cx, y: arcY }); + return positions; + } + + const step = usableWidth / (orphanIds.length - 1); + for (let i = 0; i < orphanIds.length; i++) { + const id = orphanIds[i]; + const x = box.x1 - sidePadding + step * i; + positions.set(id, { x, y: arcY }); + } + return positions; +} + +/** + * Determine which node IDs are orphans relative to a set of edges: a node is + * an orphan if no edge in the set has it as a source or target. + * + * Both `nodeIds` and `edges` should reflect the layout subgraph — i.e. the + * edges that were active for the layout, not the full overlay set. + */ +export function identifyOrphans( + nodeIds: readonly string[], + edges: readonly { readonly source: string; readonly target: string }[], +): string[] { + const connected = new Set(); + for (const edge of edges) { + connected.add(edge.source); + connected.add(edge.target); + } + return nodeIds.filter((id) => !connected.has(id)); +} + +/** + * Type guard extracting a `string[]` from a node's `includes` field using + * safe `in`-based narrowing (no index-signature access or `as` casts). + */ +function readIncludes(node: Node): string[] { + if (!("includes" in node)) return []; + const value = node.includes; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + +// --------------------------------------------------------------------------- +// Cytoscape integration — operate on a live instance after layout settles. +// --------------------------------------------------------------------------- + +/** + * After the primary layout has settled, reposition view nodes to the centroid + * of their `includes` members, then place truly-orphan nodes (no incident + * layout edges and not in any view) in a loose arc along the bottom periphery + * of the laid-out bounding box. + * + * The `layoutEdges` parameter is the set of edges that drove the layout + * (backbone for Refinement, emergent for Emergent topology, all for Overview). + * A node is an orphan only if none of these edges touch it. Overlay edges + * (hidden during layout) do not count — they were not part of the ranking. + * + * The bounding box for the arc is computed from connected (non-orphan) nodes + * only, so the arc sits below the main laid-out mass rather than being skewed + * by orphan positions that the primary layout may have scattered. + * + * The `doc` provides view `includes` membership data so view nodes can be + * positioned at their members' centroid. + */ +export function placeOrphansAfterLayout( + cy: Core, + doc: SysProMDocument, + layoutEdges: ReadonlySet, +): void { + const visibleNodes = cy.nodes(":visible"); + const viewIncludes = collectViewIncludes(doc); + const viewIds = new Set(viewIncludes.keys()); + + const orphanIds = identifyLayoutOrphans(visibleNodes, viewIds, layoutEdges); + placeOrphanArc(cy, visibleNodes, orphanIds, viewIds); + placeViewsAtCentroid(cy, viewIncludes); +} + +/** + * Build a map of view node ID -> includes member IDs from the document. + * Only views with a non-empty `includes` array are included. + */ +function collectViewIncludes(doc: SysProMDocument): Map { + const result = new Map(); + for (const node of doc.nodes) { + if (node.type !== "view") continue; + const includes = readIncludes(node); + if (includes.length > 0) result.set(node.id, includes); + } + return result; +} + +/** + * Identify visible nodes that have zero incident edges in the `layoutEdges` + * set, excluding view nodes (which are positioned at their members' centroid + * rather than in the orphan arc). + */ +function identifyLayoutOrphans( + visibleNodes: NodeCollection, + viewIds: Set, + layoutEdges: ReadonlySet, +): Set { + const orphans = new Set(); + for (const node of visibleNodes) { + const id = node.id(); + if (viewIds.has(id)) continue; + let hasLayoutEdge = false; + const incident = node.connectedEdges(); + for (const edge of incident) { + if (layoutEdges.has(edge.id())) { + hasLayoutEdge = true; + break; + } + } + if (!hasLayoutEdge) orphans.add(id); + } + return orphans; +} + +/** + * Reposition orphan nodes into a peripheral arc below the connected mass. + * The bounding box is computed from connected (non-orphan, non-view) nodes + * only, so the arc sits below the main laid-out mass. + */ +function placeOrphanArc( + cy: Core, + visibleNodes: NodeCollection, + orphanIds: Set, + viewIds: Set, +): void { + if (orphanIds.size === 0) return; + const connected: PositionedNode[] = []; + for (const node of visibleNodes) { + const id = node.id(); + if (orphanIds.has(id) || viewIds.has(id)) continue; + const pos = node.position(); + connected.push({ id, x: pos.x, y: pos.y }); + } + const box = boundingBox(connected); + if (box === undefined) return; + const placements = placeOrphansInArc([...orphanIds], box); + for (const [id, pos] of placements) { + cy.getElementById(id).position(pos); + } +} + +/** + * Reposition each view node to the centroid of its visible members. + * Runs after orphan arc placement so the centroid reflects final positions. + */ +function placeViewsAtCentroid( + cy: Core, + viewIncludes: Map, +): void { + for (const [viewId, memberIds] of viewIncludes) { + const viewNode = cy.getElementById(viewId); + if (viewNode.empty()) continue; + const memberPositions: PositionedNode[] = []; + for (const memberId of memberIds) { + const memberNode = cy.getElementById(memberId); + if (memberNode.empty() || !memberNode.is(":visible")) continue; + const pos = memberNode.position(); + memberPositions.push({ id: memberId, x: pos.x, y: pos.y }); + } + const center = centroid(memberPositions); + if (center !== undefined) { + viewNode.position(center); + } + } +} diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index 805d2a9..9eb0a4a 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -14,5 +14,5 @@ "@sysprom/core": ["../core/src/index.ts"] } }, - "include": ["src", "vite.config.ts"] + "include": ["src", "tests", "vite.config.ts"] } From f3ec57d39c3e3a01df636a265acfa479c0a70515 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 22:34:19 +0100 Subject: [PATCH 22/28] test(web): cover orphan-placement pure functions Tests identifyOrphans, boundingBox, centroid, and placeOrphansInArc. Written to the project's strict lint rules: void on each node:test describe/it (they return thenable Test objects), assert.ok narrowing in place of non-null assertions, localeCompare comparators on sorts. --- packages/web/tests/orphan-placement.test.ts | 131 ++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/web/tests/orphan-placement.test.ts diff --git a/packages/web/tests/orphan-placement.test.ts b/packages/web/tests/orphan-placement.test.ts new file mode 100644 index 0000000..bfcdaa8 --- /dev/null +++ b/packages/web/tests/orphan-placement.test.ts @@ -0,0 +1,131 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + boundingBox, + centroid, + identifyOrphans, + placeOrphansInArc, +} from "../src/graph/orphanPlacement"; + +// `describe`/`it` from node:test return thenable `Test` objects, so each call +// is prefixed with `void` to satisfy `no-floating-promises` without relaxing +// the rule. +void describe("orphan placement", () => { + void describe("identifyOrphans", () => { + void it("returns all nodes when there are no edges", () => { + const result = identifyOrphans(["A", "B", "C"], []); + const sorted = [...result].sort((a, b) => a.localeCompare(b)); + assert.deepEqual(sorted, ["A", "B", "C"]); + }); + + void it("returns only nodes with no incident edges", () => { + const edges = [ + { source: "A", target: "B" }, + { source: "B", target: "C" }, + ]; + const result = identifyOrphans(["A", "B", "C", "D", "E"], edges); + const sorted = [...result].sort((a, b) => a.localeCompare(b)); + assert.deepEqual(sorted, ["D", "E"]); + }); + + void it("returns empty when all nodes are connected", () => { + const edges = [{ source: "A", target: "B" }]; + const result = identifyOrphans(["A", "B"], edges); + assert.deepEqual(result, []); + }); + }); + + void describe("boundingBox", () => { + void it("returns undefined for empty input", () => { + assert.equal(boundingBox([]), undefined); + }); + + void it("computes min/max coordinates", () => { + const box = boundingBox([ + { id: "A", x: 10, y: 20 }, + { id: "B", x: -5, y: 50 }, + { id: "C", x: 100, y: 0 }, + ]); + assert.ok(box); + assert.equal(box.x1, -5); + assert.equal(box.y1, 0); + assert.equal(box.x2, 100); + assert.equal(box.y2, 50); + }); + }); + + void describe("centroid", () => { + void it("returns undefined for empty input", () => { + assert.equal(centroid([]), undefined); + }); + + void it("computes the arithmetic mean position", () => { + const result = centroid([ + { id: "A", x: 0, y: 0 }, + { id: "B", x: 100, y: 200 }, + ]); + assert.ok(result); + assert.equal(result.x, 50); + assert.equal(result.y, 100); + }); + + void it("handles a single node", () => { + const result = centroid([{ id: "A", x: 42, y: 99 }]); + assert.ok(result); + assert.equal(result.x, 42); + assert.equal(result.y, 99); + }); + }); + + void describe("placeOrphansInArc", () => { + void it("returns empty map for zero orphans", () => { + const box = { x1: 0, y1: 0, x2: 200, y2: 100 }; + assert.equal(placeOrphansInArc([], box).size, 0); + }); + + void it("centres a single orphan", () => { + const box = { x1: 0, y1: 0, x2: 200, y2: 100 }; + const result = placeOrphansInArc(["X"], box); + const pos = result.get("X"); + assert.ok(pos); + assert.equal(pos.x, 100); // centred + assert.ok(pos.y > 100); // below the box + }); + + void it("spreads multiple orphans horizontally below the box", () => { + const box = { x1: 0, y1: 0, x2: 300, y2: 100 }; + const ids = ["O1", "O2", "O3", "O4"]; + const result = placeOrphansInArc(ids, box); + assert.equal(result.size, 4); + const xs = ids.map((id) => { + const p = result.get(id); + assert.ok(p); + return p.x; + }); + // Strictly increasing left to right. + for (let i = 1; i < xs.length; i++) { + assert.ok( + xs[i] > xs[i - 1], + `expected increasing x at index ${String(i)}`, + ); + } + // All below the bounding box. + for (const id of ids) { + const p = result.get(id); + assert.ok(p); + assert.ok(p.y > 100, `orphan ${id} should be below box`); + } + }); + + void it("gap scales with box height", () => { + const smallBox = { x1: 0, y1: 0, x2: 200, y2: 50 }; + const tallBox = { x1: 0, y1: 0, x2: 200, y2: 1000 }; + const smallPos = placeOrphansInArc(["X"], smallBox).get("X"); + const tallPos = placeOrphansInArc(["X"], tallBox).get("X"); + assert.ok(smallPos); + assert.ok(tallPos); + // Tall box should push orphan further down. + assert.ok(tallPos.y > smallPos.y); + }); + }); +}); From f0565466191ffad6f14520952d16f7fe168ccefb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 23:26:31 +0100 Subject: [PATCH 23/28] fix(web): group orphan nodes by type around the periphery with labels Replace the single-row orphan arc with type-grouped peripheral clusters. Orphans are bucketed by SysProM node type, each bucket laid out as a compact grid and anchored to a distinct point on one of the four sides of the laid-out bounding box, so disconnected nodes read as intentional labelled sets ('Invariants', 'Decisions', ...) around the rim rather than a horizontal line or dense square. Cluster labels are rendered as an HTML overlay (not canvas text, which is unreadable at whole-graph zoom). The overlay is synced to pan/zoom via a viewport tick; chip positions are derived from each label's model coordinates using cy.zoom()/pan(). Also enforce a hard node-clearance invariant: after every layout settles (including the orphan clusters and view-centroid placements) an iterative pairwise separation pass pushes apart any nodes closer than MIN_NODE_CLEARANCE (derived from the 28px node size). fCoSE nodeSeparation and dagre nodeSep are set to the same value so the layout itself starts near-clearance-respecting. Tune the Emergent fCoSE options (lower edgeElasticity, longer idealEdgeLength, more numIter, higher nodeRepulsion) to reduce edge crossings on the broader emergent edge set. Views with visible includes members still move to their members' centroid; views with no visible members now fall through to orphan grouping under their own type. --- packages/web/src/graph/CytoscapeGraph.tsx | 124 ++++- packages/web/src/graph/layouts.ts | 48 +- packages/web/src/graph/orphanPlacement.ts | 614 ++++++++++++++++++---- packages/web/src/graph/stylesheets.ts | 21 + 4 files changed, 688 insertions(+), 119 deletions(-) diff --git a/packages/web/src/graph/CytoscapeGraph.tsx b/packages/web/src/graph/CytoscapeGraph.tsx index f087669..ba2ad1c 100644 --- a/packages/web/src/graph/CytoscapeGraph.tsx +++ b/packages/web/src/graph/CytoscapeGraph.tsx @@ -6,7 +6,7 @@ * This avoids the cost of tearing down and rebuilding the renderer on every * prop change — essential for the 223-node sample document. */ -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import cytoscape, { type Core, type Collection, @@ -36,7 +36,11 @@ import { type LayoutMode, type BackboneLayoutOptions, } from "./layouts"; -import { placeOrphansAfterLayout } from "./orphanPlacement"; +import { + placeOrphansAfterLayout, + enforceClearanceOnCytoscape, + type OrphanLabel, +} from "./orphanPlacement"; // Register the layout extensions once. cytoscape.use(fcose); @@ -92,6 +96,12 @@ export function CytoscapeGraph({ const cyRef = useRef(null); const onSelectRef = useRef(onSelect); onSelectRef.current = onSelect; + // Orphan-cluster labels rendered as an HTML overlay (canvas text is + // unreadable at the zoom levels a full-graph fit produces). + const [labels, setLabels] = useState([]); + // Bumped on every viewport change so the overlay re-renders at the new + // pan/zoom, keeping the label chips pinned to their clusters. + const [viewportTick, setViewportTick] = useState(0); // Initialise the Cytoscape instance exactly once. useEffect(() => { @@ -133,6 +143,12 @@ export function CytoscapeGraph({ cy.on("tap", "node", handleNodeTap); cy.on("tap", handleBackgroundTap); + // Keep the HTML label overlay in sync with pan/zoom. + const handleViewport = (): void => { + setViewportTick((t) => t + 1); + }; + cy.on("pan zoom", handleViewport); + return () => { cy.destroy(); cyRef.current = null; @@ -208,6 +224,7 @@ export function CytoscapeGraph({ buildRefinementLayoutOptions(), backboneSubgraph, crossCuttingEdges, + setLabels, ); } else if (layout === "emergent") { runRankedLayout( @@ -216,11 +233,12 @@ export function CytoscapeGraph({ buildEmergentLayoutOptions(), emergentSubgraph, nonEmergentEdges, + setLabels, ); } else if (layout === "subsystem") { - runFcoseLayout(cy, doc, buildSubsystemLayoutOptions()); + runFcoseLayout(cy, doc, buildSubsystemLayoutOptions(), setLabels); } else if (layout === "overview") { - runFcoseLayout(cy, doc, buildOverviewLayoutOptions()); + runFcoseLayout(cy, doc, buildOverviewLayoutOptions(), setLabels); } // layout === "trace" is handled by the dedicated trace effect below. }, [layout, doc, useSubsystemElements]); @@ -232,10 +250,96 @@ export function CytoscapeGraph({ const cy = cyRef.current; if (!cy) return; if (layout !== "trace" || !traceRootId) return; - cy.layout(toLayoutOptions(buildTraceLayoutOptions(traceRootId))).run(); + setLabels([]); + const traceLayout = cy.layout( + toLayoutOptions(buildTraceLayoutOptions(traceRootId)), + ); + traceLayout.one("layoutstop", () => { + // Enforce the hard node-clearance invariant after the trace settles. + setTimeout(() => { + enforceClearanceOnCytoscape(cy); + cy.fit(undefined, 40); + }, 0); + }); + traceLayout.run(); }, [layout, traceRootId]); - return
; + return ( +
+
+ +
+ ); +} + +/** + * HTML overlay rendering orphan-cluster label chips pinned to their clusters. + * Canvas text is unreadable at the zoom levels a full-graph fit produces, so + * labels are rendered as DOM elements whose screen position is recomputed on + * every pan/zoom tick from each label's model coordinates via + * `cy.renderedPoint`. + */ +function OrphanLabelOverlay({ + cyRef, + labels, + viewportTick, +}: { + readonly cyRef: React.RefObject; + readonly labels: readonly OrphanLabel[]; + readonly viewportTick: number; +}): React.ReactElement | null { + const cy = cyRef.current; + // `viewportTick` forces this component to re-render on pan/zoom so the + // label chips recompute their screen positions. + if (cy === null || labels.length === 0 || viewportTick < 0) return null; + return ( +
+ {labels.map((label) => { + // Model-to-screen transform: rendered = model * zoom + pan. + const zoom = cy.zoom(); + const pan = cy.pan(); + const rx = label.x * zoom + pan.x; + const ry = label.y * zoom + pan.y; + return ( + + {label.label} + + ); + })} +
+ ); } /** @@ -264,6 +368,7 @@ function runRankedLayout( options: BackboneLayoutOptions, selectSubgraph: (cy: Core) => Collection, selectOverlays: (cy: Core) => Collection, + onLabels: (labels: readonly OrphanLabel[]) => void, ): void { const overlays = selectOverlays(cy); overlays.style("display", "none"); @@ -283,7 +388,8 @@ function runRankedLayout( // Place orphans peripherally and views at their members' centroid. // Deferred to the next frame so positions are final after animation. setTimeout(() => { - placeOrphansAfterLayout(cy, doc, layoutEdgeIds); + const labels = placeOrphansAfterLayout(cy, doc, layoutEdgeIds); + onLabels(labels); cy.fit(undefined, 40); }, 0); }); @@ -301,6 +407,7 @@ function runFcoseLayout( cy: Core, doc: SysProMDocument, options: BackboneLayoutOptions, + onLabels: (labels: readonly OrphanLabel[]) => void, ): void { const layout = cy.layout(toLayoutOptions(options)); layout.one("layoutstop", () => { @@ -310,7 +417,8 @@ function runFcoseLayout( }); // Deferred to the next frame so positions are final after animation. setTimeout(() => { - placeOrphansAfterLayout(cy, doc, layoutEdgeIds); + const labels = placeOrphansAfterLayout(cy, doc, layoutEdgeIds); + onLabels(labels); cy.fit(undefined, 40); }, 0); }); diff --git a/packages/web/src/graph/layouts.ts b/packages/web/src/graph/layouts.ts index 66a629d..6aa43d4 100644 --- a/packages/web/src/graph/layouts.ts +++ b/packages/web/src/graph/layouts.ts @@ -33,6 +33,7 @@ import { CROSS_CUTTING_REL_TYPES, EMERGENT_REL_TYPES, } from "./elements"; +import { MIN_NODE_CLEARANCE } from "./orphanPlacement"; export type LayoutMode = | "refinement" @@ -57,6 +58,12 @@ interface FcoseLayoutOptions extends ShapedLayoutOptions { tile?: boolean; packComponents?: boolean; quality?: "draft" | "default"; + /** + * Minimum additional distance between unconnected nodes (fcose's built-in + * spacing knob). Used alongside the post-layout `enforceClearance` pass so + * the layout itself starts from a near-clearance-respecting state. + */ + nodeSeparation?: number; } /** @@ -139,14 +146,19 @@ function isEmergentType(type: unknown): boolean { return typeof type === "string" && EMERGENT_REL_TYPES.has(type); } -/** Build the dagre options for the Refinement hierarchy (top-down DAG). */ +/** + * Build the dagre options for the Refinement hierarchy (top-down DAG). + * `nodeSep` is set to the global minimum clearance so the DAG layers respect + * the node-to-node spacing invariant from the outset; the post-layout + * `enforceClearance` pass then guarantees it across every node pair. + */ export function buildRefinementLayoutOptions(): DagreLayoutOptions { return { name: "dagre", rankDir: "TB", - nodeSep: 50, - edgeSep: 20, - rankSep: 70, + nodeSep: MIN_NODE_CLEARANCE, + edgeSep: 40, + rankSep: 90, ranker: "network-simplex", acyclicer: "greedy", animate: true, @@ -168,15 +180,22 @@ export type BackboneLayoutOptions = DagreLayoutOptions | FcoseLayoutOptions; /** * Build the fcose options for the Emergent topology. Ranks on the emergent * edge set (backbone plus governance / impact), so far more edges are present - * than in the Refinement hierarchy. Tuned for clean cluster separation: - * higher node repulsion and longer ideal edges keep decisions, invariants, - * and policies from collapsing onto their targets. + * than in the Refinement hierarchy. Tuned for clean cluster separation AND + * fewer edge crossings: high node repulsion, long ideal edges, low edge + * elasticity, and a long iteration budget give the force-directed solver + * room to untangle. Lower `edgeElasticity` (0.25) lets edges stretch so the + * layout can escape tangled local minima; `idealEdgeLength` 220 keeps + * connected groups well separated; `numIter` 8000 gives the solver the + * iterations it needs to converge on the larger edge set. * * `tile` and `packComponents` are disabled: disconnected components would * otherwise be packed into a dense grid square. Instead, the remaining * orphans (nodes with no incident layout edges) are placed peripherally by * `placeOrphansAfterLayout` after the primary layout settles, so they read - * as an "unlinked" cluster rather than a grid block. + * as type-grouped clusters around the edge rather than a grid block. + * `nodeSeparation` is set to the global minimum clearance so the layout + * itself starts from a near-clearance-respecting state; the post-layout + * `enforceClearance` pass then guarantees the invariant. */ export function buildEmergentLayoutOptions(): FcoseLayoutOptions { return { @@ -187,14 +206,15 @@ export function buildEmergentLayoutOptions(): FcoseLayoutOptions { fit: true, padding: 40, randomize: true, - nodeRepulsion: 45000, - idealEdgeLength: 180, - edgeElasticity: 0.4, - gravity: 0.15, - numIter: 4000, + nodeRepulsion: 65000, + idealEdgeLength: 220, + edgeElasticity: 0.25, + gravity: 0.12, + numIter: 8000, tile: false, packComponents: false, quality: "default", + nodeSeparation: MIN_NODE_CLEARANCE, }; } @@ -222,6 +242,7 @@ export function buildSubsystemLayoutOptions(): FcoseLayoutOptions { tile: false, packComponents: false, quality: "default", + nodeSeparation: MIN_NODE_CLEARANCE, }; } @@ -248,6 +269,7 @@ export function buildOverviewLayoutOptions(): FcoseLayoutOptions { numIter: 2500, tile: false, packComponents: false, + nodeSeparation: MIN_NODE_CLEARANCE, }; } diff --git a/packages/web/src/graph/orphanPlacement.ts b/packages/web/src/graph/orphanPlacement.ts index 671f091..d57f6e4 100644 --- a/packages/web/src/graph/orphanPlacement.ts +++ b/packages/web/src/graph/orphanPlacement.ts @@ -1,26 +1,35 @@ /** * Orphan node placement — prevents disconnected nodes from tiling into a dense - * grid square after the primary layout settles. + * grid square or collapsing into a single horizontal line after the primary + * layout settles. * - * Two concerns are handled here: + * Three concerns are handled here: * * 1. **View nodes** — view nodes link to their members via the `includes` * data field (an array of node IDs), not via relationships. After the - * primary layout settles, each view node is repositioned to the centroid of - * its includes members that are visible and already positioned. This places - * the view near the centre of its members without distorting the layout - * (synthetic edges would create a star topology that hierarchical and - * force-directed layouts handle poorly when a view includes many nodes). + * primary layout settles, each view node with at least one visible + * included member is repositioned to the centroid of those members. This + * places the view near the centre of its members without distorting the + * layout (synthetic edges would create a star topology that hierarchical + * and force-directed layouts handle poorly when a view includes many + * nodes). A view with no visible members is treated as an orphan and falls + * through to the type-grouped placement below. * * 2. **Truly-orphan nodes** — nodes with no relationship edges AND not - * included in any view. After the primary layout settles we identify these - * nodes (they have zero incident edges in the layout subgraph) and - * reposition them in a loose arc around the periphery of the laid-out - * bounding box, so they read as "unlinked" rather than forming a dense - * block. + * included in any view with visible members. After the primary layout + * settles we identify these nodes (they have zero incident edges in the + * layout subgraph) and reposition them in **type-grouped clusters around + * the periphery** of the laid-out bounding box, so they read as + * intentional grouped sets ("the invariants", "the decisions", …) rather + * than detached clutter. * - * The arc and centroid placement are pure functions, so they can be - * unit-tested without a Cytoscape instance. + * 3. **Cluster labels** — each type-group is annotated with a labelled + * compound-style node (e.g. "Invariants (11)") positioned just outside + * the group, reusing the same `type: "cluster"` rendering the "By + * subsystem" layout uses for its compound parents. + * + * The grouping, peripheral anchoring, and centroid placement are pure + * functions, so they can be unit-tested without a Cytoscape instance. */ import type { Core, NodeCollection, Position } from "cytoscape"; @@ -46,6 +55,37 @@ export interface BoundingBox { readonly y2: number; } +/** + * A typed group of orphan node IDs, in the order they should be laid out + * within their cluster. The `type` is the SysProM node type (e.g. + * `"invariant"`, `"decision"`); `label` is the human-readable cluster title + * (e.g. `"Invariants"`). + */ +export interface OrphanGroup { + readonly type: string; + readonly label: string; + readonly ids: readonly string[]; +} + +/** + * A label descriptor for a placed orphan cluster: the synthetic node ID to + * use for the label, the display label text, and where to position it. + */ +export interface OrphanLabel { + readonly id: string; + readonly label: string; + readonly x: number; + readonly y: number; +} + +/** + * Result of placing orphan groups: per-node positions plus per-group labels. + */ +export interface OrphanPlacement { + readonly positions: ReadonlyMap; + readonly labels: readonly OrphanLabel[]; +} + /** * Compute the bounding box of a set of positioned nodes. Returns `undefined` * if the set is empty. @@ -89,78 +129,356 @@ export function centroid( } /** - * Compute positions for a set of orphan nodes arranged in a loose arc along - * the bottom edge of the bounding box, spread across its full width. + * Determine which node IDs are orphans relative to a set of edges: a node is + * an orphan if no edge in the set has it as a source or target. * - * The arc sits below the main mass with a vertical gap proportional to the - * box height, so orphans read as a separate "unlinked" cluster rather than - * overlapping positioned nodes. Nodes are spaced evenly left-to-right with a - * per-node gap derived from the box width. + * Both `nodeIds` and `edges` should reflect the layout subgraph — i.e. the + * edges that were active for the layout, not the full overlay set. + */ +export function identifyOrphans( + nodeIds: readonly string[], + edges: readonly { readonly source: string; readonly target: string }[], +): string[] { + const connected = new Set(); + for (const edge of edges) { + connected.add(edge.source); + connected.add(edge.target); + } + return nodeIds.filter((id) => !connected.has(id)); +} + +/** + * Minimum centre-to-centre distance that must hold between any two nodes + * after a layout settles. This is the layout's hard clearance invariant: no + * two nodes may overlap or sit closer than this in the final rendered + * positions. * - * Returns a map from node ID to `{ x, y }` position. + * The value is derived from the stylesheet's node size (28px → 14px radius), + * doubled for the two-node pair, plus a 28px breathing-gap so labels and + * selection borders never collide either: 14 + 14 + 28 = 56. */ -export function placeOrphansInArc( - orphanIds: readonly string[], - box: BoundingBox, -): Map { - const positions = new Map(); - if (orphanIds.length === 0) return positions; +export const MIN_NODE_CLEARANCE = 56; - const width = box.x2 - box.x1; - const height = box.y2 - box.y1; +/** + * Maximum number of separation iterations. Each iteration scans every + * violating pair once and pushes them apart; thirty iterations converges on + * the sample document (223 nodes) without measurable cost. + */ +const CLEARANCE_ITERATIONS = 30; - // Vertical gap below the main mass: at least 150px, scales with height. - const gap = Math.max(150, height * 0.25); - const arcY = box.y2 + gap; +/** + * A node with its bounding radius for clearance resolution. `fixed` nodes + * are not moved (e.g. compound parents that anchor a cluster). + */ +export interface ClearanceNode { + readonly id: string; + x: number; + y: number; + readonly radius: number; + readonly fixed: boolean; +} - // Horizontal padding so orphans don't start exactly at the left edge. - const sidePadding = Math.max(80, width * 0.05); - const usableWidth = width + sidePadding * 2; +/** + * Resolve a single pair of nodes that are too close, returning the + * displacement to apply to each (`a` moves by `ax/ay`, `b` by `bx/by`). + * Returns zeroes when the pair already satisfies the clearance or both nodes + * are fixed. Pure: does not mutate its inputs. + */ +function resolvePair( + a: ClearanceNode, + b: ClearanceNode, +): { + readonly ax: number; + readonly ay: number; + readonly bx: number; + readonly by: number; +} { + const dx = b.x - a.x; + const dy = b.y - a.y; + const distSq = dx * dx + dy * dy; + const target = a.radius + b.radius + MIN_NODE_CLEARANCE; + const zero = { ax: 0, ay: 0, bx: 0, by: 0 }; + if (distSq >= target * target) return zero; + const dist = Math.sqrt(distSq) || 0.0001; + const overlap = target - dist; + const ux = dx / dist; + const uy = dy / dist; + if (a.fixed && b.fixed) return zero; + if (a.fixed) return { ax: 0, ay: 0, bx: ux * overlap, by: uy * overlap }; + if (b.fixed) return { ax: -ux * overlap, ay: -uy * overlap, bx: 0, by: 0 }; + return { + ax: -(ux * overlap) / 2, + ay: -(uy * overlap) / 2, + bx: (ux * overlap) / 2, + by: (uy * overlap) / 2, + }; +} - // Spread orphans evenly across the width. For a single orphan, centre it. - if (orphanIds.length === 1) { - const cx = (box.x1 + box.x2) / 2; - positions.set(orphanIds[0], { x: cx, y: arcY }); - return positions; +/** + * Iteratively push apart any pair of nodes whose centre-to-centre distance is + * less than `MIN_NODE_CLEARANCE`, so the clearance invariant holds in the + * final positions. Fixed nodes are never moved; the moving node absorbs the + * full displacement for a fixed/moving pair, otherwise each absorbs half. + * + * Mutates the `x`/`y` of the supplied `ClearanceNode` objects in place — + * callers build these fresh from Cytoscape positions and write the resolved + * values back, so no live renderer state is touched here. + * + * This is a bounded greedy relaxation: it always terminates (capped at + * `CLEARANCE_ITERATIONS`) and removes every violation a single pass can + * resolve. On the SysProM sample the budget converges; if it ever does not, + * the worst case is reduced (not increased) overlap. + */ +export function enforceClearance(nodes: ClearanceNode[]): void { + if (nodes.length < 2) return; + for (let iter = 0; iter < CLEARANCE_ITERATIONS; iter++) { + let violations = 0; + for (let i = 0; i < nodes.length; i++) { + const a = nodes[i]; + for (let j = i + 1; j < nodes.length; j++) { + const b = nodes[j]; + const d = resolvePair(a, b); + if (d.ax === 0 && d.ay === 0 && d.bx === 0 && d.by === 0) continue; + violations++; + a.x += d.ax; + a.y += d.ay; + b.x += d.bx; + b.y += d.by; + } + } + if (violations === 0) break; } +} - const step = usableWidth / (orphanIds.length - 1); - for (let i = 0; i < orphanIds.length; i++) { - const id = orphanIds[i]; - const x = box.x1 - sidePadding + step * i; - positions.set(id, { x, y: arcY }); +/** + * Spacing constants for the peripheral clusters, in layout coordinate units. + * Tuned against the ~28px node size used by the stylesheet. + */ +const NODE_SPACING = 70; +/** How far a cluster's nodes are offset outward from the bounding-box edge. */ +const PERIPHERY_OFFSET = 160; +/** Extra outward offset for the cluster label, beyond the nodes. */ +const LABEL_OFFSET = 52; + +/** + * Arrange a small group of nodes into a compact, roughly square grid centred + * on `(cx, cy)`. Returns the per-ID positions in the order of `ids`. + * + * A grid (rather than a line) keeps each cluster compact even when a type has + * many members, so the overall silhouette reads as several small grouped + * blocks around the periphery rather than a single line of detached nodes. + */ +function gridPositions( + ids: readonly string[], + cx: number, + cy: number, +): Position[] { + const n = ids.length; + if (n === 0) return []; + if (n === 1) return [{ x: cx, y: cy }]; + const cols = Math.ceil(Math.sqrt(n)); + const rows = Math.ceil(n / cols); + const startX = cx - ((cols - 1) * NODE_SPACING) / 2; + const startY = cy - ((rows - 1) * NODE_SPACING) / 2; + const positions: Position[] = []; + for (let i = 0; i < n; i++) { + const col = i % cols; + const row = Math.floor(i / cols); + positions.push({ + x: startX + col * NODE_SPACING, + y: startY + row * NODE_SPACING, + }); } return positions; } /** - * Determine which node IDs are orphans relative to a set of edges: a node is - * an orphan if no edge in the set has it as a source or target. + * Pick a centre point for cluster `index` of `total`, distributed around the + * four sides of the bounding box and offset outward by `offset`. * - * Both `nodeIds` and `edges` should reflect the layout subgraph — i.e. the - * edges that were active for the layout, not the full overlay set. + * Groups are walked around the perimeter so neighbouring types sit on + * neighbouring sides rather than stretching along one edge. The starting + * side rotates with `total` so a single group still lands somewhere + * reasonable (bottom-centre) and small counts fan out rather than piling up. + * + * Returns the cluster centre plus a unit outward normal describing which way + * the cluster faces (used to offset the label further out). */ -export function identifyOrphans( - nodeIds: readonly string[], - edges: readonly { readonly source: string; readonly target: string }[], -): string[] { - const connected = new Set(); - for (const edge of edges) { - connected.add(edge.source); - connected.add(edge.target); +function perimeterAnchor( + box: BoundingBox, + index: number, + total: number, + offset: number, +): { + readonly cx: number; + readonly cy: number; + readonly nx: number; + readonly ny: number; +} { + const width = box.x2 - box.x1; + const height = box.y2 - box.y1; + const cx = (box.x1 + box.x2) / 2; + + // Four sides: bottom, right, top, left. For a single group we always use + // the bottom-centre so it reads as a single labelled cluster. + if (total === 1) { + return { cx, cy: box.y2 + offset, nx: 0, ny: 1 }; } - return nodeIds.filter((id) => !connected.has(id)); + + const sides = 4; + const side = index % sides; + // Evenly fraction along the side, with margin from corners. + const slotsPerSide = Math.ceil(total / sides); + const slotIndex = Math.floor(index / sides); + const t = + slotsPerSide <= 1 ? 0.5 : 0.2 + (0.6 * slotIndex) / (slotsPerSide - 1); + + if (side === 0) { + // Bottom edge, left-to-right. + return { + cx: box.x1 + width * t, + cy: box.y2 + offset, + nx: 0, + ny: 1, + }; + } + if (side === 1) { + // Right edge, top-to-bottom. + return { + cx: box.x2 + offset, + cy: box.y1 + height * t, + nx: 1, + ny: 0, + }; + } + if (side === 2) { + // Top edge, right-to-left. + return { + cx: box.x2 - width * t, + cy: box.y1 - offset, + nx: 0, + ny: -1, + }; + } + // Left edge, bottom-to-top. + return { + cx: box.x1 - offset, + cy: box.y2 - height * t, + nx: -1, + ny: 0, + }; } /** - * Type guard extracting a `string[]` from a node's `includes` field using - * safe `in`-based narrowing (no index-signature access or `as` casts). + * Compute positions for orphan nodes arranged as **type-grouped clusters + * around the periphery** of the laid-out bounding box. + * + * Each group is laid out as a compact grid centred on an anchor point on one + * of the four sides of the box (groups are distributed around all sides so + * the silhouette reads as several labelled sets around the edge — not a + * single line, not a dense square). A label descriptor is returned per group + * so the caller can render an "Invariants (n)"-style annotation just outside + * the cluster. + * + * Returns `{ positions, labels }`. `positions` maps each orphan node ID to + * its `{ x, y }` position; `labels` carries one `OrphanLabel` per group. */ -function readIncludes(node: Node): string[] { - if (!("includes" in node)) return []; - const value = node.includes; - if (!Array.isArray(value)) return []; - return value.filter((item): item is string => typeof item === "string"); +export function placeOrphansByType( + groups: readonly OrphanGroup[], + box: BoundingBox, +): OrphanPlacement { + const positions = new Map(); + const labels: OrphanLabel[] = []; + if (groups.length === 0) return { positions, labels }; + + const total = groups.length; + for (let gi = 0; gi < total; gi++) { + const group = groups[gi]; + if (group.ids.length === 0) continue; + const anchor = perimeterAnchor(box, gi, total, PERIPHERY_OFFSET); + const nodePositions = gridPositions(group.ids, anchor.cx, anchor.cy); + for (let i = 0; i < group.ids.length; i++) { + positions.set(group.ids[i], nodePositions[i]); + } + // Label sits further out along the outward normal of the side. + const grid = gridOf(group.ids.length); + const labelDistance = + LABEL_OFFSET + (Math.floor((grid.rows - 1) / 2) + 1) * NODE_SPACING; + labels.push({ + id: orphanLabelId(group.type), + label: `${group.label} (${String(group.ids.length)})`, + x: anchor.cx + anchor.nx * labelDistance, + y: anchor.cy + anchor.ny * labelDistance, + }); + } + return { positions, labels }; +} + +/** Grid dimensions for `n` nodes (columns, rows). */ +function gridOf(n: number): { readonly cols: number; readonly rows: number } { + if (n <= 1) return { cols: 1, rows: 1 }; + const cols = Math.ceil(Math.sqrt(n)); + const rows = Math.ceil(n / cols); + return { cols, rows }; +} + +/** + * Synthetic ID for an orphan cluster's label node. Prefixed so it cannot + * collide with real SysProM node IDs (which use type prefixes like `INT1`). + */ +export function orphanLabelId(type: string): string { + return `orphan-label:${type}`; +} + +/** + * Build the list of orphan groups from a set of orphan IDs and a type lookup. + * Groups are sorted alphabetically by type so the peripheral layout is + * deterministic across runs. The label is the capitalised plural of the type + * (e.g. `invariant` -> `Invariants`). + */ +export function buildOrphanGroups( + orphanIds: readonly string[], + typeOf: (id: string) => string, +): OrphanGroup[] { + const byType = new Map(); + for (const id of orphanIds) { + const type = typeOf(id); + const bucket = byType.get(type); + if (bucket === undefined) { + byType.set(type, [id]); + } else { + bucket.push(id); + } + } + return [...byType.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([type, ids]) => ({ + type, + label: clusterLabel(type), + ids: [...ids].sort((a, b) => a.localeCompare(b)), + })); +} + +/** Capitalised plural label for a SysProM node type. */ +function clusterLabel(type: string): string { + const capitalised = type.charAt(0).toUpperCase() + type.slice(1); + return pluralise(capitalised); +} + +/** Naive English pluraliser, sufficient for the SysProM node-type vocabulary. */ +function pluralise(word: string): string { + if (word.endsWith("y") && !word.endsWith("ay")) { + return `${word.slice(0, -1)}ies`; + } + if ( + word.endsWith("s") || + word.endsWith("x") || + word.endsWith("ch") || + word.endsWith("sh") + ) { + return `${word}es`; + } + return `${word}s`; } // --------------------------------------------------------------------------- @@ -170,33 +488,105 @@ function readIncludes(node: Node): string[] { /** * After the primary layout has settled, reposition view nodes to the centroid * of their `includes` members, then place truly-orphan nodes (no incident - * layout edges and not in any view) in a loose arc along the bottom periphery - * of the laid-out bounding box. + * layout edges and not in any view with visible members) in type-grouped + * clusters around the periphery of the laid-out bounding box. Each cluster + * is annotated with a labelled synthetic node. * * The `layoutEdges` parameter is the set of edges that drove the layout * (backbone for Refinement, emergent for Emergent topology, all for Overview). * A node is an orphan only if none of these edges touch it. Overlay edges * (hidden during layout) do not count — they were not part of the ranking. * - * The bounding box for the arc is computed from connected (non-orphan) nodes - * only, so the arc sits below the main laid-out mass rather than being skewed - * by orphan positions that the primary layout may have scattered. + * The bounding box for the clusters is computed from connected (non-orphan) + * nodes only, so the clusters sit around the main laid-out mass rather than + * being skewed by orphan positions that the primary layout may have + * scattered. * * The `doc` provides view `includes` membership data so view nodes can be - * positioned at their members' centroid. + * positioned at their members' centroid. Views with no visible members fall + * through to orphan grouping under their own type. */ export function placeOrphansAfterLayout( cy: Core, doc: SysProMDocument, layoutEdges: ReadonlySet, -): void { +): readonly OrphanLabel[] { const visibleNodes = cy.nodes(":visible"); const viewIncludes = collectViewIncludes(doc); - const viewIds = new Set(viewIncludes.keys()); - const orphanIds = identifyLayoutOrphans(visibleNodes, viewIds, layoutEdges); - placeOrphanArc(cy, visibleNodes, orphanIds, viewIds); - placeViewsAtCentroid(cy, viewIncludes); + // First pass: position views that have visible members at their centroid. + // Views without visible members are returned so they fall through to + // orphan grouping. + const orphanedViews = placeViewsAtCentroid(cy, viewIncludes); + const viewIdsWithMembers = new Set(); + for (const [viewId] of viewIncludes) { + if (!orphanedViews.has(viewId)) viewIdsWithMembers.add(viewId); + } + + const orphanIds = identifyLayoutOrphans( + visibleNodes, + viewIdsWithMembers, + layoutEdges, + ); + // Type lookup straight from the document so the pure helpers stay free of + // Cytoscape coupling. + const typeOfNode = buildTypeLookup(doc); + const groups = buildOrphanGroups(orphanIds, typeOfNode); + const labels = placeOrphanClusters(cy, visibleNodes, groups); + // Finally, enforce the hard node-clearance invariant across every visible + // node (main mass, view-centroid placements, orphan clusters, and labels). + enforceClearanceOnCytoscape(cy); + return labels; +} + +/** + * Enforce the hard node-clearance invariant on a settled Cytoscape instance: + * iterate every visible node, build `ClearanceNode` records from current + * positions, run the pure separation pass, and write the adjusted positions + * back. Decorative orphan-label nodes participate but are free to move. + * + * Intended to run after every layout (structural layouts call it via + * `placeOrphansAfterLayout`; the Trace layout calls it directly). + */ +export function enforceClearanceOnCytoscape(cy: Core): void { + const visible = cy.nodes(":visible"); + const visibleArray = visible.toArray(); + if (visibleArray.length < 2) return; + const clearance: ClearanceNode[] = []; + const positions = new Map(); + for (const node of visibleArray) { + const pos = node.position(); + // Real nodes render at the stylesheet's 28px size (14px radius). + // Compound cluster parents (the "By subsystem" layout) are containers, + // not point nodes, so they participate with zero radius. + const isCluster = node.data("type") === "cluster"; + clearance.push({ + id: node.id(), + x: pos.x, + y: pos.y, + radius: isCluster ? 0 : 14, + fixed: false, + }); + positions.set(node.id(), { x: pos.x, y: pos.y }); + } + enforceClearance(clearance); + for (const cn of clearance) { + const original = positions.get(cn.id); + if (original === undefined) continue; + if (original.x === cn.x && original.y === cn.y) continue; + cy.getElementById(cn.id).position({ x: cn.x, y: cn.y }); + } +} + +/** + * Build an ID -> type lookup from the document for the pure grouping helper. + */ +function buildTypeLookup(doc: SysProMDocument): (id: string) => string { + const types = new Map(); + for (const node of doc.nodes) { + types.set(node.id, node.type); + } + return (id: string): string => types.get(id) ?? "node"; } /** @@ -213,20 +603,32 @@ function collectViewIncludes(doc: SysProMDocument): Map { return result; } +/** + * Type guard extracting a `string[]` from a node's `includes` field using + * safe `in`-based narrowing (no index-signature access or `as` casts). + */ +function readIncludes(node: Node): string[] { + if (!("includes" in node)) return []; + const value = node.includes; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + /** * Identify visible nodes that have zero incident edges in the `layoutEdges` - * set, excluding view nodes (which are positioned at their members' centroid - * rather than in the orphan arc). + * set, excluding view nodes that were placed at their members' centroid. + * Views without visible members are NOT excluded here — they fall through to + * orphan grouping. */ function identifyLayoutOrphans( visibleNodes: NodeCollection, - viewIds: Set, + viewIdsWithMembers: ReadonlySet, layoutEdges: ReadonlySet, -): Set { - const orphans = new Set(); +): string[] { + const orphans: string[] = []; for (const node of visibleNodes) { const id = node.id(); - if (viewIds.has(id)) continue; + if (viewIdsWithMembers.has(id)) continue; let hasLayoutEdge = false; const incident = node.connectedEdges(); for (const edge of incident) { @@ -235,46 +637,57 @@ function identifyLayoutOrphans( break; } } - if (!hasLayoutEdge) orphans.add(id); + if (!hasLayoutEdge) orphans.push(id); } return orphans; } /** - * Reposition orphan nodes into a peripheral arc below the connected mass. - * The bounding box is computed from connected (non-orphan, non-view) nodes - * only, so the arc sits below the main laid-out mass. + * Reposition orphan nodes into type-grouped peripheral clusters and render a + * label node per cluster. The bounding box is computed from connected + * (non-orphan, non-view) nodes only, so the clusters sit around the main + * laid-out mass. Any label nodes left over from a previous layout pass are + * removed first. */ -function placeOrphanArc( +function placeOrphanClusters( cy: Core, visibleNodes: NodeCollection, - orphanIds: Set, - viewIds: Set, -): void { - if (orphanIds.size === 0) return; + groups: readonly OrphanGroup[], +): readonly OrphanLabel[] { + if (groups.length === 0) return []; const connected: PositionedNode[] = []; for (const node of visibleNodes) { const id = node.id(); - if (orphanIds.has(id) || viewIds.has(id)) continue; + // Skip nodes that are about to be repositioned as orphans; include + // view nodes at their members' centroid as part of the mass. + const isOrphan = groups.some((g) => g.ids.some((oid) => oid === id)); + if (isOrphan) continue; const pos = node.position(); connected.push({ id, x: pos.x, y: pos.y }); } const box = boundingBox(connected); - if (box === undefined) return; - const placements = placeOrphansInArc([...orphanIds], box); - for (const [id, pos] of placements) { + if (box === undefined) return []; + const placement = placeOrphansByType(groups, box); + for (const [id, pos] of placement.positions) { cy.getElementById(id).position(pos); } + // Labels are returned (not rendered as Cytoscape nodes) so the React layer + // can render them as HTML overlays, which stay readable at any zoom unlike + // canvas text. + return placement.labels; } /** * Reposition each view node to the centroid of its visible members. - * Runs after orphan arc placement so the centroid reflects final positions. + * Runs before orphan placement so the bounding box of the connected mass + * reflects final view positions. Returns the set of view IDs that had no + * visible members — these fall through to orphan grouping. */ function placeViewsAtCentroid( cy: Core, - viewIncludes: Map, -): void { + viewIncludes: ReadonlyMap, +): Set { + const orphaned = new Set(); for (const [viewId, memberIds] of viewIncludes) { const viewNode = cy.getElementById(viewId); if (viewNode.empty()) continue; @@ -288,6 +701,11 @@ function placeViewsAtCentroid( const center = centroid(memberPositions); if (center !== undefined) { viewNode.position(center); + } else { + // No visible members: the view is effectively orphaned and will + // be grouped by its type alongside the other orphans. + orphaned.add(viewId); } } + return orphaned; } diff --git a/packages/web/src/graph/stylesheets.ts b/packages/web/src/graph/stylesheets.ts index 8c6979a..77c115c 100644 --- a/packages/web/src/graph/stylesheets.ts +++ b/packages/web/src/graph/stylesheets.ts @@ -224,6 +224,27 @@ export function buildStylesheet(): StylesheetJson { selector: ".highlighted", style: { opacity: 1, "border-width": 4 }, }, + // Compound cluster parents ("By subsystem" layout): transparent + // bounding boxes with a labelled title, drawn behind their children. + { + selector: 'node[type="cluster"]', + style: { + "background-opacity": 0.05, + "border-width": 1, + "border-style": "dashed", + "border-color": themeTokens.color.textMuted, + "font-size": "11px", + "font-weight": "bold", + color: themeTokens.color.text, + "text-valign": "top", + "text-halign": "center", + "text-margin-y": 6, + shape: "round-rectangle", + width: 16, + height: 16, + padding: "8px", + }, + }, ]; return [...base, ...typeSelectors, ...relSelectors, ...semantic]; From a50e18c8c096c6a863bc699663240adaa0c0350e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 24 Jun 2026 23:26:45 +0100 Subject: [PATCH 24/28] test(web): cover type-grouped orphan placement and clearance invariant Replace the placeOrphansInArc tests with coverage for the new pure helpers (buildOrphanGroups, placeOrphansByType, orphanLabelId) and add a suite for enforceClearance: no-op on small inputs, pairwise separation to MIN_NODE_CLEARANCE, fixed-node handling, and a tight 2D cluster converging without overlap. --- packages/web/tests/orphan-placement.test.ts | 240 ++++++++++++++++---- 1 file changed, 199 insertions(+), 41 deletions(-) diff --git a/packages/web/tests/orphan-placement.test.ts b/packages/web/tests/orphan-placement.test.ts index bfcdaa8..dfc4d6d 100644 --- a/packages/web/tests/orphan-placement.test.ts +++ b/packages/web/tests/orphan-placement.test.ts @@ -1,11 +1,16 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { + MIN_NODE_CLEARANCE, boundingBox, + buildOrphanGroups, centroid, + enforceClearance, identifyOrphans, - placeOrphansInArc, + orphanLabelId, + placeOrphansByType, } from "../src/graph/orphanPlacement"; +import type { ClearanceNode, OrphanGroup } from "../src/graph/orphanPlacement"; // `describe`/`it` from node:test return thenable `Test` objects, so each call // is prefixed with `void` to satisfy `no-floating-promises` without relaxing @@ -77,55 +82,208 @@ void describe("orphan placement", () => { }); }); - void describe("placeOrphansInArc", () => { - void it("returns empty map for zero orphans", () => { - const box = { x1: 0, y1: 0, x2: 200, y2: 100 }; - assert.equal(placeOrphansInArc([], box).size, 0); + void describe("buildOrphanGroups", () => { + void it("returns empty for no orphans", () => { + assert.deepEqual( + buildOrphanGroups([], () => "x"), + [], + ); }); - void it("centres a single orphan", () => { - const box = { x1: 0, y1: 0, x2: 200, y2: 100 }; - const result = placeOrphansInArc(["X"], box); - const pos = result.get("X"); - assert.ok(pos); - assert.equal(pos.x, 100); // centred - assert.ok(pos.y > 100); // below the box + void it("groups IDs by type and sorts groups by type name", () => { + const typeOf = (id: string): string => { + if (id.startsWith("D")) return "decision"; + if (id.startsWith("I")) return "invariant"; + return "policy"; + }; + const groups = buildOrphanGroups(["I2", "D1", "I1", "P1", "D2"], typeOf); + assert.equal(groups.length, 3); + // Alphabetical by type: decision, invariant, policy. + assert.equal(groups[0].type, "decision"); + assert.equal(groups[1].type, "invariant"); + assert.equal(groups[2].type, "policy"); + // IDs sorted within each group. + assert.deepEqual(groups[0].ids, ["D1", "D2"]); + assert.deepEqual(groups[1].ids, ["I1", "I2"]); + assert.deepEqual(groups[2].ids, ["P1"]); }); - void it("spreads multiple orphans horizontally below the box", () => { - const box = { x1: 0, y1: 0, x2: 300, y2: 100 }; - const ids = ["O1", "O2", "O3", "O4"]; - const result = placeOrphansInArc(ids, box); - assert.equal(result.size, 4); - const xs = ids.map((id) => { - const p = result.get(id); - assert.ok(p); - return p.x; - }); - // Strictly increasing left to right. - for (let i = 1; i < xs.length; i++) { - assert.ok( - xs[i] > xs[i - 1], - `expected increasing x at index ${String(i)}`, - ); + void it("capitalises and pluralises the label", () => { + const groups = buildOrphanGroups(["I1"], () => "invariant"); + assert.equal(groups[0].label, "Invariants"); + const policies = buildOrphanGroups(["P1"], () => "policy"); + assert.equal(policies[0].label, "Policies"); + }); + }); + + void describe("placeOrphansByType", () => { + const box = { x1: 0, y1: 0, x2: 400, y2: 200 }; + + void it("returns empty placement for no groups", () => { + const result = placeOrphansByType([], box); + assert.equal(result.positions.size, 0); + assert.equal(result.labels.length, 0); + }); + + void it("places a single group as a compact grid below the box", () => { + const group: OrphanGroup = { + type: "invariant", + label: "Invariants", + ids: ["I1", "I2", "I3"], + }; + const result = placeOrphansByType([group], box); + assert.equal(result.positions.size, 3); + assert.equal(result.labels.length, 1); + // Every node sits below the bounding box (single group -> bottom). + for (const id of group.ids) { + const pos = result.positions.get(id); + assert.ok(pos, `missing position for ${id}`); + assert.ok(pos.y > 200, `${id} should be below the box`); } - // All below the bounding box. - for (const id of ids) { - const p = result.get(id); + // Nodes must NOT be collinear in a single horizontal line: they + // form a 2-row grid, so at least two distinct Y values exist. + const ys = new Set( + group.ids.map((id) => { + const p = result.positions.get(id); + assert.ok(p); + return p.y; + }), + ); + assert.ok(ys.size > 1, "cluster must be a grid, not a line"); + // Label sits further out than the nodes. + const label = result.labels[0]; + assert.ok(label.y > 200, "label should be below the box"); + assert.equal(label.label, "Invariants (3)"); + assert.equal(label.id, orphanLabelId("invariant")); + }); + + void it("distributes multiple groups around the periphery", () => { + const groups: OrphanGroup[] = [ + { type: "decision", label: "Decisions", ids: ["D1", "D2"] }, + { type: "invariant", label: "Invariants", ids: ["I1"] }, + { type: "policy", label: "Policies", ids: ["P1", "P2"] }, + { type: "change", label: "Changes", ids: ["C1"] }, + ]; + const result = placeOrphansByType(groups, box); + assert.equal(result.positions.size, 6); + assert.equal(result.labels.length, 4); + // Collect which side each group centre falls on. + const centres = groups.map((g) => { + const first = g.ids[0]; + const p = result.positions.get(first); assert.ok(p); - assert.ok(p.y > 100, `orphan ${id} should be below box`); + return { type: g.type, x: p.x, y: p.y }; + }); + const below = centres.filter((c) => c.y > 200).length; + const above = centres.filter((c) => c.y < 0).length; + const right = centres.filter((c) => c.x > 400).length; + const left = centres.filter((c) => c.x < 0).length; + // With four groups the anchors cycle through all four sides. + assert.ok(below >= 1, "at least one group below"); + assert.ok(above >= 1, "at least one group above"); + assert.ok(right >= 1, "at least one group to the right"); + assert.ok(left >= 1, "at least one group to the left"); + }); + + void it("does not place every orphan on the same Y (no single line)", () => { + const groups: OrphanGroup[] = Array.from({ length: 5 }, (_, i) => { + const s = String(i); + return { + type: `t${s}`, + label: `T${s}`, + ids: [`n${s}-1`, `n${s}-2`, `n${s}-3`], + }; + }); + const result = placeOrphansByType(groups, box); + const ys = new Set(); + for (const g of groups) { + for (const id of g.ids) { + const p = result.positions.get(id); + assert.ok(p); + ys.add(Math.round(p.y)); + } } + // Five groups across four sides => multiple distinct Y bands. + assert.ok(ys.size > 2, "orphans must span multiple Y bands, not a line"); + }); + }); + + void describe("enforceClearance", () => { + /** Build a fresh ClearanceNode (ids must be unique per test). */ + const node = ( + id: string, + x: number, + y: number, + radius = 14, + fixed = false, + ): ClearanceNode => ({ id, x, y, radius, fixed }); + + void it("is a no-op for fewer than two nodes", () => { + const single = [node("A", 0, 0)]; + enforceClearance(single); + assert.equal(single[0].x, 0); + assert.equal(single[0].y, 0); + }); + + void it("pushes apart overlapping nodes to at least MIN_NODE_CLEARANCE", () => { + const nodes = [ + node("A", 0, 0), + node("B", 10, 0), // 24px apart centre-to-centre < 56 + ]; + enforceClearance(nodes); + const dx = nodes[1].x - nodes[0].x; + const dy = nodes[1].y - nodes[0].y; + const dist = Math.sqrt(dx * dx + dy * dy); + const target = 14 + 14 + MIN_NODE_CLEARANCE; + assert.ok( + dist >= target - 0.5, + `expected >= ${String(target)}, got ${String(dist)}`, + ); + }); + + void it("leaves already-separated nodes untouched", () => { + const far = 200; + const nodes = [node("A", 0, 0), node("B", far, 0)]; + enforceClearance(nodes); + assert.equal(nodes[0].x, 0); + assert.equal(nodes[1].x, far); }); - void it("gap scales with box height", () => { - const smallBox = { x1: 0, y1: 0, x2: 200, y2: 50 }; - const tallBox = { x1: 0, y1: 0, x2: 200, y2: 1000 }; - const smallPos = placeOrphansInArc(["X"], smallBox).get("X"); - const tallPos = placeOrphansInArc(["X"], tallBox).get("X"); - assert.ok(smallPos); - assert.ok(tallPos); - // Tall box should push orphan further down. - assert.ok(tallPos.y > smallPos.y); + void it("respects fixed nodes by moving only the free one", () => { + const nodes = [node("A", 0, 0, 14, true), node("B", 10, 0, 14, false)]; + enforceClearance(nodes); + assert.equal(nodes[0].x, 0); // fixed A did not move + const dx = nodes[1].x - nodes[0].x; + const dist = Math.abs(dx); + const target = 14 + 14 + MIN_NODE_CLEARANCE; + assert.ok( + dist >= target - 0.5, + `free node should have been pushed to ${String(target)}`, + ); + }); + + void it("resolves a tight 2D cluster so every pair clears the minimum", () => { + // Four nodes in a tight 2x2 block: the relaxation has room to + // push them apart in two dimensions without oscillation. + const nodes: ClearanceNode[] = [ + node("A", 0, 0), + node("B", 5, 0), + node("C", 0, 5), + node("D", 5, 5), + ]; + enforceClearance(nodes); + const target = 14 + 14 + MIN_NODE_CLEARANCE; + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const dx = nodes[j].x - nodes[i].x; + const dy = nodes[j].y - nodes[i].y; + const dist = Math.sqrt(dx * dx + dy * dy); + assert.ok( + dist >= target - 0.5, + `pair ${nodes[i].id}/${nodes[j].id} too close: ${String(dist)} < ${String(target)}`, + ); + } + } }); }); }); From 9dfad38150b7f3747c064da6cd26120a5a31cc43 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 25 Jun 2026 06:18:08 +0100 Subject: [PATCH 25/28] feat(web): add ELK layered layout with orthogonal edge routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an ELK Layered layout option alongside the existing five layout modes. ELK's layered algorithm assigns nodes to discrete layers, minimises edge crossings (LAYER_SWEEP strategy), and routes edges orthogonally — so edges go around nodes rather than through them. The default elkjs entry spawns a web worker that 404s under Vite. Instead the bundled build (elkjs/lib/elk.bundled.js) runs the layout engine synchronously on the main thread, no worker required. It is lazy-loaded via dynamic import() so it lands in its own Vite chunk, fetched only when the ELK Layered layout is activated. ELK's orthogonal edge routes are applied to Cytoscape edges as segments curve-style control points (segment-distances / segment-weights), computed from each edge's bend points relative to the source-target line. The elk-routed stylesheet class enables segments mode per-edge only when ELK routing data is present. After positions settle, the same post-layout passes run as the other layouts: placeOrphansAfterLayout (type-grouped peripheral clusters + view centroids) and enforceClearanceOnCytoscape (minimum node clearance). Pure helper functions (buildElkGraph, processElkResult, computeSegments) are unit-tested without a browser. --- packages/web/package.json | 1 + packages/web/src/graph/CytoscapeGraph.tsx | 107 ++++++- packages/web/src/graph/elk-bundled.d.ts | 17 + packages/web/src/graph/elkLayout.ts | 359 ++++++++++++++++++++++ packages/web/src/graph/layouts.ts | 2 + packages/web/src/graph/stylesheets.ts | 13 + packages/web/src/tabs/GraphsTab.tsx | 2 + packages/web/tests/elk-layout.test.ts | 297 ++++++++++++++++++ pnpm-lock.yaml | 8 + 9 files changed, 805 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/graph/elk-bundled.d.ts create mode 100644 packages/web/src/graph/elkLayout.ts create mode 100644 packages/web/tests/elk-layout.test.ts diff --git a/packages/web/package.json b/packages/web/package.json index 572ff60..02bb430 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -20,6 +20,7 @@ "cytoscape": "3.34.0", "cytoscape-dagre": "4.0.0", "cytoscape-fcose": "2.2.0", + "elkjs": "0.11.1", "mermaid": "11.15.0", "react": "19.2.7", "react-dom": "19.2.7" diff --git a/packages/web/src/graph/CytoscapeGraph.tsx b/packages/web/src/graph/CytoscapeGraph.tsx index ba2ad1c..3d0434b 100644 --- a/packages/web/src/graph/CytoscapeGraph.tsx +++ b/packages/web/src/graph/CytoscapeGraph.tsx @@ -12,6 +12,7 @@ import cytoscape, { type Collection, type EventObject, type NodeSingular, + type EdgeSingular, } from "cytoscape"; import fcose from "cytoscape-fcose"; import dagre from "cytoscape-dagre"; @@ -41,6 +42,7 @@ import { enforceClearanceOnCytoscape, type OrphanLabel, } from "./orphanPlacement"; +import { runElkLayout, type VisibleNode, type VisibleEdge } from "./elkLayout"; // Register the layout extensions once. cytoscape.use(fcose); @@ -58,6 +60,18 @@ function isNodeSingular(target: unknown): target is NodeSingular { return Boolean(fn.call(target)); } +/** + * Extract the `type` data field from a Cytoscape edge, narrowing from `any` + * to `string` without a type assertion. Returns `"unknown"` if the field is + * absent or not a string — should not happen for well-formed elements but + * avoids propagating `any` into the ELK layer. + */ +function edgeDataType(edge: EdgeSingular): string { + const value: unknown = edge.data("type"); + if (typeof value === "string") return value; + return "unknown"; +} + export interface CytoscapeGraphHandle { /** The underlying Cytoscape core instance. */ readonly cy: Core; @@ -158,7 +172,7 @@ export function CytoscapeGraph({ // Rebuild the element set when the document changes, or when switching // between the flat and subsystem (compound) element sets. The subsystem // layout flattens the recursive document tree into compound clusters and - // needs a different element set; the other four layouts share the flat set. + // needs a different element set; the other layouts share the flat set. const useSubsystemElements = layout === "subsystem"; useEffect(() => { const cy = cyRef.current; @@ -239,6 +253,8 @@ export function CytoscapeGraph({ runFcoseLayout(cy, doc, buildSubsystemLayoutOptions(), setLabels); } else if (layout === "overview") { runFcoseLayout(cy, doc, buildOverviewLayoutOptions(), setLabels); + } else if (layout === "elk") { + void runElkLayeredLayout(cy, doc, setLabels); } // layout === "trace" is handled by the dedicated trace effect below. }, [layout, doc, useSubsystemElements]); @@ -425,6 +441,95 @@ function runFcoseLayout( layout.run(); } +/** + * Run the ELK layered layout asynchronously, then apply the computed node + * positions via Cytoscape's `preset` layout and render ELK's orthogonal edge + * routes as per-edge `segments` curve-style control points. + * + * After positions settle, the same post-layout passes run as the other + * layouts: `placeOrphansAfterLayout` (type-grouped peripheral clusters + + * view centroids) and `enforceClearanceOnCytoscape` (minimum node clearance). + * + * Edges that are not routed by ELK (supersedes, or edges with no sections) are + * left as straight beziers — the `elk-routed` class is only applied to edges + * that received orthogonal routes. + */ +async function runElkLayeredLayout( + cy: Core, + doc: SysProMDocument, + onLabels: (labels: readonly OrphanLabel[]) => void, +): Promise { + // Gather visible nodes and edges in the shape ELK expects. + const visibleNodes: VisibleNode[] = cy + .nodes(":visible") + .map((node): VisibleNode => ({ id: node.id() })); + const visibleEdges: VisibleEdge[] = cy.edges(":visible").map( + (edge): VisibleEdge => ({ + id: edge.id(), + source: edge.source().id(), + target: edge.target().id(), + type: edgeDataType(edge), + }), + ); + + // Remove any stale elk-routed class and segment data from a previous run. + cy.edges().removeClass("elk-routed"); + cy.edges().forEach((edge) => { + edge.removeData("segmentDistances"); + edge.removeData("segmentWeights"); + }); + + const result = await runElkLayout(visibleNodes, visibleEdges); + + // Apply node positions via the preset layout so Cytoscape animates to them. + const positionMap = new Map(); + for (const [id, pos] of result.positions) { + positionMap.set(id, { x: pos.x, y: pos.y }); + } + const presetLayout = cy.layout({ + name: "preset", + animate: true, + animationDuration: 500, + animationEasing: "ease-out", + fit: false, + padding: 40, + positions: (nodeId): { x: number; y: number } => { + const pos = positionMap.get(nodeId); + return pos ?? { x: 0, y: 0 }; + }, + }); + presetLayout.one("layoutstop", () => { + // Apply ELK's orthogonal edge routes as per-edge segment data. + for (const route of result.routes) { + const edgeId = `${route.source}->${route.target}:${route.type}`; + const edge = cy.getElementById(edgeId); + if (edge.empty()) continue; + edge.data("segmentDistances", [...route.segmentDistances]); + edge.data("segmentWeights", [...route.segmentWeights]); + edge.addClass("elk-routed"); + } + + // Collect the layout edge IDs (all visible edges except supersedes, which + // ELK did not route) for orphan detection. + const layoutEdgeIds = new Set(); + cy.edges(":visible").forEach((edge) => { + if (edge.data("type") !== "supersedes") { + layoutEdgeIds.add(edge.id()); + } + }); + + // Run the standard post-layout passes so the ELK view is consistent + // with the other layouts: peripheral orphan clusters + view centroids + + // minimum node clearance. + setTimeout(() => { + const labels = placeOrphansAfterLayout(cy, doc, layoutEdgeIds); + onLabels(labels); + cy.fit(undefined, 40); + }, 0); + }); + presetLayout.run(); +} + /** Highlight a node's neighbourhood and dim everything else. */ function highlightNeighbourhood(cy: Core, nodeId: string): void { const neighbours = neighbourhoodElementIds(cy, nodeId); diff --git a/packages/web/src/graph/elk-bundled.d.ts b/packages/web/src/graph/elk-bundled.d.ts new file mode 100644 index 0000000..a3572fc --- /dev/null +++ b/packages/web/src/graph/elk-bundled.d.ts @@ -0,0 +1,17 @@ +/** + * Ambient declaration for the bundled ELK build (`elkjs/lib/elk.bundled.js`). + * + * The bundled ELK build runs the layout engine on the main thread without + * spawning a web worker. The default `elkjs` entry (`lib/main.js`) spawns + * `elk-worker.min.js` which 404s under Vite; this bundled entry avoids that. + * + * Only the default constructor is declared here. The ELK type interfaces + * (ElkNode, ElkExtendedEdge, etc.) are imported directly from + * `elkjs/lib/elk-api` — elkjs's own type definitions — in consuming modules. + */ +declare module "elkjs/lib/elk.bundled.js" { + import type { ELK, ELKConstructorArguments } from "elkjs/lib/elk-api"; + type ElkConstructor = new (args?: ELKConstructorArguments) => ELK; + const elkConstructor: ElkConstructor; + export default elkConstructor; +} diff --git a/packages/web/src/graph/elkLayout.ts b/packages/web/src/graph/elkLayout.ts new file mode 100644 index 0000000..d1195fa --- /dev/null +++ b/packages/web/src/graph/elkLayout.ts @@ -0,0 +1,359 @@ +/** + * ELK layered graph layout — builds an ELK JSON graph from Cytoscape visible + * elements, runs the ELK layered algorithm (main-thread bundled build), and + * extracts node positions plus orthogonal edge routes. + * + * The default `elkjs` entry (`new ELK()` from `elkjs`) spawns a web worker + * (`elk-worker.min.js`) that 404s under Vite. Instead we use + * `elkjs/lib/elk.bundled.js` — a self-contained UMD bundle that runs the entire + * layout engine synchronously on the main thread, no worker required. The + * bundled build is dynamically imported so it lands in its own chunk, loaded + * only when the ELK Layered layout is activated. + * + * ELK's layered algorithm assigns nodes to discrete layers and minimises edge + * crossings, then routes edges orthogonally — so edges go *around* nodes rather + * than through them. The edge section bend points are applied to Cytoscape + * edges as `segments` curve-style control points, preserving ELK's orthogonal + * routing in the rendered graph. + */ +import type { + ElkNode, + ElkExtendedEdge, + ElkEdgeSection, + ElkPoint, +} from "elkjs/lib/elk-api"; + +import { MIN_NODE_CLEARANCE } from "./orphanPlacement"; + +/** + * Uniform node box size fed to ELK. ELK needs concrete dimensions so it can + * pack layers and route edges around node bounding boxes. The value is large + * enough relative to the stylesheet's 28px rendered node that ELK's orthogonal + * router leaves visible clearance. + */ +export const ELK_NODE_WIDTH = 60; +export const ELK_NODE_HEIGHT = 30; + +/** + * ELK layered layout configuration. Direction DOWN stacks layers top-to-bottom; + * orthogonal edge routing makes edges route around nodes; crossing minimisation + * uses the LAYER_SWEEP strategy. Spacing values are tuned for readability on a + * dense graph and respect the global minimum node clearance. + */ +export const ELK_LAYOUT_OPTIONS: Readonly> = { + "elk.algorithm": "layered", + "elk.direction": "DOWN", + "elk.layered.spacing.nodeNodeBetweenLayers": String( + Math.max(MIN_NODE_CLEARANCE, 80), + ), + "elk.layered.spacing.edgeNodeBetweenLayers": "40", + "elk.spacing.nodeNode": String(MIN_NODE_CLEARANCE), + "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", + "elk.edgeRouting": "orthogonal", + "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", + "elk.layered.nodePlacement.favorStraightEdges": "true", +}; + +/** + * A node visible in the Cytoscape graph, in the minimal shape ELK needs. + */ +export interface VisibleNode { + readonly id: string; +} + +/** + * An edge visible in the Cytoscape graph, in the minimal shape ELK needs. + */ +export interface VisibleEdge { + readonly id: string; + readonly source: string; + readonly target: string; + readonly type: string; +} + +/** + * Build the ELK root graph JSON from visible nodes and edges. Every + * relationship type is included except `supersedes` (consistent with the + * Emergent topology). Edges referencing nodes not in the visible set are + * skipped so ELK does not receive dangling references. The edge mapping + * records the Cytoscape source/target/type for each ELK edge index so + * `processElkResult` can translate routes back. + * + * Pure: no side effects, no I/O. Tested without a browser. + */ +export function buildElkGraph( + nodes: readonly VisibleNode[], + edges: readonly VisibleEdge[], +): { + readonly graph: ElkNode; + readonly edgeMapping: { + readonly source: string; + readonly target: string; + readonly type: string; + }[]; +} { + const nodeIds = new Set(nodes.map((n) => n.id)); + const children: ElkNode[] = nodes.map((node) => ({ + id: node.id, + width: ELK_NODE_WIDTH, + height: ELK_NODE_HEIGHT, + })); + const elkEdges: ElkExtendedEdge[] = []; + const edgeMapping: { + source: string; + target: string; + type: string; + }[] = []; + let edgeIndex = 0; + for (const edge of edges) { + if (edge.type === "supersedes") continue; + if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) continue; + elkEdges.push({ + id: `elk-edge-${String(edgeIndex++)}`, + sources: [edge.source], + targets: [edge.target], + }); + edgeMapping.push({ + source: edge.source, + target: edge.target, + type: edge.type, + }); + } + return { + graph: { + id: "root", + layoutOptions: { ...ELK_LAYOUT_OPTIONS }, + children, + edges: elkEdges, + }, + edgeMapping, + }; +} + +/** + * A computed position for a single node. + */ +export interface NodePosition { + readonly x: number; + readonly y: number; +} + +/** + * An orthogonal edge route: the source and target node IDs of the Cytoscape + * edge this route applies to, plus the ordered list of bend points (in + * Cytoscape model coordinates, already offset by node half-dimensions), and + * the computed Cytoscape `segments` curve-style parameters. + */ +export interface EdgeRoute { + readonly source: string; + readonly target: string; + readonly type: string; + readonly points: readonly { readonly x: number; readonly y: number }[]; + /** + * Cytoscape `segment-distances` values: signed perpendicular distances + * from each bend point to the source-target straight line. Paired with + * `segmentWeights`. + */ + readonly segmentDistances: readonly number[]; + /** + * Cytoscape `segment-weights` values: fractional position (0-1) of each + * bend point projected onto the source-target straight line. Paired with + * `segmentDistances`. + */ + readonly segmentWeights: readonly number[]; +} + +/** + * Result of an ELK layout run: per-node positions and per-edge orthogonal + * routes, keyed for application to Cytoscape elements. + */ +export interface ElkLayoutResult { + readonly positions: ReadonlyMap; + readonly routes: readonly EdgeRoute[]; +} + +/** + * Narrow `unknown` to `Record` without a type assertion. + * After `typeof === "object"`, `!== null`, and `!Array.isArray()`, the value + * is a plain object whose properties can be safely accessed by key. + */ +function isStringRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Determine whether an ELK result child or edge has positioned content. Used as + * a type guard over the `unknown` sections of the raw ELK JSON output. + */ +function isElkPoint(value: unknown): value is ElkPoint { + if (!isStringRecord(value)) return false; + if (!("x" in value) || !("y" in value)) return false; + return typeof value.x === "number" && typeof value.y === "number"; +} + +/** + * Type guard: narrow an `unknown` to `ElkEdgeSection[]` (ELK edge sections). + * Uses `in`-based narrowing on each element via `isStringRecord` so member + * access on `startPoint` / `endPoint` is type-safe. + */ +function isSectionArray(value: unknown): value is ElkEdgeSection[] { + if (!Array.isArray(value)) return false; + return value.every((item) => { + if (!isStringRecord(item)) return false; + if (!("startPoint" in item) || !("endPoint" in item)) return false; + return isElkPoint(item.startPoint) && isElkPoint(item.endPoint); + }); +} + +/** + * Extract ordered bend points from an ELK edge's sections. Each section has a + * start point, optional bend points, and an end point; concatenated they form + * the full orthogonal polyline. The first section's start point is the source + * anchor; subsequent sections chain start-to-end. + */ +function extractSectionPoints( + sections: readonly ElkEdgeSection[], +): { readonly x: number; readonly y: number }[] { + const points: { x: number; y: number }[] = []; + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + const start = section.startPoint; + // Avoid duplicating the junction point between consecutive sections: + // each section's start is the previous section's end. + if (i === 0) { + points.push({ x: start.x, y: start.y }); + } + if (section.bendPoints) { + for (const bp of section.bendPoints) { + if (isElkPoint(bp)) points.push({ x: bp.x, y: bp.y }); + } + } + const end = section.endPoint; + points.push({ x: end.x, y: end.y }); + } + return points; +} + +/** + * Compute Cytoscape `segments` curve-style parameters (weights and distances) + * for a set of bend points relative to the source-target line. + * + * For each bend point $B$: + * - **weight** $w = \frac{(B - S) \cdot \vec{d}}{|\vec{d}|^2}$ — the + * fractional position along the source-to-target vector $\vec{d}$. + * - **distance** $dist = \frac{(B - S) \times \vec{d}}{|\vec{d}|}$ — the signed + * perpendicular distance from $B$ to the source-target line (positive on one + * side, negative on the other). + * + * Interior bend points (excluding the first source point and last target + * point) are converted; endpoints are implied by Cytoscape's `segments` style. + * + * Pure: no side effects. Tested without a browser. + */ +export function computeSegments( + bendPoints: readonly { readonly x: number; readonly y: number }[], + source: { readonly x: number; readonly y: number }, + target: { readonly x: number; readonly y: number }, +): { readonly weights: number[]; readonly distances: number[] } { + const dx = target.x - source.x; + const dy = target.y - source.y; + const lenSq = dx * dx + dy * dy; + if (lenSq < 0.0001) return { weights: [], distances: [] }; + const len = Math.sqrt(lenSq); + const weights: number[] = []; + const distances: number[] = []; + // Interior points only — skip first (source anchor) and last (target anchor). + for (let i = 1; i < bendPoints.length - 1; i++) { + const bp = bendPoints[i]; + const px = bp.x - source.x; + const py = bp.y - source.y; + // Projection fraction along the source-target line. + const w = (px * dx + py * dy) / lenSq; + // Signed perpendicular distance (cross product / length). + const dist = (px * dy - py * dx) / len; + weights.push(w); + distances.push(dist); + } + return { weights, distances }; +} + +/** + * Process a completed ELK layout result into node positions and edge routes. + * The `edgeIndexToCytoscape` map translates ELK's sequential edge IDs back to + * the Cytoscape edge source/target/type so routes can be applied to the right + * Cytoscape edges. + * + * Node positions are offset by half the ELK node dimensions so the position + * represents the node centre (Cytoscape uses centre positioning; ELK uses + * top-left). + * + * Pure: no side effects. Tested without a browser. + */ +export function processElkResult( + result: ElkNode, + edgeIndexToCytoscape: readonly { + readonly source: string; + readonly target: string; + readonly type: string; + }[], +): ElkLayoutResult { + const positions = new Map(); + const children = result.children ?? []; + const halfW = ELK_NODE_WIDTH / 2; + const halfH = ELK_NODE_HEIGHT / 2; + for (const child of children) { + const x = child.x ?? 0; + const y = child.y ?? 0; + positions.set(child.id, { x: x + halfW, y: y + halfH }); + } + + const routes: EdgeRoute[] = []; + const edges = result.edges ?? []; + for (let i = 0; i < edges.length; i++) { + const elkEdge = edges[i]; + if (i >= edgeIndexToCytoscape.length) continue; + const mapping = edgeIndexToCytoscape[i]; + const sections = elkEdge.sections; + if (!isSectionArray(sections)) continue; + const points = extractSectionPoints(sections); + if (points.length < 2) continue; + // Compute Cytoscape segments parameters from the bend points relative + // to the source and target node centre positions. + const sourcePos = positions.get(mapping.source); + const targetPos = positions.get(mapping.target); + const seg = + sourcePos !== undefined && targetPos !== undefined + ? computeSegments(points, sourcePos, targetPos) + : { weights: [], distances: [] }; + routes.push({ + source: mapping.source, + target: mapping.target, + type: mapping.type, + points, + segmentDistances: seg.distances, + segmentWeights: seg.weights, + }); + } + + return { positions, routes }; +} + +/** + * Lazy-load the ELK bundled build (main-thread, no web worker) and run the + * layered layout. Returns node positions and orthogonal edge routes ready for + * application to Cytoscape. + * + * The dynamic import puts ELK into its own Vite chunk, loaded only when this + * function is called — keeping the initial bundle small. + */ +export async function runElkLayout( + nodes: readonly VisibleNode[], + edges: readonly VisibleEdge[], +): Promise { + const { default: ELK } = await import("elkjs/lib/elk.bundled.js"); + const elk = new ELK(); + + const { graph, edgeMapping } = buildElkGraph(nodes, edges); + const result = await elk.layout(graph); + return processElkResult(result, edgeMapping); +} diff --git a/packages/web/src/graph/layouts.ts b/packages/web/src/graph/layouts.ts index 6aa43d4..bb08e04 100644 --- a/packages/web/src/graph/layouts.ts +++ b/packages/web/src/graph/layouts.ts @@ -19,6 +19,7 @@ * - **Emergent topology** — fcose over emergent edges; clusters surface from connectivity. * - **By subsystem** — fcose compound layout grouping nodes by recursive subsystem. * - **Overview** — fcose over all edges. + * - **ELK Layered** — ELK layered algorithm with orthogonal edge routing. * - **Trace** — Cytoscape breadthfirst from a selected node. */ import type { @@ -40,6 +41,7 @@ export type LayoutMode = | "emergent" | "subsystem" | "overview" + | "elk" | "trace"; /** diff --git a/packages/web/src/graph/stylesheets.ts b/packages/web/src/graph/stylesheets.ts index 77c115c..c19008e 100644 --- a/packages/web/src/graph/stylesheets.ts +++ b/packages/web/src/graph/stylesheets.ts @@ -245,6 +245,19 @@ export function buildStylesheet(): StylesheetJson { padding: "8px", }, }, + // ELK-routed edges: when the ELK Layered layout is active, edges carry + // per-edge `segment-distances` / `segment-weights` data set from ELK's + // orthogonal bend points. The `elk-routed` class switches the curve + // style to `segments` so those control points take effect; the base + // edge style uses `bezier` which ignores segment data. + { + selector: "edge.elk-routed", + style: { + "curve-style": "segments", + "segment-distances": "data(segmentDistances)", + "segment-weights": "data(segmentWeights)", + }, + }, ]; return [...base, ...typeSelectors, ...relSelectors, ...semantic]; diff --git a/packages/web/src/tabs/GraphsTab.tsx b/packages/web/src/tabs/GraphsTab.tsx index 9b1df71..4e86834 100644 --- a/packages/web/src/tabs/GraphsTab.tsx +++ b/packages/web/src/tabs/GraphsTab.tsx @@ -28,6 +28,7 @@ const LAYOUT_LABELS: Readonly> = { emergent: "Emergent topology", subsystem: "By subsystem", overview: "Overview (fCoSE)", + elk: "ELK Layered", trace: "Trace from selected", }; @@ -36,6 +37,7 @@ const LAYOUT_MODES: readonly LayoutMode[] = [ "emergent", "subsystem", "overview", + "elk", "trace", ]; diff --git a/packages/web/tests/elk-layout.test.ts b/packages/web/tests/elk-layout.test.ts new file mode 100644 index 0000000..ae86a96 --- /dev/null +++ b/packages/web/tests/elk-layout.test.ts @@ -0,0 +1,297 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + buildElkGraph, + computeSegments, + processElkResult, + ELK_NODE_WIDTH, + ELK_NODE_HEIGHT, + ELK_LAYOUT_OPTIONS, + type VisibleNode, + type VisibleEdge, +} from "../src/graph/elkLayout"; + +// `describe`/`it` from node:test return thenable `Test` objects, so each call +// is prefixed with `void` to satisfy `no-floating-promises` without relaxing +// the rule. +void describe("ELK layout", () => { + void describe("buildElkGraph", () => { + void it("builds a graph with all visible nodes as children", () => { + const nodes: VisibleNode[] = [{ id: "A" }, { id: "B" }, { id: "C" }]; + const { graph, edgeMapping } = buildElkGraph(nodes, []); + assert.equal(graph.id, "root"); + assert.equal(graph.children?.length, 3); + assert.equal(graph.edges?.length, 0); + assert.equal(edgeMapping.length, 0); + }); + + void it("assigns uniform width and height to each node", () => { + const { graph } = buildElkGraph([{ id: "A" }], []); + const child = graph.children?.[0]; + assert.ok(child); + assert.equal(child.width, ELK_NODE_WIDTH); + assert.equal(child.height, ELK_NODE_HEIGHT); + }); + + void it("creates edges for all non-supersedes relationships", () => { + const nodes: VisibleNode[] = [{ id: "A" }, { id: "B" }, { id: "C" }]; + const edges: VisibleEdge[] = [ + { id: "e1", source: "A", target: "B", type: "refines" }, + { id: "e2", source: "B", target: "C", type: "affects" }, + { id: "e3", source: "A", target: "C", type: "supersedes" }, + ]; + const { graph, edgeMapping } = buildElkGraph(nodes, edges); + assert.equal(graph.edges?.length, 2); + assert.equal(edgeMapping.length, 2); + // The supersedes edge is excluded. + assert.equal( + edgeMapping.every((m) => m.type !== "supersedes"), + true, + ); + }); + + void it("skips edges referencing nodes not in the visible set", () => { + const nodes: VisibleNode[] = [{ id: "A" }, { id: "B" }]; + const edges: VisibleEdge[] = [ + { id: "e1", source: "A", target: "B", type: "refines" }, + { id: "e2", source: "A", target: "Z", type: "affects" }, + { id: "e3", source: "X", target: "B", type: "depends_on" }, + ]; + const { graph } = buildElkGraph(nodes, edges); + assert.equal(graph.edges?.length, 1); + }); + + void it("includes the layered algorithm in layout options", () => { + const { graph } = buildElkGraph([{ id: "A" }], []); + assert.equal(graph.layoutOptions["elk.algorithm"], "layered"); + assert.equal(graph.layoutOptions["elk.edgeRouting"], "orthogonal"); + }); + }); + + void describe("ELK_LAYOUT_OPTIONS", () => { + void it("uses DOWN direction", () => { + assert.equal(ELK_LAYOUT_OPTIONS["elk.direction"], "DOWN"); + }); + + void it("uses LAYER_SWEEP crossing minimisation", () => { + assert.equal( + ELK_LAYOUT_OPTIONS["elk.layered.crossingMinimization.strategy"], + "LAYER_SWEEP", + ); + }); + + void it("sets node spacing to at least the minimum clearance", () => { + const spacing = Number(ELK_LAYOUT_OPTIONS["elk.spacing.nodeNode"]); + assert.ok(spacing >= 56); + }); + }); + + void describe("processElkResult", () => { + void it("offsets node positions to centre coordinates", () => { + // ELK gives top-left positions; processElkResult should offset by + // half width/height so positions are centre-based. + const elkResult = { + id: "root", + children: [ + { + id: "A", + x: 0, + y: 0, + width: ELK_NODE_WIDTH, + height: ELK_NODE_HEIGHT, + }, + { + id: "B", + x: 100, + y: 200, + width: ELK_NODE_WIDTH, + height: ELK_NODE_HEIGHT, + }, + ], + edges: [], + }; + const result = processElkResult(elkResult, []); + const posA = result.positions.get("A"); + assert.ok(posA); + assert.equal(posA.x, ELK_NODE_WIDTH / 2); + assert.equal(posA.y, ELK_NODE_HEIGHT / 2); + const posB = result.positions.get("B"); + assert.ok(posB); + assert.equal(posB.x, 100 + ELK_NODE_WIDTH / 2); + assert.equal(posB.y, 200 + ELK_NODE_HEIGHT / 2); + }); + + void it("extracts edge routes with segment data", () => { + const elkResult = { + id: "root", + children: [ + { + id: "A", + x: 0, + y: 0, + width: ELK_NODE_WIDTH, + height: ELK_NODE_HEIGHT, + }, + { + id: "B", + x: 200, + y: 0, + width: ELK_NODE_WIDTH, + height: ELK_NODE_HEIGHT, + }, + ], + edges: [ + { + id: "elk-edge-0", + sources: ["A"], + targets: ["B"], + sections: [ + { + id: "s0", + startPoint: { x: 30, y: 15 }, + endPoint: { x: 200, y: 15 }, + }, + ], + }, + ], + }; + const mapping = [{ source: "A", target: "B", type: "refines" }]; + const result = processElkResult(elkResult, mapping); + assert.equal(result.routes.length, 1); + const route = result.routes[0]; + assert.equal(route.source, "A"); + assert.equal(route.target, "B"); + assert.equal(route.type, "refines"); + assert.ok(route.points.length >= 2); + }); + + void it("handles edges without sections gracefully", () => { + const elkResult = { + id: "root", + children: [ + { + id: "A", + x: 0, + y: 0, + width: ELK_NODE_WIDTH, + height: ELK_NODE_HEIGHT, + }, + ], + edges: [{ id: "elk-edge-0", sources: ["A"], targets: ["A"] }], + }; + const mapping = [{ source: "A", target: "A", type: "refines" }]; + const result = processElkResult(elkResult, mapping); + assert.equal(result.routes.length, 0); + }); + + void it("concatenates bend points across multiple sections", () => { + const elkResult = { + id: "root", + children: [ + { + id: "A", + x: 0, + y: 0, + width: ELK_NODE_WIDTH, + height: ELK_NODE_HEIGHT, + }, + { + id: "B", + x: 300, + y: 0, + width: ELK_NODE_WIDTH, + height: ELK_NODE_HEIGHT, + }, + ], + edges: [ + { + id: "elk-edge-0", + sources: ["A"], + targets: ["B"], + sections: [ + { + id: "s0", + startPoint: { x: 30, y: 15 }, + bendPoints: [{ x: 100, y: 15 }], + endPoint: { x: 100, y: 50 }, + }, + { + id: "s1", + startPoint: { x: 100, y: 50 }, + bendPoints: [{ x: 200, y: 50 }], + endPoint: { x: 270, y: 15 }, + }, + ], + }, + ], + }; + const mapping = [{ source: "A", target: "B", type: "refines" }]; + const result = processElkResult(elkResult, mapping); + assert.equal(result.routes.length, 1); + // Section 0 contributes: start, 1 bend, end (3 points). + // Section 1 contributes: 1 bend, end (2 points — start is skipped + // as it duplicates the junction from section 0's end). + // Total: 5 points. + assert.equal(result.routes[0].points.length, 5); + }); + }); + + void describe("computeSegments", () => { + void it("returns empty arrays for a single straight segment", () => { + const source = { x: 0, y: 0 }; + const target = { x: 100, y: 0 }; + const points = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ]; + const { weights, distances } = computeSegments(points, source, target); + assert.deepEqual(weights, []); + assert.deepEqual(distances, []); + }); + + void it("computes weight and distance for a midpoint above the line", () => { + const source = { x: 0, y: 0 }; + const target = { x: 100, y: 0 }; + // A bend point at (50, -20) in screen coords (above the x-axis). + // The cross product (px*dy - py*dx) / len = (50*0 - (-20)*100)/100 = 20. + // So the signed distance is +20 for a point above the line. + const points = [ + { x: 0, y: 0 }, + { x: 50, y: -20 }, + { x: 100, y: 0 }, + ]; + const { weights, distances } = computeSegments(points, source, target); + assert.equal(weights.length, 1); + assert.equal(distances.length, 1); + assert.ok(Math.abs(weights[0] - 0.5) < 0.001); + assert.ok(Math.abs(distances[0] - 20) < 0.001); + }); + + void it("returns empty for coincident source and target", () => { + const source = { x: 50, y: 50 }; + const target = { x: 50, y: 50 }; + const points = [ + { x: 50, y: 50 }, + { x: 60, y: 40 }, + { x: 50, y: 50 }, + ]; + const { weights, distances } = computeSegments(points, source, target); + assert.deepEqual(weights, []); + assert.deepEqual(distances, []); + }); + + void it("computes correct weight for a diagonal source-target line", () => { + const source = { x: 0, y: 0 }; + const target = { x: 100, y: 100 }; + // Bend at (100, 0): projects to 0.5 along the diagonal. + const points = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + ]; + const { weights } = computeSegments(points, source, target); + assert.equal(weights.length, 1); + assert.ok(Math.abs(weights[0] - 0.5) < 0.001); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1eda72b..e9e2ea9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: cytoscape-fcose: specifier: 2.2.0 version: 2.2.0(cytoscape@3.34.0) + elkjs: + specifier: 0.11.1 + version: 0.11.1 mermaid: specifier: 11.15.0 version: 11.15.0 @@ -2645,6 +2648,9 @@ packages: electron-to-chromium@1.5.376: resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -7396,6 +7402,8 @@ snapshots: electron-to-chromium@1.5.376: {} + elkjs@0.11.1: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} From 2e9b1f167082aa286da2afbb31bd0f87ab488d45 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 25 Jun 2026 06:54:00 +0100 Subject: [PATCH 26/28] fix(web): resolve ELK bundled UMD module under Vite ESM interop The elkjs bundled build is a UMD module. Under Vite's production build, dynamic import() wraps the UMD inner modules in an 'e' namespace, so the constructor is at mod.e.default rather than mod.default. The loadElk function now handles all known export shapes (ESM default, Vite UMD wrapper, and direct global) using type guards instead of assertions. Also adds an assert.ok guard in the layout options test to narrow the optional layoutOptions field before property access. --- packages/web/src/graph/elkLayout.ts | 63 ++++++++++++++++++++++++++- packages/web/tests/elk-layout.test.ts | 1 + 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/web/src/graph/elkLayout.ts b/packages/web/src/graph/elkLayout.ts index d1195fa..bbc2fa3 100644 --- a/packages/web/src/graph/elkLayout.ts +++ b/packages/web/src/graph/elkLayout.ts @@ -21,6 +21,7 @@ import type { ElkExtendedEdge, ElkEdgeSection, ElkPoint, + ELK, } from "elkjs/lib/elk-api"; import { MIN_NODE_CLEARANCE } from "./orphanPlacement"; @@ -338,6 +339,65 @@ export function processElkResult( return { positions, routes }; } +/** + * Type guard: narrow `unknown` to a constructable ELK factory. + */ +function isConstructor(value: unknown): value is new () => ELK { + return typeof value === "function"; +} + +/** + * Type guard: narrow `unknown` to a module namespace object with a + * `default` property that is a constructable ELK factory. + */ +function hasDefaultCtor(value: unknown): value is { default: new () => ELK } { + if (!isStringRecord(value)) return false; + return isConstructor(value.default); +} + +/** + * Type guard: narrow `unknown` to a module namespace object with an + * `e` property (Vite's UMD interop wrapper) whose `default` is the ELK + * constructor. + */ +function hasViteUmdE( + value: unknown, +): value is { e: { default: new () => ELK } } { + if (!isStringRecord(value)) return false; + const e = value.e; + if (!isStringRecord(e)) return false; + return isConstructor(e.default); +} + +/** + * Lazy-load the ELK bundled build (main-thread, no web worker) and resolve the + * constructor. The bundled build is a UMD module; under Vite's ESM interop the + * default export can be nested at different levels depending on the bundler + * phase (dev vs. build). This loader handles all known shapes. + * + * The dynamic import puts ELK into its own Vite chunk, loaded only when this + * function is called — keeping the initial bundle small. + */ +async function loadElk(): Promise { + const mod: unknown = await import("elkjs/lib/elk.bundled.js"); + // Shape 1: mod.default is the constructor (ESM default export). + if (hasDefaultCtor(mod)) { + return new mod.default(); + } + // Shape 2: mod.e.default — Vite wraps UMD inner modules in an `e` namespace. + if (hasViteUmdE(mod)) { + return new mod.e.default(); + } + // Shape 3: mod itself is the constructor (UMD global-style). + if (isConstructor(mod)) { + return new mod(); + } + throw new Error( + "Could not resolve ELK constructor from bundled module. " + + `Module keys: ${mod !== null && typeof mod === "object" ? Object.keys(mod).join(", ") : typeof mod}`, + ); +} + /** * Lazy-load the ELK bundled build (main-thread, no web worker) and run the * layered layout. Returns node positions and orthogonal edge routes ready for @@ -350,8 +410,7 @@ export async function runElkLayout( nodes: readonly VisibleNode[], edges: readonly VisibleEdge[], ): Promise { - const { default: ELK } = await import("elkjs/lib/elk.bundled.js"); - const elk = new ELK(); + const elk = await loadElk(); const { graph, edgeMapping } = buildElkGraph(nodes, edges); const result = await elk.layout(graph); diff --git a/packages/web/tests/elk-layout.test.ts b/packages/web/tests/elk-layout.test.ts index ae86a96..4c276a2 100644 --- a/packages/web/tests/elk-layout.test.ts +++ b/packages/web/tests/elk-layout.test.ts @@ -63,6 +63,7 @@ void describe("ELK layout", () => { void it("includes the layered algorithm in layout options", () => { const { graph } = buildElkGraph([{ id: "A" }], []); + assert.ok(graph.layoutOptions); assert.equal(graph.layoutOptions["elk.algorithm"], "layered"); assert.equal(graph.layoutOptions["elk.edgeRouting"], "orthogonal"); }); From f83a437091c0b379c4a14cf8f883fc426eaa83ec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 25 Jun 2026 07:36:33 +0100 Subject: [PATCH 27/28] fix(web): apply ELK node positions by id, not via the preset callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preset layout's positions callback receives a NodeSingular at runtime (its .d.ts types the param as a string id), so the Map-by-id lookup missed every node and returned {0,0} for all 223 — collapsing the ELK view into a single overlapping blob with hundreds of invalid-endpoint warnings. Set each node's position directly by id instead. The layout now spreads correctly; a residual set of dense-cluster edges still warn (clearance can't fully separate the change->decision bipartite cluster). --- packages/web/src/graph/CytoscapeGraph.tsx | 75 ++++++++++------------- 1 file changed, 33 insertions(+), 42 deletions(-) diff --git a/packages/web/src/graph/CytoscapeGraph.tsx b/packages/web/src/graph/CytoscapeGraph.tsx index 3d0434b..38045b4 100644 --- a/packages/web/src/graph/CytoscapeGraph.tsx +++ b/packages/web/src/graph/CytoscapeGraph.tsx @@ -481,53 +481,44 @@ async function runElkLayeredLayout( const result = await runElkLayout(visibleNodes, visibleEdges); - // Apply node positions via the preset layout so Cytoscape animates to them. - const positionMap = new Map(); + // Apply node positions directly by id. Cytoscape's `preset` layout exposes a + // `positions(node)` callback whose parameter the bundled .d.ts types as a + // string id but which is actually a NodeSingular at runtime — so a Map keyed + // by string id silently missed every lookup and collapsed every node to + // (0,0). Setting positions by id avoids that trap entirely. for (const [id, pos] of result.positions) { - positionMap.set(id, { x: pos.x, y: pos.y }); + const node = cy.getElementById(id); + if (node.empty()) continue; + node.position({ x: pos.x, y: pos.y }); } - const presetLayout = cy.layout({ - name: "preset", - animate: true, - animationDuration: 500, - animationEasing: "ease-out", - fit: false, - padding: 40, - positions: (nodeId): { x: number; y: number } => { - const pos = positionMap.get(nodeId); - return pos ?? { x: 0, y: 0 }; - }, - }); - presetLayout.one("layoutstop", () => { - // Apply ELK's orthogonal edge routes as per-edge segment data. - for (const route of result.routes) { - const edgeId = `${route.source}->${route.target}:${route.type}`; - const edge = cy.getElementById(edgeId); - if (edge.empty()) continue; - edge.data("segmentDistances", [...route.segmentDistances]); - edge.data("segmentWeights", [...route.segmentWeights]); - edge.addClass("elk-routed"); - } - // Collect the layout edge IDs (all visible edges except supersedes, which - // ELK did not route) for orphan detection. - const layoutEdgeIds = new Set(); - cy.edges(":visible").forEach((edge) => { - if (edge.data("type") !== "supersedes") { - layoutEdgeIds.add(edge.id()); - } - }); + // Apply ELK's orthogonal edge routes as per-edge segment data. + for (const route of result.routes) { + const edgeId = `${route.source}->${route.target}:${route.type}`; + const edge = cy.getElementById(edgeId); + if (edge.empty()) continue; + edge.data("segmentDistances", [...route.segmentDistances]); + edge.data("segmentWeights", [...route.segmentWeights]); + edge.addClass("elk-routed"); + } - // Run the standard post-layout passes so the ELK view is consistent - // with the other layouts: peripheral orphan clusters + view centroids + - // minimum node clearance. - setTimeout(() => { - const labels = placeOrphansAfterLayout(cy, doc, layoutEdgeIds); - onLabels(labels); - cy.fit(undefined, 40); - }, 0); + // Collect the layout edge IDs (all visible edges except supersedes, which + // ELK did not route) for orphan detection. + const layoutEdgeIds = new Set(); + cy.edges(":visible").forEach((edge) => { + if (edge.data("type") !== "supersedes") { + layoutEdgeIds.add(edge.id()); + } }); - presetLayout.run(); + + // Run the standard post-layout passes so the ELK view is consistent with + // the other layouts: peripheral orphan clusters + view centroids + minimum + // node clearance. Deferred a frame so the direct positions above are final. + setTimeout(() => { + const labels = placeOrphansAfterLayout(cy, doc, layoutEdgeIds); + onLabels(labels); + cy.fit(undefined, 40); + }, 0); } /** Highlight a node's neighbourhood and dim everything else. */ From b638e1bbc39348ce54c02e6d9d440c689408f90a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 25 Jun 2026 09:34:30 +0100 Subject: [PATCH 28/28] feat(web): add countEdgeCrossings topology metric Pure segment-intersection count of edge crossings for a laid-out graph (positions + edges -> number of properly-intersecting edge pairs, excluding pairs that share an endpoint). O(E^2) pairwise, fine for the document's edge count. Lets any layout's crossing count be measured objectively rather than eyeballed, and to compare against the graph's topological crossing-number floor. --- packages/web/src/graph/topology.ts | 88 +++++++++++++++++++++++++++++ packages/web/tests/topology.test.ts | 73 ++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 packages/web/src/graph/topology.ts create mode 100644 packages/web/tests/topology.test.ts diff --git a/packages/web/src/graph/topology.ts b/packages/web/src/graph/topology.ts new file mode 100644 index 0000000..7fbad8f --- /dev/null +++ b/packages/web/src/graph/topology.ts @@ -0,0 +1,88 @@ +/** + * Pure topology / drawing-geometry utilities for the graph viewer. + * + * These functions operate on a laid-out graph (node positions + edges) and have + * no dependency on Cytoscape or any layout engine, so they are unit-testable. + */ + +/** A point in model coordinates. */ +export interface Position { + readonly x: number; + readonly y: number; +} + +/** A directed edge, identified by its endpoint node IDs. */ +export interface Edge { + readonly from: string; + readonly to: string; +} + +/** + * Orientation of point `c` relative to the directed line `a -> b`: positive if + * `c` lies to the left (counterclockwise), negative to the right (clockwise), + * 0 if collinear. + */ +function orientation(a: Position, b: Position, c: Position): number { + const cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + if (cross > 0) return 1; + if (cross < 0) return -1; + return 0; +} + +/** + * Whether segment `p1 -> p2` properly crosses segment `p3 -> p4`: an interior + * intersection, excluding shared endpoints and collinear overlaps. Two segments + * properly cross when each segment's endpoints lie on strictly opposite sides + * of the other. + */ +function segmentsProperlyCross( + p1: Position, + p2: Position, + p3: Position, + p4: Position, +): boolean { + const d1 = orientation(p3, p4, p1); + const d2 = orientation(p3, p4, p2); + const d3 = orientation(p1, p2, p3); + const d4 = orientation(p1, p2, p4); + const opposite1 = (d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0); + const opposite2 = (d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0); + return opposite1 && opposite2; +} + +/** + * Count the edge crossings in a laid-out graph: the number of edge pairs whose + * straight-line segments properly intersect, excluding pairs that share an + * endpoint (those meet at a node, not a crossing). O(E^2) pairwise, which is + * fine for the document's edge count. + * + * Pure: no side effects, no I/O. Tested without a browser. + */ +export function countEdgeCrossings( + positions: ReadonlyMap, + edges: readonly Edge[], +): number { + let count = 0; + for (let i = 0; i < edges.length; i++) { + const e1 = edges[i]; + const p1 = positions.get(e1.from); + const p2 = positions.get(e1.to); + if (p1 === undefined || p2 === undefined) continue; + for (let j = i + 1; j < edges.length; j++) { + const e2 = edges[j]; + if ( + e1.from === e2.from || + e1.from === e2.to || + e1.to === e2.from || + e1.to === e2.to + ) { + continue; + } + const p3 = positions.get(e2.from); + const p4 = positions.get(e2.to); + if (p3 === undefined || p4 === undefined) continue; + if (segmentsProperlyCross(p1, p2, p3, p4)) count++; + } + } + return count; +} diff --git a/packages/web/tests/topology.test.ts b/packages/web/tests/topology.test.ts new file mode 100644 index 0000000..7d94978 --- /dev/null +++ b/packages/web/tests/topology.test.ts @@ -0,0 +1,73 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { countEdgeCrossings, type Position } from "../src/graph/topology"; + +// `describe`/`it` from node:test return thenable `Test` objects, so each call +// is prefixed with `void` to satisfy `no-floating-promises` without relaxing +// the rule. +void describe("countEdgeCrossings", () => { + void it("counts two edges that cross as 1", () => { + // A(0,0)-C(2,2) and B(0,2)-D(2,0) form an X. + const positions = new Map([ + ["A", { x: 0, y: 0 }], + ["B", { x: 0, y: 2 }], + ["C", { x: 2, y: 2 }], + ["D", { x: 2, y: 0 }], + ]); + const edges = [ + { from: "A", to: "C" }, + { from: "B", to: "D" }, + ]; + assert.equal(countEdgeCrossings(positions, edges), 1); + }); + + void it("does not count edges that share an endpoint", () => { + const positions = new Map([ + ["A", { x: 0, y: 0 }], + ["B", { x: 2, y: 0 }], + ["C", { x: 0, y: 2 }], + ]); + const edges = [ + { from: "A", to: "B" }, + { from: "A", to: "C" }, + ]; + assert.equal(countEdgeCrossings(positions, edges), 0); + }); + + void it("counts parallel, non-crossing edges as 0", () => { + const positions = new Map([ + ["A", { x: 0, y: 0 }], + ["B", { x: 2, y: 0 }], + ["C", { x: 0, y: 2 }], + ["D", { x: 2, y: 2 }], + ]); + const edges = [ + { from: "A", to: "B" }, + { from: "C", to: "D" }, + ]; + assert.equal(countEdgeCrossings(positions, edges), 0); + }); + + void it("counts multiple crossings independently", () => { + // A horizontal line A-B crossed by two vertical lines C-D and E-F. + const positions = new Map([ + ["A", { x: 0, y: 1 }], + ["B", { x: 4, y: 1 }], + ["C", { x: 1, y: 0 }], + ["D", { x: 1, y: 2 }], + ["E", { x: 3, y: 0 }], + ["F", { x: 3, y: 2 }], + ]); + const edges = [ + { from: "A", to: "B" }, + { from: "C", to: "D" }, + { from: "E", to: "F" }, + ]; + assert.equal(countEdgeCrossings(positions, edges), 2); + }); + + void it("returns 0 for an empty edge set", () => { + const positions = new Map([["A", { x: 0, y: 0 }]]); + assert.equal(countEdgeCrossings(positions, []), 0); + }); +});