From 78fc7dacb2f9b1f9feba1deeee717fbec9cb608a Mon Sep 17 00:00:00 2001 From: draedful Date: Sat, 25 Jul 2026 18:32:47 +0300 Subject: [PATCH 01/10] docs: define v1 v2 branch and release policy --- README.md | 2 + .../0001-v1-v2-branch-release-policy.md | 234 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 docs/architecture-decisions/0001-v1-v2-branch-release-policy.md diff --git a/README.md b/README.md index c30194e6..65fc4c75 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # @gravity-ui/graph · [![npm package](https://img.shields.io/npm/v/@gravity-ui/graph)](https://www.npmjs.com/package/@gravity-ui/graph) [![Release](https://img.shields.io/github/actions/workflow/status/gravity-ui/graph/release.yml?branch=main&label=Release)](https://github.com/gravity-ui/graph/actions/workflows/release.yml?query=branch:main) [![storybook](https://img.shields.io/badge/Storybook-deployed-ff4685)](https://preview.gravity-ui.com/graph/) > [Migration Guide from 0.x to 1.x →](docs/migration-guides/v0-to-v1.md) +> +> [v1/v2 branch, release, synchronization, and cutover policy →](docs/architecture-decisions/0001-v1-v2-branch-release-policy.md) A graph visualization library that combines the best of both worlds: - Canvas for high performance when viewing the full graph diff --git a/docs/architecture-decisions/0001-v1-v2-branch-release-policy.md b/docs/architecture-decisions/0001-v1-v2-branch-release-policy.md new file mode 100644 index 00000000..dfa58f81 --- /dev/null +++ b/docs/architecture-decisions/0001-v1-v2-branch-release-policy.md @@ -0,0 +1,234 @@ +# ADR 0001: v1 maintenance, v2 development, synchronization, and cutover + +- Status: Proposed for Stage 0 +- Decision issue: [#315](https://github.com/gravity-ui/graph/issues/315) +- Parent initiative: [#310](https://github.com/gravity-ui/graph/issues/310) +- Audited Stage 0 base: `058990e019c1a79458a50584cb21666689ab9c3c` +- Approved `v2` creation commit: not selected yet; see [Selecting the `v2` creation commit](#selecting-the-v2-creation-commit) + +## Context + +Gravity Graph v1 must remain a stable, supported line while v2 is developed and +released independently. The two lines need explicit branch, release, and +synchronization rules so that v2 work cannot accidentally change the v1 package +contract or publish to the stable npm channel. + +Cutover to v2 and formal deprecation of v1 are separate decisions. Until a +formal deprecation decision is recorded, v1 remains supported under the +maintenance policy below. + +## Decision + +### Branch and release ownership + +Before cutover: + +| Line | Branch | Branch role | Allowed versions | npm dist-tag | Workflow | +| --- | --- | --- | --- | --- | --- | +| v1 | `main` | Default, stable, maintenance-only | stable `1.x` | `latest` | `release-v1.yml` | +| v2 | `v2` | v2-only development | `2.x` prereleases | `next` | `release-v2.yml` | + +`release/v1` does not exist before cutover. It must not be created as part of +Stage 0 branch preparation. + +After cutover: + +| Line | Branch | Branch role | Allowed versions | npm dist-tag | Workflow | +| --- | --- | --- | --- | --- | --- | +| v1 | `release/v1` | Stable, maintenance-only | stable `1.x` | `v1` | `release-v1.yml` | +| v2 | `main` | Default v2 development and stable releases | stable `2.x` | `latest` | `release-v2.yml` | + +The release workflows are independent. Both remain present and operational +after cutover. Retiring `release-v1.yml`, the `release/v1` branch, or the `v1` +dist-tag requires a later, explicit v1 deprecation decision; cutover alone does +not authorize any of those actions. + +Release configuration must enforce the branch, major version, prerelease state, +and dist-tag before publication. Before cutover, `release-v2.yml` must be unable +to update `latest`. Publication requires a reviewed release pull request or an +equivalent explicit approval step. Preview destinations for `main` and `v2` +must be distinct before cutover. + +### v1 maintenance scope + +Before and after cutover, v1 accepts only: + +- necessary bug fixes; +- security fixes; +- documentation changes relevant to v1. + +Features, broad refactors, and v2 migration work do not target the v1 branch. +Before cutover they target `v2`; after cutover they target `main`. + +A v1 change that alters a supported public entry point must update the v1 +package-contract baseline and receive an explicit v2 synchronization decision. +The contract baseline and checks are defined by [#314](https://github.com/gravity-ui/graph/issues/314). + +### Manual synchronization from v1 to v2 + +Synchronization is event-driven, not scheduled or automatic. Each accepted v1 +change creates one synchronization decision after it lands on the v1 branch. +Before cutover the source and target are `main` and `v2`; after cutover they are +`release/v1` and `main`. + +The decision/release owner records the following in a dedicated tracking issue, +pull request, or release checklist: + +| Field | Required value | +| --- | --- | +| Source | Exact v1 branch name and source commit SHA | +| Applicability | `port` or `no-port`, with rationale | +| Synchronization | Link to the sync pull request, or the explicit no-port record | +| Result | Merge commit SHA when synchronized | +| Resolution | Any v2-specific conflict or neutralization and its verification | + +When the change applies, synchronization uses a reviewed pull request that +merges the v1 branch into the v2 branch. The pull request must be merged with a +merge commit. Squash, rebase, and cherry-pick are not the canonical branch +synchronization mechanism because they do not preserve the ancestry needed for +later full merges. + +A no-port decision rejects the v1 behavior, not its commit ancestry. A later +full merge can therefore bring that content into the v2 tree. If it does, the +sync pull request must explicitly neutralize it or resolve the conflict in favor +of the intended v2 behavior, and record that resolution. + +Before every v2 release candidate and again before cutover, the release owner +performs a drift check: + +1. enumerate v1 commits since the last recorded synchronization boundary; +2. verify that every commit has a `port` or `no-port` decision; +3. verify source and result SHAs against the repository ancestry; +4. inspect no-port content that may have arrived through a later full merge; +5. resolve and test any remaining v1/v2 behavioral drift before approving the + release candidate or cutover. + +### Selecting the `v2` creation commit + +The audited Stage 0 base is +`058990e019c1a79458a50584cb21666689ab9c3c` (`v1.11.3`). It identifies the +known starting point for Stage 0 work; it is **not** the approved `v2` creation +commit because it does not yet contain the complete Stage 0 delivery baseline. + +The decision owner may select the creation commit only after all of the +following are present on `main` and verified: + +- the reusable v1 package-contract fixture and checks from #314; +- CI for `main` and the future `v2` branch; +- distinct preview routing for `main` and `v2`; +- independent `release-v1.yml` and `release-v2.yml` workflows and their + pre-publication guards from #313; +- contribution, synchronization, preview, and release routing documentation + from #316; +- the repository-verifiable ruleset artifacts and preparation from #317; +- an explicit disposition for every open pull request targeting `main`, as + required by #312. + +Ruleset activation is a separate, mandatory pre-creation gate. Before the `v2` +Git ref exists, the assigned repository or organization administrator must +create and activate a ruleset targeting the future branch name `v2`. The +administrator must record the ruleset owner and verifiable evidence of its +effective configuration in #317, including required pull requests and checks, +review requirements, deletion and force-push prevention, merge-commit support, +and only the minimum release-automation bypass. Repository-verifiable artifacts +do not substitute for this external configuration, and this ADR does not claim +that the external gate has already passed. + +The selected commit must descend from the audited Stage 0 base. The decision +owner records its full SHA in both this ADR's `Approved v2 creation commit` +field and issue #315 only after both the repository baseline gate and the +external ruleset-activation gate have passed. The branch-creation operator then +verifies that `main` resolves to that exact SHA, creates `v2` from it, and +records the same SHA as the initial common commit in #312. Immediately after +creation, the assigned administrator must verify and record in #317 that the +ruleset is effective on `v2` and that its required checks apply. Stage 1 is not +authorized until that post-creation verification passes. Any baseline change +after approval invalidates the selection and requires a new full SHA approval +before `v2` is created. + +This record-and-selection rule deliberately prevents premature creation of the +long-lived `v2` branch. + +### Cutover readiness and ownership + +The cutover decision record assigns roles, not permanent named individuals: + +- **Decision owner** confirms every readiness criterion and authorizes or stops + cutover. +- **Release owner** runs the release, synchronization, drift, and publication + checks and records their evidence. +- **Rollback owner** owns the stop/go checkpoints and executes the documented + rollback or recovery decision. + +One person may hold more than one role only when repository governance permits +it, but each role and current assignee must be explicit in the cutover tracking +issue. Repository or organization administrators remain responsible for +ruleset operations described by #317. + +Cutover is ready only when: + +- Stage 0 exit criteria in #310 and branch validation in #312 are complete; +- the v1 contract, CI, preview, release routes, and publication guards pass on + their actual protected branches; +- all required checks and rulesets are effective, including support for + merge-commit synchronization; +- every v1 change has a verified synchronization decision and the final drift + check is clean; +- the final v1 and final v2 release candidates pass their agreed test suites; +- the decision, release, and rollback owners have accepted the evidence and + rollback checkpoints. + +### Cutover sequence + +The release owner executes these steps in order, with a stop/go checkpoint after +each step: + +1. publish the final v1 release from `main`; +2. create `release/v1` from the exact commit that produced that release, record + the full SHA, and apply/verify its prepared protection; +3. perform the final reviewed merge-commit synchronization from `main` to `v2` + and record the source and resulting merge SHAs; +4. pass the final drift check and verify a final v2 release candidate under + `next`; +5. promote `v2` to `main` through the reviewed, ruleset-compliant operation + approved in the cutover record, then verify default-branch and workflow + routing; +6. publish stable v2 under `latest`. + +The operation used in step 5 must preserve the ancestry established by the +final synchronization and must not rewrite either maintenance line. + +### Rollback and recovery + +Before stable v2 publication, failure at a checkpoint stops the sequence. The +rollback owner keeps `latest` on the final stable v1 release, restores the last +verified branch/workflow routing where a cutover configuration changed, and +records which checks must pass before retrying. `release/v1`, once created from +the final v1 commit, is retained; it is not evidence that v1 has been deprecated. + +After stable v2 is published under `latest`, the version and publication are +treated as irreversible. Recovery must not delete or reuse the published +version, rewrite protected branch history, or silently restore v1 to `latest`. +The rollback owner stops further publication, opens an incident decision, and +chooses an auditable forward recovery such as a corrective `2.x` release. Any +exceptional npm dist-tag or default-branch change requires an explicit +administrator-approved incident action. V1 maintenance remains available from +`release/v1` under `v1`. + +### Formal v1 deprecation + +V1 deprecation is not implied by creating `v2`, cutting over the default branch, +or publishing stable v2. It requires a separate decision that defines the +support end date, communication, remaining security obligations, dist-tag +handling, branch/ruleset disposition, and retirement criteria for +`release-v1.yml`. + +## Consequences + +- Stable v1 consumers remain on `latest` until the v2 cutover. +- V2 prereleases cannot accidentally claim the stable channel. +- Full merge ancestry makes synchronization and drift auditable. +- The v2 branch cannot be created until its contract, delivery, documentation, + and protection baseline is present at one approved commit. +- Maintaining two release workflows is intentional until v1 is formally + deprecated. From 3bfb66f83d35f41c4f30f54e4a364994aee8f096 Mon Sep 17 00:00:00 2001 From: draedful Date: Sat, 25 Jul 2026 18:38:26 +0300 Subject: [PATCH 02/10] docs: add v1 v2 contribution routing --- .github/ISSUE_TEMPLATE/v1-v2-work.yml | 58 +++++++++++++ .github/pull_request_template.md | 40 +++++++++ CONTRIBUTING.md | 9 +- docs/contributing/v1-v2-routing.md | 119 ++++++++++++++++++++++++++ 4 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 .github/ISSUE_TEMPLATE/v1-v2-work.yml create mode 100644 .github/pull_request_template.md create mode 100644 docs/contributing/v1-v2-routing.md diff --git a/.github/ISSUE_TEMPLATE/v1-v2-work.yml b/.github/ISSUE_TEMPLATE/v1-v2-work.yml new file mode 100644 index 00000000..c776b013 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/v1-v2-work.yml @@ -0,0 +1,58 @@ +name: V1/v2 tracked work +description: Route v1 maintenance, v2, synchronization, release, or cutover work +title: "" +body: + - type: markdown + attributes: + value: | + Read the [v1/v2 routing runbook](https://github.com/gravity-ui/graph/blob/main/docs/contributing/v1-v2-routing.md) before choosing a work type. + - type: dropdown + id: work-type + attributes: + label: Work type + description: Select the line or operation that owns this work. + options: + - v1-maintenance + - v2 + - sync + - release-v1 + - release-v2 + - cutover + validations: + required: true + - type: dropdown + id: phase + attributes: + label: Project phase + options: + - Before v2 cutover + - After v2 cutover + validations: + required: true + - type: input + id: target + attributes: + label: Target branch + description: State the branch selected using the runbook. + placeholder: main, v2, or release/v1 + validations: + required: true + - type: textarea + id: scope + attributes: + label: Scope and routing rationale + description: Explain why this work belongs to the selected line. + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Required evidence + description: For sync/release/cutover work, include owners, SHAs, PRs, workflow runs, preview destinations, and verification as applicable. + - type: checkboxes + id: confirmation + attributes: + label: Confirmation + options: + - label: I followed the v1/v2 routing runbook and did not infer a branch or release channel from an unavailable future configuration. + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..16065001 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,40 @@ +## Summary + + + +## V1/v2 routing + +- Work type: +- Project phase: +- Target branch: +- Routing rationale: + +Follow the +[v1/v2 contribution and synchronization runbook](https://github.com/gravity-ui/graph/blob/main/docs/contributing/v1-v2-routing.md). + +## Verification + +- Checks run: +- Workflow or preview evidence, when applicable: + +## Synchronization record + + + +- Owner: +- Source v1 branch: +- Full source SHA: +- Applicability: +- Rationale: +- Sync PR or no-port record: +- Resulting merge SHA: +- V2 conflict resolution or neutralization: +- Verification: + +## Checklist + +- [ ] The selected target branch matches the current project phase and work type. +- [ ] V1 changes are limited to necessary bug fixes, security fixes, or v1 documentation. +- [ ] A synchronization PR uses a merge commit; squash, rebase, and cherry-pick are not used as the canonical branch sync. +- [ ] Release and preview evidence uses the configured route and does not assume an undocumented destination. +- [ ] This change does not treat v2 cutover as formal v1 deprecation or workflow retirement. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 192834a9..2fa328f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,4 +33,11 @@ It is enough to provide us such notification once. ## Other questions -If you have any questions, please mail us at opensource@yandex-team.ru. \ No newline at end of file +If you have any questions, please mail us at opensource@yandex-team.ru. + +## Choose a target branch + +Before opening an issue or pull request, follow the +[v1/v2 contribution and synchronization runbook](docs/contributing/v1-v2-routing.md). +It explains which branch owns v1 maintenance and v2 development before and +after cutover, and which routing evidence maintainers require. diff --git a/docs/contributing/v1-v2-routing.md b/docs/contributing/v1-v2-routing.md new file mode 100644 index 00000000..2fc6603b --- /dev/null +++ b/docs/contributing/v1-v2-routing.md @@ -0,0 +1,119 @@ +# V1/v2 contribution, synchronization, and release routing + +This runbook applies while Gravity Graph maintains independent v1 and v2 lines. +The governing policy is +[ADR 0001](../architecture-decisions/0001-v1-v2-branch-release-policy.md). +V1 remains stable and supported under a maintenance-only policy; it is not +deprecated. + +## Choose the target branch + +Use the project phase and change type to select the pull-request target: + +| Project phase | Target | Accepted changes | +| ----------------- | ------------ | -------------------------------------------------------------- | +| Before v2 cutover | `main` | Necessary v1 bug fixes, security fixes, and v1 documentation | +| Before v2 cutover | `v2` | V2 features, migrations, breaking changes, and broad refactors | +| After v2 cutover | `release/v1` | Necessary v1 bug fixes, security fixes, and v1 documentation | +| After v2 cutover | `main` | V2 development, fixes, and documentation | + +Do not target the v1 branch with new features, broad refactors, or v2 migration +work. Before cutover, `release/v1` does not exist and must not be created. + +If a change affects both maintained lines, first submit the v1 maintenance +change to the current v1 branch. After it lands, use the manual synchronization +process below; do not combine unrelated v2 work into the v1 pull request. + +## Record work type + +Issue and pull-request authors must identify one work type in the template: + +- `v1-maintenance` for an allowed v1 bug, security, or documentation change; +- `v2` for v2-only development; +- `sync` for a v1-to-v2 synchronization decision or pull request; +- `release-v1` or `release-v2` for work on the corresponding release route; +- `cutover` for an explicitly approved cutover task. + +These names are routing guidance. If repository labels with the same names are +available, maintainers apply them; otherwise the completed template remains the +authoritative routing record. Do not create or infer labels merely to satisfy +this document. + +## Manually synchronize an accepted v1 change + +Synchronization is event-driven. Every accepted change to the current v1 branch +creates one dedicated synchronization decision after it lands. There is no +scheduled or automatic synchronization. + +Before cutover, synchronize from `main` to `v2`. After cutover, synchronize from +`release/v1` to `main`. + +The assigned synchronization owner records: + +| Field | Required evidence | +| ------------- | ------------------------------------------------------------------------ | +| Owner | Current synchronization decision owner | +| Source | Exact v1 branch and full source commit SHA | +| Applicability | `port` or `no-port`, with rationale | +| Sync record | Reviewed synchronization pull request, or explicit no-port issue/comment | +| Result | Resulting merge commit SHA when ported | +| Resolution | V2-specific conflict resolution or neutralization, plus verification | + +For `port`, open a dedicated pull request that merges the complete current v1 +branch into the current v2 branch. Use a merge commit. Squash, rebase, and +cherry-pick are not the canonical synchronization mechanism because later full +merges must retain ancestry. Record both the source v1 SHA and the resulting +merge SHA. + +For `no-port`, record why the v1 behavior does not apply to v2. This rejects the +behavior, not the commit ancestry: a later full merge can still introduce the +content. The later synchronization pull request must identify that content and +neutralize it or explicitly resolve it in favor of v2 behavior, with tests or +other verification recorded. + +## Check drift + +The release owner performs a drift check before every v2 release candidate and +again before cutover: + +1. enumerate v1 commits since the last recorded synchronization boundary; +2. confirm that each has a `port` or `no-port` decision; +3. verify each recorded source SHA and merge SHA against repository ancestry; +4. inspect whether later full merges introduced content covered by `no-port`; +5. resolve and verify remaining behavioral drift before approval. + +## Release and preview routing + +Before cutover: + +| Line | Branch | Versions | npm dist-tag | Workflow | +| ---- | ------ | ----------------- | ------------ | ---------------- | +| v1 | `main` | stable `1.x` | `latest` | `release-v1.yml` | +| v2 | `v2` | `2.x` prereleases | `next` | `release-v2.yml` | + +After cutover: + +| Line | Branch | Versions | npm dist-tag | Workflow | +| ---- | ------------ | ------------ | ------------ | ---------------- | +| v1 | `release/v1` | stable `1.x` | `v1` | `release-v1.yml` | +| v2 | `main` | stable `2.x` | `latest` | `release-v2.yml` | + +Publication requires a reviewed release pull request or an equivalent explicit +approval step. Route release changes through the workflow for their line; never +use the v2 prerelease route to update `latest`. + +Before cutover, pushes to `main` and `v2` publish previews to separately +configured destinations so that one line cannot overwrite the other. The +release or pull-request record must link the workflow run and its reported +preview destination as evidence. Do not assume a destination from a branch name +or hard-code an undocumented URL. After cutover, `main` owns the v2 preview; any +continued v1 preview from `release/v1` must use its own configured destination +and must not overwrite the v2 preview. + +## Cutover is not v1 deprecation + +V1 deprecation and retirement of `release-v1.yml` are separate future decisions. +Cutover alone does not authorize retiring the v1 workflow, deleting +`release/v1`, removing its protection, or changing the `v1` dist-tag. Until a +formal deprecation record defines support dates, communication, and retirement +criteria, both release workflows remain operational. From cdae882939c46a9387029f1f7dab995c06072cc0 Mon Sep 17 00:00:00 2001 From: draedful Date: Sat, 25 Jul 2026 18:51:55 +0300 Subject: [PATCH 03/10] test: protect the v1 package contract --- package.json | 3 +- tests/package-contract/README.md | 27 + tests/package-contract/contract.json | 28 + .../package-contract/fixtures/react/index.tsx | 22 + tests/package-contract/fixtures/tsconfig.json | 12 + .../fixtures/vanilla/index.ts | 20 + .../package-contract/prepare-declarations.mjs | 91 + tests/package-contract/public-api.json | 1824 +++++++++++++++++ tests/package-contract/run.mjs | 276 +++ 9 files changed, 2302 insertions(+), 1 deletion(-) create mode 100644 tests/package-contract/README.md create mode 100644 tests/package-contract/contract.json create mode 100644 tests/package-contract/fixtures/react/index.tsx create mode 100644 tests/package-contract/fixtures/tsconfig.json create mode 100644 tests/package-contract/fixtures/vanilla/index.ts create mode 100644 tests/package-contract/prepare-declarations.mjs create mode 100644 tests/package-contract/public-api.json create mode 100644 tests/package-contract/run.mjs diff --git a/package.json b/package.json index acd914e0..923f0f3b 100644 --- a/package.json +++ b/package.json @@ -53,12 +53,13 @@ "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", "test:unit": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests --updateSnapshot", "test": "npm run test:unit", + "test:package-contract": "node tests/package-contract/run.mjs", "storybook": "storybook dev -p 6006", "storybook:build": "storybook -c .storybook -o storybook-static -s .storybook/public", "build-storybook": "storybook build", "ci:storybook:build": "npm run build-storybook", "copy-styles": "copyfiles -u 1 \"src/**/*.css\" build/", - "build:publish": "tsc -p tsconfig.publish.json && npm run copy-styles", + "build:publish": "tsc -p tsconfig.publish.json && node tests/package-contract/prepare-declarations.mjs && npm run copy-styles", "build": "tsc --declaration && npm run copy-styles", "prepublishOnly": "npm run typecheck && npm run test && npm run build:publish", "dev": "concurrently \"tsc --watch --declaration\" \"chokidar 'src/**/*.css' -c 'npm run copy-styles'\"", diff --git a/tests/package-contract/README.md b/tests/package-contract/README.md new file mode 100644 index 00000000..e40258ba --- /dev/null +++ b/tests/package-contract/README.md @@ -0,0 +1,27 @@ +# v1 package contract + +This fixture protects the public contract of the actual npm tarball produced from +the current branch. It is intentionally branch-agnostic so the same command can +run unchanged on `main` and the future `release/v1` branch. + +Run the check with: + +```sh +npm run test:package-contract +``` + +The command builds and packs the package, installs only that tarball as the +`@gravity-ui/graph` source in isolated vanilla and React consumers, checks the +published export map, declarations, CSS assets, TypeScript consumption, and +browser-runtime bundles, and compares the public declarations with the committed +baseline. + +After an explicitly approved v1 maintenance change modifies the supported public +contract, review the diff and update the baseline with: + +```sh +npm run test:package-contract -- --update +``` + +The CI workflow wiring belongs to #313. The command itself performs no publish, +release, dist-tag, or GitHub operation. diff --git a/tests/package-contract/contract.json b/tests/package-contract/contract.json new file mode 100644 index 00000000..d4fe0934 --- /dev/null +++ b/tests/package-contract/contract.json @@ -0,0 +1,28 @@ +{ + "entryPoints": { + ".": "build/index.d.ts", + "./react": "build/react-components/index.d.ts" + }, + "exports": { + ".": { + "types": "./build/index.d.ts", + "default": "./build/index.js" + }, + "./react": { + "types": "./build/react-components/index.d.ts", + "default": "./build/react-components/index.js" + } + }, + "styles": [ + "build/plugins/devtools/devtools-layer.css", + "build/react-components/Anchor.css", + "build/react-components/Block.css", + "build/react-components/graph-canvas.css", + "build/services/Layer.css" + ], + "typesVersions": { + "*": { + "react": ["build/react-components/index.d.ts"] + } + } +} diff --git a/tests/package-contract/fixtures/react/index.tsx b/tests/package-contract/fixtures/react/index.tsx new file mode 100644 index 00000000..fa70a29d --- /dev/null +++ b/tests/package-contract/fixtures/react/index.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import { Graph, type TGraphColors } from "@gravity-ui/graph"; +import { GraphCanvas, GraphContext, useGraph, useGraphEvent, type HookGraphParams } from "@gravity-ui/graph/react"; + +const colors: Partial = {}; +const params: HookGraphParams = { settings: { colors } }; + +export function ContractGraph() { + const { graph } = useGraph(params); + + useGraphEvent(graph, "camera-change", () => undefined); + + return ; +} + +export const reactContract = { + React, + Graph, + GraphCanvas, + GraphContext, + ContractGraph, +}; diff --git a/tests/package-contract/fixtures/tsconfig.json b/tests/package-contract/fixtures/tsconfig.json new file mode 100644 index 00000000..a4863ae5 --- /dev/null +++ b/tests/package-contract/fixtures/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "lib": ["ES2020", "DOM"], + "skipLibCheck": true + } +} diff --git a/tests/package-contract/fixtures/vanilla/index.ts b/tests/package-contract/fixtures/vanilla/index.ts new file mode 100644 index 00000000..ddbc5b20 --- /dev/null +++ b/tests/package-contract/fixtures/vanilla/index.ts @@ -0,0 +1,20 @@ +import { + Graph, + createAnchorPortId, + createPortId, + defaultGetCameraBlockScaleLevel, + type TGraphColors, + type TPoint, +} from "@gravity-ui/graph"; + +const point: TPoint = { x: 0, y: 0 }; +const colors: Partial = {}; + +export const vanillaContract = { + Graph, + colors, + point, + anchorPortId: createAnchorPortId("block", "anchor"), + portId: createPortId("block", "port"), + scaleLevel: defaultGetCameraBlockScaleLevel(1), +}; diff --git a/tests/package-contract/prepare-declarations.mjs b/tests/package-contract/prepare-declarations.mjs new file mode 100644 index 00000000..95717a55 --- /dev/null +++ b/tests/package-contract/prepare-declarations.mjs @@ -0,0 +1,91 @@ +import { copyFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import ts from "typescript"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const buildRoot = resolve(repositoryRoot, "build"); +const internalRoots = new Set( + (await readdir(resolve(repositoryRoot, "src"), { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && entry.name !== "stories") + .map((entry) => entry.name) +); + +await mkdir(resolve(buildRoot, "utils/types"), { recursive: true }); +await copyFile(resolve(repositoryRoot, "src/utils/types/global.d.ts"), resolve(buildRoot, "utils/types/global.d.ts")); + +async function declarationFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all( + entries.map((entry) => { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + return declarationFiles(path); + } + return entry.name.endsWith(".d.ts") ? [path] : []; + }) + ); + return nested.flat(); +} + +function relativeSpecifier(fromFile, absoluteTarget) { + let specifier = relative(dirname(fromFile), absoluteTarget).split(sep).join("/"); + if (!specifier.startsWith(".")) { + specifier = `./${specifier}`; + } + return specifier; +} + +function isModuleSpecifier(node) { + const parent = node.parent; + return ( + (ts.isImportDeclaration(parent) && parent.moduleSpecifier === node) || + (ts.isExportDeclaration(parent) && parent.moduleSpecifier === node) || + (ts.isExternalModuleReference(parent) && parent.expression === node) || + (ts.isLiteralTypeNode(parent) && ts.isImportTypeNode(parent.parent)) || + (ts.isCallExpression(parent) && + parent.arguments.includes(node) && + parent.expression.kind === ts.SyntaxKind.ImportKeyword) + ); +} + +function rewriteInternalModuleSpecifiers(declarationFile, declaration) { + const source = ts.createSourceFile(declarationFile, declaration, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const replacements = []; + + function visit(node) { + if (ts.isStringLiteralLike(node) && isModuleSpecifier(node)) { + const [root, ...rest] = node.text.split("/"); + if (internalRoots.has(root) && rest.length > 0) { + const target = resolve(buildRoot, root, ...rest); + replacements.push({ + start: node.getStart(source) + 1, + end: node.getEnd() - 1, + text: relativeSpecifier(declarationFile, target), + }); + } + } + ts.forEachChild(node, visit); + } + visit(source); + + return replacements + .sort((left, right) => right.start - left.start) + .reduce( + (result, replacement) => + `${result.slice(0, replacement.start)}${replacement.text}${result.slice(replacement.end)}`, + declaration + ); +} + +for (const declarationFile of await declarationFiles(buildRoot)) { + const declaration = rewriteInternalModuleSpecifiers(declarationFile, await readFile(declarationFile, "utf8")); + await writeFile(declarationFile, declaration); +} + +for (const entryPoint of [resolve(buildRoot, "index.d.ts"), resolve(buildRoot, "react-components/index.d.ts")]) { + const globalTypes = relativeSpecifier(entryPoint, resolve(buildRoot, "utils/types/global.d.ts")); + const declaration = await readFile(entryPoint, "utf8"); + await writeFile(entryPoint, `/// \n${declaration}`); +} diff --git a/tests/package-contract/public-api.json b/tests/package-contract/public-api.json new file mode 100644 index 00000000..0a8f72f8 --- /dev/null +++ b/tests/package-contract/public-api.json @@ -0,0 +1,1824 @@ +{ + ".": [ + { + "name": "Anchor", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class Anchor extends GraphComponent {\n readonly cursor = \"pointer\";\n static CANVAS_HOVER_FACTOR: number;\n static DETAILED_HOVER_FACTOR: number;\n getEntityId(): number | string;\n get zIndex(): number;\n connectedState: AnchorState;\n private shift;\n constructor(props: T, parent: GraphLayer);\n getHoverFactor(): number;\n protected stateChanged(_nextState: TAnchorState): void;\n protected propsChanged(_nextProps: T): void;\n protected willMount(): void;\n protected computeShift(state?: TAnchorState, props?: T): void;\n protected onPositionChanged: () => void;\n getPorts(): PortState[];\n /**\n * Get the position of the anchor.\n * Returns the position of the anchor in the coordinate system of the graph(ABSOLUTE).\n *\n * Example:\n * ```ts\n * const pos = anchor.getPosition(); // { x: 100, y: 100 }\n * ```\n * port.getPoint is used port.$state.value so you can use this method in signals effect and compute.\n * ```ts\n * computed(() => {\n * return anchor.getPosition().x + 10; // { x: 110, y: 100 }\n * });\n * ```\n * @returns The position of the anchor in the coordinate system of the graph(ABSOLUTE).\n */\n getPosition(): import(\"../../..\").TPoint;\n toggleSelected(): void;\n /**\n * Anchor is draggable only when connection creation is disabled.\n * When connections can be created via anchors, dragging is handled by ConnectionLayer.\n */\n isDraggable(): boolean;\n handleDragStart(context: DragContext): void;\n handleDrag(diff: DragDiff, context: DragContext): void;\n handleDragEnd(context: DragContext): void;\n protected isVisible(): boolean;\n protected unmount(): void;\n handleEvent(event: MouseEvent | KeyboardEvent): void;\n private computeRenderSize;\n protected render(): void;\n}" + } + ] + }, + { + "name": "AnchorState", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class AnchorState {\n readonly block: BlockState;\n protected $state: import(\"@preact/signals-core\").Signal;\n $selected: import(\"@preact/signals-core\").ReadonlySignal;\n $viewComponentReady: import(\"@preact/signals-core\").Signal;\n private anchorView;\n get id(): string;\n get blockId(): import(\"../block/Block\").TBlockId;\n get state(): TAnchor;\n constructor(block: BlockState, anchor: TAnchor);\n update(anchor: TAnchor): void;\n setSelection(selected: boolean): void;\n setViewComponent(anchorComponent: Anchor): void;\n unsetViewComponent(): void;\n getViewComponent(): Anchor;\n asTAnchor(): TAnchor;\n}" + } + ] + }, + { + "name": "applyAlpha", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function applyAlpha(color: string, alpha: number): string;" + } + ] + }, + { + "name": "BaseConnection", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class BaseConnection extends GraphComponent {\n /**\n * @deprecated use port system instead\n */\n protected get sourceBlock(): Block;\n /**\n * @deprecated use port system instead\n */\n protected get targetBlock(): Block;\n /**\n * @deprecated use port system instead\n */\n protected get sourceAnchor(): TAnchor | undefined;\n /**\n * @deprecated use port system instead\n */\n protected get targetAnchor(): TAnchor | undefined;\n /**\n * Calculated connection endpoints based on port positions\n * Updated automatically when source or target ports change position\n */\n connectionPoints: [TPoint, TPoint] | undefined;\n /**\n * Reference to the reactive connection state from the store\n * Provides access to connection data and port references\n */\n protected connectedState: ConnectionState;\n /**\n * Bounding box for the connection [minX, minY, maxX, maxY]\n * Used for hit detection and rendering optimizations\n */\n protected bBox: [minX: number, minY: number, maxX: number, maxY: number];\n constructor(props: Props, parent: Component);\n getEntityId(): TConnectionId;\n protected willMount(): void;\n protected isVisible(): boolean;\n protected handleEvent(event: MouseEvent): void;\n protected onHoverChange(hoverState: boolean): void;\n protected debounceHover: ((hovered: boolean) => void) & {\n cancel: () => void;\n flush: () => void;\n isScheduled: () => boolean;\n };\n protected unmount(): void;\n /**\n * Updates connection points based on current port positions\n * Called automatically when port geometry changes\n *\n * This method:\n * 1. Retrieves current port positions from the Port System\n * 2. Updates connectionPoints for rendering\n * 3. Recalculates bounding box for optimization\n * 4. Updates hit box for interaction\n *\n * @returns {void}\n */\n protected updatePoints(additionalPoints?: TPoint[]): void;\n /**\n * Collects points that define the bounding box of the connection.\n * Override in subclasses to include additional points (e.g., bezier control points, labels).\n */\n protected collectBBoxPoints(): TPoint[];\n /**\n * Get the current bounding box of the connection\n * @returns Readonly tuple of [sourceX, sourceY, targetX, targetY]\n */\n protected getBBox(): Readonly<[sourceX: number, sourceY: number, targetX: number, targetY: number]>;\n /**\n * Get the current bounding box of the connection as a TRect.\n */\n protected getHitBoxRect(): TRect;\n /**\n * Updates the hit box for user interaction\n * Adds threshold padding around the connection line to make it easier to click\n *\n * @returns {void}\n */\n private updateHitBox;\n}" + } + ] + }, + { + "name": "BaseSelectionBucket", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare abstract class BaseSelectionBucket implements ISelectionBucket {\n readonly entityType: string;\n protected onSelectionChange: (payload: TSelectionDiff, defaultAction: (rewritenIds?: Set) => void) => void | boolean;\n isRelatedElement?: (element: GraphComponent) => boolean;\n protected resolver?: (ids: IDType[]) => TEntity[];\n protected readonly $selectedIds: Signal>;\n readonly $selected: ReadonlySignal>;\n /**\n * Computed signal that resolves selected IDs to their corresponding entities.\n * Returns an empty array if no resolver function is provided.\n */\n readonly $selectedEntities: ReadonlySignal;\n /**\n * Computed signal that resolves selected entities to their GraphComponent views.\n * Works with entities that:\n * - Are GraphComponent instances themselves\n * - Implement IEntityWithComponent interface (have getViewComponent() method)\n * Returns an empty array if entities cannot be resolved to components.\n */\n readonly $selectedComponents: ReadonlySignal;\n protected manager: SelectionService;\n /**\n * Check if an entity is a GraphComponent\n */\n private isGraphComponent;\n /**\n * Check if an entity has getViewComponent method\n */\n private hasViewComponent;\n constructor(entityType: string, onSelectionChange?: (payload: TSelectionDiff, defaultAction: (rewritenIds?: Set) => void) => void | boolean, isRelatedElement?: (element: GraphComponent) => boolean, resolver?: (ids: IDType[]) => TEntity[]);\n /**\n * Attaches the bucket to the manager\n *\n * @param manager {SelectionService} - The manager to attach to\n * @returns void\n */\n attachToManager(manager: SelectionService): void;\n /**\n * Detaches the bucket from the manager\n * @param manager {SelectionService} - The manager to detach from\n * @returns void\n */\n detachFromManager(manager: SelectionService): void;\n /**\n * Selects the given ids\n *\n * @param ids {IDType[]} - The ids to select\n * @param strategy {ESelectionStrategy} - The strategy to use\n * @param silent {boolean} - Whether to suppress the selection change event\n * @returns void\n */\n select(ids: IDType[], strategy?: ESelectionStrategy, silent?: boolean): void;\n /**\n * Deselects the given ids\n * Passed ids will be deselected with strategy SUBTRACT\n *\n * @param ids {IDType[]} - The ids to deselect\n * @param silent {boolean} - Whether to suppress the selection change event\n * @returns void\n */\n deselect(ids: IDType[], silent?: boolean): void;\n /**\n * Updates the selection\n * @param ids - The ids to update\n * @param select - Whether to select or deselect\n * @param strategy - The strategy to use\n * @param silent - Whether to suppress the selection change event\n */\n abstract updateSelection(ids: IDType[], select: boolean, strategy: ESelectionStrategy, silent?: boolean): void;\n /**\n * Resets the selection\n * All selected ids will be deselected with strategy SUBTRACT\n *\n * @returns void\n */\n reset(): void;\n /**\n * Checks if the given id is selected\n *\n * @param id {IDType} - The id to check\n * @returns boolean\n */\n isSelected(id: IDType): boolean;\n /**\n * Applies the selection\n * Generate diff between new and current selected ids and run onSelectionChange callback\n * If silent is true, the nextSelection state will be applied immediately, otherwise it will be applied after the callback is executed and\n *\n * @param newSelectedIds {Set} - The new selected ids\n * @param currentSelectedIds {Set} - The current selected ids\n * @param silent {boolean} - Whether to suppress the selection change event\n * @returns void\n */\n protected applySelection(newSelectedIds: Set, currentSelectedIds: Set, silent?: boolean): void;\n}" + } + ] + }, + { + "name": "BatchPath2DRenderer", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class BatchPath2DRenderer {\n protected onChange: () => void;\n private chunkSize;\n constructor(onChange: () => void, chunkSize?: number);\n protected indexes: Map>;\n protected itemParams: Map;\n orderedPaths: {\n get: () => any[];\n reset(): void;\n clear(): void;\n };\n protected requestRender: () => void;\n protected getGroup(zIndex: number, group: string): Path2DGroup;\n add(item: Path2DRenderInstance, params: {\n zIndex: number;\n group: string;\n }): void;\n update(item: Path2DRenderInstance, params: {\n zIndex: number;\n group: string;\n }): void;\n delete(item: Path2DRenderInstance): void;\n markDirty(item: Path2DRenderInstance): void;\n}" + } + ] + }, + { + "name": "BezierMultipointConnection", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class BezierMultipointConnection extends MultipointConnection {\n createPath(): Path2D;\n}" + } + ] + }, + { + "name": "BlockConnection", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class BlockConnection extends BaseConnection implements Path2DRenderInstance {\n readonly cursor = \"pointer\";\n protected path2d: Path2D;\n protected labelGeometry: {\n x: number;\n y: number;\n width: number;\n height: number;\n } | undefined;\n protected geometry: {\n x1: number;\n x2: number;\n y1: number;\n y2: number;\n };\n /**\n * The arrow shape component that renders the arrow in the middle of the connection.\n * This is conditionally added to the batch renderer based on the showConnectionArrows setting.\n */\n protected arrowShape: ConnectionArrow;\n /**\n * Creates a new BlockConnection instance.\n *\n * @param props - The connection properties including showConnectionArrows setting\n * @param parent - The parent BlockConnections component\n */\n constructor(props: TConnectionProps, parent: BlockConnections);\n /**\n * Updates the visual appearance of the connection and manages arrow visibility.\n * This method centralizes all arrow rendering logic to ensure consistency.\n *\n * IMPORTANT: We must use the props parameter instead of this.props because this.props\n * may contain outdated values during re-renders, which was the source of the original bug.\n * Always pass the most current props to this method when calling it from propsChanged.\n *\n * @param state - The current state of the connection (selected, hovered, etc.)\n * @param props - The connection properties, used to check showConnectionArrows setting\n */\n protected applyShape(state?: TBaseConnectionState, props?: TConnectionProps): void;\n getPath(): Path2D;\n /**\n * Creates the Path2D object for the arrow in the middle of the connection.\n * This is used by the ConnectionArrow component to render the arrow.\n *\n * @returns A Path2D object representing the arrow shape\n */\n createArrowPath(): Path2D;\n styleArrow(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined;\n protected generatePath(): Path2D;\n protected createPath(): Path2D;\n getClassName(state?: TBaseConnectionState): string;\n style(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined;\n protected setRenderStyles(ctx: CanvasRenderingContext2D, state?: TBaseConnectionState, withDashed?: boolean): void;\n afterRender?(ctx: CanvasRenderingContext2D): void;\n protected propsChanged(nextProps: TConnectionProps): void;\n protected stateChanged(nextState: TBaseConnectionState): void;\n get zIndex(): number;\n protected collectBBoxPoints(): import(\"../../..\").TPoint[];\n protected updatePoints(): void;\n protected handleEvent(event: MouseEvent): void;\n onHitBox(shape: HitBoxData): boolean;\n protected renderLabelText(ctx: CanvasRenderingContext2D): void;\n getStrokeColor(state: TConnection): string;\n protected unmount(): void;\n}" + } + ] + }, + { + "name": "BlockGroups", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class BlockGroups

extends Layer {\n static withBlockGrouping

>(this: new (props: P) => Instance, { groupingFn, mapToGroups, }: {\n groupingFn: (blocks: BlockState[]) => Record;\n mapToGroups: (key: string, params: {\n blocks: BlockState[];\n rect: TRect;\n }) => TGroup;\n }): new (props: P) => Instance & {\n $groupsBlocksMap: ReadonlySignal>;\n };\n static withPredefinedGroups = TDefinitionGroup, Props extends BlockGroupsProps = BlockGroupsProps, Instance extends BlockGroups = BlockGroups>(this: new (props: Props) => Instance): new (props: Props) => Instance & {\n $groupsBlocksMap: ReadonlySignal>;\n defineGroups(groups: P[]): void;\n };\n /**\n * Map of groups to blocks\n * Used to quickly find the blocks of a group\n */\n protected $groupsBlocksMap: Signal[]>>;\n /**\n * Source of groups\n */\n protected $groupsSource: ReadonlySignal[]>;\n /**\n * Map of blocks to groups\n * Used to quickly find the group of a block\n */\n protected $blockGroupsMap: ReadonlySignal>;\n constructor(props: P);\n protected afterInit(): void;\n getParent(): CoreComponent | undefined;\n updateBlocks: (groupId: TGroupId, { deltaX, deltaY }: {\n deltaX: number;\n deltaY: number;\n }) => void;\n setGroups(groups: T[]): void;\n updateGroups(groups: T[]): void;\n protected unmountLayer(): void;\n protected getGroupComponent(group: GroupState): {\n new (props: import(\"./Group\").TGroupProps, parent: BlockGroups): Group;\n define(config: {\n style?: Partial;\n geometry?: Partial;\n }): typeof Group;\n create(this: Constructor>, props?: Props, options?: {\n readonly key?: string;\n readonly ref?: ((inst: unknown) => void) | string;\n }): import(\"../../../lib/CoreComponent\").ComponentDescriptor;\n mount(Component: Constructor>, props?: Props): CoreComponent;\n unmount(instance: any): void;\n };\n protected updateChildren(): import(\"../../../lib/CoreComponent\").ComponentDescriptor[];\n /**\n * Find a Group component by its ID\n */\n getGroupById(groupId: string): Group | null;\n}" + } + ] + }, + { + "name": "BlockGroupsContext", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type BlockGroupsContext = LayerContext & {\n canvas: HTMLCanvasElement;\n ctx: CanvasRenderingContext2D;\n root?: HTMLElement;\n ownerDocument: Document | HTMLElement;\n graph: Graph;\n};" + } + ] + }, + { + "name": "BlockGroupsProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type BlockGroupsProps = LayerProps & {\n mapBlockGroups?: (blocks: BlockState[]) => GroupState[];\n groupComponent?: typeof Group;\n draggable?: boolean;\n updateBlocksOnDrag?: boolean;\n};" + } + ] + }, + { + "name": "BlockGroupsState", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type BlockGroupsState = TComponentState & {\n groups: GroupState[];\n};" + } + ] + }, + { + "name": "BlockGroupsTransferLayer", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class BlockGroupsTransferLayer

extends BlockGroups

{\n /** Current transfer state */\n protected transferState: TransferState;\n /** Cleanup function for the drag state subscription */\n protected disposeSubscription: (() => void) | null;\n protected get isTransferEnabled(): boolean;\n protected afterInit(): void;\n /**\n * Subscribe to DragService state changes\n */\n protected subscribeToDragState(): void;\n /**\n * Handle drag state changes - react to Shift key in real-time\n */\n protected handleDragStateChange(dragState: DragState, isShiftPressed: boolean): void;\n /**\n * Activate transfer mode for currently dragged blocks\n */\n protected activateTransfer(dragState: DragState): void;\n /**\n * Deactivate transfer mode - apply transfer and unlock groups\n * Called when Shift is released during drag\n */\n protected deactivateTransfer(): void;\n /**\n * Lock all groups' sizes\n */\n protected lockAllGroups(): void;\n /**\n * Unlock all groups' sizes\n */\n protected unlockAllGroups(): void;\n protected createIdleState(): TransferState;\n /**\n * Update highlighting based on cursor position\n */\n protected updateHighlight(point: [number, number]): void;\n /**\n * End transfer on drag end (mouseup) - apply transfer if in transfer mode\n */\n protected endTransfer(): void;\n /**\n * Cancel the transfer operation without applying changes.\n *\n * This method can be called to abort an ongoing transfer without moving blocks to a new group.\n * It will unhighlight groups, unlock sizes, and reset the transfer state.\n *\n * @example\n * ```typescript\n * // Cancel transfer on Escape key\n * document.addEventListener('keydown', (e) => {\n * if (e.key === 'Escape' && layer.isTransferring()) {\n * layer.cancelTransfer();\n * }\n * });\n * ```\n */\n cancelTransfer(): void;\n /**\n * Find a group at the given point\n */\n protected findGroupAtPoint(point: [number, number]): Group | null;\n /**\n * Set highlight state for a group directly on the component\n */\n protected setGroupHighlight(groupId: string, highlighted: boolean): void;\n /**\n * Apply the group change to the block\n */\n protected applyGroupChange(changes: TBlockGroupsTransferGroupChange[]): void;\n /**\n * Check if a block transfer is currently in progress.\n *\n * @returns `true` if transfer mode is active (Shift is pressed during drag), `false` otherwise\n *\n * @example\n * ```typescript\n * if (layer.isTransferring()) {\n * console.log('Transferring', layer.getTransferringBlocksCount(), 'blocks');\n * }\n * ```\n */\n isTransferring(): boolean;\n /**\n * Get the number of blocks being transferred in the current operation.\n *\n * @returns Number of blocks currently being transferred, or 0 if no transfer is in progress\n *\n * @example\n * ```typescript\n * const count = layer.getTransferringBlocksCount();\n * console.log(`Transferring ${count} block${count !== 1 ? 's' : ''}`);\n * ```\n */\n getTransferringBlocksCount(): number;\n protected unmountLayer(): void;\n}" + } + ] + }, + { + "name": "BlockGroupsTransferLayerProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type BlockGroupsTransferLayerProps = BlockGroupsProps & {\n /**\n * Enable/disable block transfer between groups with Shift+drag.\n * Default: true\n */\n transferEnabled?: boolean;\n /**\n * Called when block transfer starts (Shift pressed during drag)\n */\n onTransferStart?: OnTransferStart;\n /**\n * Called when block transfer ends (mouse released or Shift released)\n */\n onTransferEnd?: OnTransferEnd;\n /**\n * Called when a block's group changes\n */\n onBlockGroupChange?: OnBlockGroupChange;\n /**\n * If true, blocks will move when the group is dragged\n */\n updateBlocksOnDrag?: boolean;\n};" + } + ] + }, + { + "name": "BlockState", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class BlockState {\n readonly store: BlockListStore;\n private readonly blockSelectionBucket;\n static fromTBlock(store: BlockListStore, block: TBlock): BlockState;\n protected $rawState: Signal;\n /**\n * Block state signal\n *\n * @returns {ReadonlySignal} Block state\n */\n $state: import(\"@preact/signals-core\").ReadonlySignal;\n /**\n * Block id\n */\n get id(): TBlockId;\n /**\n * Block x position\n */\n get x(): number;\n /**\n * Block y position\n */\n get y(): number;\n /**\n * Block width\n */\n get width(): number;\n /**\n * Block height\n */\n get height(): number;\n /**\n * Block selected\n */\n get selected(): boolean;\n /**\n * Computed signal that reactively determines if this block is selected\n * by checking if its ID exists in the selection bucket\n */\n readonly $selected: import(\"@preact/signals-core\").ReadonlySignal;\n readonly $anchorStates: Signal;\n /**\n * Block geometry signal\n *\n * Pay attention!! x and y are rounded to integer\n *\n * @returns {ReadonlySignal<{x: number, y: number, width: number, height: number}>} Block geometry\n */\n readonly $geometry: import(\"@preact/signals-core\").ReadonlySignal<{\n x: number;\n y: number;\n width: number;\n height: number;\n }>;\n /**\n * Block anchor indexes signal\n *\n * @returns {ReadonlySignal>} Block anchor indexes\n */\n $anchorIndexs: import(\"@preact/signals-core\").ReadonlySignal>;\n /**\n * Block anchors signal\n *\n * @returns {ReadonlySignal} Block anchors\n */\n $anchors: import(\"@preact/signals-core\").ReadonlySignal;\n /**\n * Block selected anchors signal\n *\n * @returns {TAnchor[]} Block selected anchors\n */\n $selectedAnchors: import(\"@preact/signals-core\").ReadonlySignal;\n readonly $viewComponent: Signal, import(\"../../components/canvas/blocks/Block\").TBlockProps>>;\n private pendingHidden?;\n /** Reactive flag that mirrors the canvas component's hidden state. */\n readonly $hidden: Signal;\n constructor(store: BlockListStore, block: T, blockSelectionBucket: ISelectionBucket);\n onAnchorSelected(anchorId: AnchorState[\"id\"], selected: boolean): void;\n setSelection(selected: boolean, strategy?: ESelectionStrategy): void;\n getSelectedAnchor(): AnchorState;\n getAnchorState(id: AnchorState[\"id\"]): AnchorState;\n updateXY(x: number, y: number, forceUpdate?: boolean): void;\n setViewComponent(blockComponent: Block): void;\n /**\n * Request to hide or show this block's canvas component.\n * Safe to call before the canvas component is mounted — the request is\n * deferred and applied automatically in setViewComponent().\n */\n requestHidden(hidden: boolean): void;\n getViewComponent(): Block, import(\"../../components/canvas/blocks/Block\").TBlockProps>;\n getConnections(): import(\"../..\").ConnectionState<{\n id?: import(\"../..\").TConnectionId;\n sourceBlockId?: TBlockId;\n targetBlockId?: TBlockId;\n sourceAnchorId?: string;\n targetAnchorId?: string;\n sourcePortId?: import(\"../..\").TPortId;\n targetPortId?: import(\"../..\").TPortId;\n label?: string;\n styles?: Partial & {\n dashes?: number[];\n };\n dashed?: boolean;\n selected?: boolean;\n }>[];\n clearAnchorsSelection(): void;\n setName(newName: string): void;\n updateAnchors(anchors: TAnchor[]): void;\n /**\n * Updates block state\n *\n * @param block {Partial} Block to update\n * @returns void\n */\n updateBlock(block: Partial): void;\n getAnchorById(anchorId: string): AnchorState;\n /**\n * Converts the block state to a TBlock\n *\n * @returns {TBlock} TBlock\n */\n asTBlock(): TBlock;\n /**\n * Cheap snapshot for graph events (e.g. `block-change`): one object spread, no deep clone.\n * Nested values (`anchors`, `meta`, `settings`, …) are shared with the live store — treat as read-only.\n */\n asTBlockShallow(): Readonly;\n}" + } + ] + }, + { + "name": "cachedMeasureText", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function cachedMeasureText(text: string, params: TMeasureTextOptions): TWrapText;" + } + ] + }, + { + "name": "CanvasBlock", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class Block extends GraphComponent {\n static IS: \"Block\";\n cursor?: string;\n readonly isBlock = true;\n context: TGraphLayerContext;\n state: T;\n props: Props;\n connectedState: BlockState;\n private connectedStateUnsubscribers;\n protected lastDragEvent?: MouseEvent;\n protected startDragCoords: number[];\n protected shouldRenderText: boolean;\n protected shouldRenderHtml: boolean;\n protected raised: boolean;\n /**\n * True when the block is fully hidden by CollapsibleGroup (hitbox removed).\n * This is separate from `this.hidden` which is set by setRenderDelegated\n * (React overlay mode, hitbox kept). Both flags make isVisible() return false,\n * but only blockHidden removes the hitbox.\n */\n private blockHidden;\n protected currentState(): T;\n $viewState: import(\"@preact/signals-core\").Signal;\n constructor(props: Props, parent: Component);\n getEntityId(): TBlockId;\n isRendered(): boolean;\n protected updateViewState(params: Partial): void;\n getGeometry(): TRect;\n protected getHitBoxRect(): TRect;\n protected isVisible(): boolean;\n getConfigFlag(flagPath: K): number | boolean | typeof Component | import(\"../../../services/camera/defaultGetCameraBlockScaleLevel\").TGetCameraBlockScaleLevel | \"vertical\" | \"horizontal\" | import(\"../../..\").TResolveWheelIntent | {\n new (props: import(\"../connections\").TConnectionProps, parent: import(\"../connections/BlockConnections\").BlockConnections): import(\"../connections\").BlockConnection<{\n id?: import(\"../../..\").TConnectionId;\n sourceBlockId?: TBlockId;\n targetBlockId?: TBlockId;\n sourceAnchorId?: string;\n targetAnchorId?: string;\n sourcePortId?: import(\"../../../store/connection/port/Port\").TPortId;\n targetPortId?: import(\"../../../store/connection/port/Port\").TPortId;\n label?: string;\n styles?: Partial & {\n dashes?: number[];\n };\n dashed?: boolean;\n selected?: boolean;\n }>;\n create(this: Constructor>, props?: Props_1, options?: {\n readonly key?: string;\n readonly ref?: ((inst: unknown) => void) | string;\n }): import(\"../../../lib/CoreComponent\").ComponentDescriptor;\n mount(Component: Constructor>, props?: Props_1): import(\"../../../lib\").CoreComponent;\n unmount(instance: any): void;\n } | import(\"../../..\").ECanChangeBlockGeometry | import(\"../../..\").ECanDrag | Record;\n IS: \"Block\";\n create(this: Constructor>, props?: Props_1, options?: {\n readonly key?: string;\n readonly ref?: ((inst: unknown) => void) | string;\n }): import(\"../../../lib/CoreComponent\").ComponentDescriptor;\n mount(Component: Constructor>, props?: Props_1): import(\"../../../lib\").CoreComponent;\n unmount(instance: any): void;\n }>;\n protected subscribe(id: TBlockId): void;\n protected propsChanged(nextProps: Props): void;\n protected getNextState(): T;\n protected didIterate(): void;\n protected willMount(): void;\n protected calcZIndex(): number;\n protected raiseBlock(): void;\n protected stateChanged(nextState: T): void;\n getRenderIndex(): number;\n updatePosition(x: number, y: number, silent?: boolean): void;\n protected updatePortPositions(): void;\n getInputPort(): PortState;\n getOutputPort(): PortState;\n getAnchorPort(anchorId: string): PortState;\n /**\n * Check if block can be dragged based on canDrag setting\n */\n isDraggable(): boolean;\n /**\n * Handle drag start - emit event and initialize drag state\n */\n handleDragStart(context: DragContext): void;\n /**\n * Handle click event\n */\n protected handleClick: (event: MouseEvent) => void;\n protected handleSelectionChange: (event: MouseEvent) => void;\n /**\n * Handle drag update - calculate new position and update block\n */\n handleDrag(diff: DragDiff, context: DragContext): void;\n /**\n * Handle drag end - finalize drag state\n */\n handleDragEnd(context: DragContext): void;\n protected calcNextDragPosition(x: number, y: number): [number, number];\n protected applyNextPosition(x: number, y: number): void;\n updateHitBox: (geometry: TRect, force?: boolean) => void;\n getConnectionAnchorPosition(anchor: TAnchor): {\n x: number;\n y: number;\n };\n getAnchorPosition(anchor: TAnchor): TPoint;\n getConnectionPoint(direction: \"in\" | \"out\"): TPoint;\n protected renderAnchor(anchor: TAnchor): import(\"../../../lib/CoreComponent\").ComponentDescriptor;\n protected isAnchorsAllowed(): number | boolean | \"\" | typeof Component | import(\"../../../services/camera/defaultGetCameraBlockScaleLevel\").TGetCameraBlockScaleLevel | \"vertical\" | \"horizontal\" | import(\"../../..\").TResolveWheelIntent | {\n new (props: import(\"../connections\").TConnectionProps, parent: import(\"../connections/BlockConnections\").BlockConnections): import(\"../connections\").BlockConnection<{\n id?: import(\"../../..\").TConnectionId;\n sourceBlockId?: TBlockId;\n targetBlockId?: TBlockId;\n sourceAnchorId?: string;\n targetAnchorId?: string;\n sourcePortId?: import(\"../../../store/connection/port/Port\").TPortId;\n targetPortId?: import(\"../../../store/connection/port/Port\").TPortId;\n label?: string;\n styles?: Partial & {\n dashes?: number[];\n };\n dashed?: boolean;\n selected?: boolean;\n }>;\n create(this: Constructor>, props?: Props_1, options?: {\n readonly key?: string;\n readonly ref?: ((inst: unknown) => void) | string;\n }): import(\"../../../lib/CoreComponent\").ComponentDescriptor;\n mount(Component: Constructor>, props?: Props_1): import(\"../../../lib\").CoreComponent;\n unmount(instance: any): void;\n } | import(\"../../..\").ECanChangeBlockGeometry | import(\"../../..\").ECanDrag | Record;\n IS: \"Block\";\n create(this: Constructor>, props?: Props_1, options?: {\n readonly key?: string;\n readonly ref?: ((inst: unknown) => void) | string;\n }): import(\"../../../lib/CoreComponent\").ComponentDescriptor;\n mount(Component: Constructor>, props?: Props_1): import(\"../../../lib\").CoreComponent;\n unmount(instance: any): void;\n }>;\n protected updateChildren(): import(\"../../../lib/CoreComponent\").ComponentDescriptor[];\n protected willRender(): void;\n protected renderStroke(color: string): void;\n protected getContentRect(): TRect;\n protected renderText(text: string, ctx?: CanvasRenderingContext2D, { rect, renderParams }?: {\n rect: TTExtRect;\n renderParams: TMeasureTextOptions;\n }): void;\n renderMinimalisticBlock(ctx: CanvasRenderingContext2D): void;\n protected renderBody(ctx: CanvasRenderingContext2D): void;\n renderSchematicView(ctx: CanvasRenderingContext2D): void;\n setHiddenBlock(hidden: boolean): void;\n renderDetailedView(ctx: CanvasRenderingContext2D): void;\n protected render(): void;\n protected unmount(): void;\n}" + } + ] + }, + { + "name": "clearColorCache", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function clearColorCache(): void;" + } + ] + }, + { + "name": "clearTextCache", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function clearTextCache(): void;" + } + ] + }, + { + "name": "CollapsibleGroup", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class CollapsibleGroup extends Group {\n /** Snapshot of `collapsedRect` at drag start; see {@link handleDrag}. */\n private dragStartCollapsedRect;\n /**\n * Lazily computed delegation counts for {@link renderCollapsedView}; cleared when\n * delegation changes (delegate/undelegate ports).\n */\n private portDelegationCountsCache;\n static define(config: {\n style?: Partial;\n geometry?: Partial;\n }): typeof CollapsibleGroup;\n /**\n * Extend base subscription to also react to collapsed state on init.\n * subscribeSignal fires immediately with the current value, so a group\n * that starts with collapsed: true will hide its blocks on mount.\n *\n * Also handles external collapse state changes: if setGroups() is called\n * with collapsed: false while the group is currently collapsed, it expands.\n */\n protected subscribeToGroup(): ReturnType;\n /**\n * Returns the visual rect. When collapsed, returns `collapsedRect`\n * (with padding) so the group renders as a compact header.\n */\n protected getRect(rect?: TRect): TRect;\n /**\n * Sets the hitbox to the collapsed rect when collapsed, or the expanded\n * rect otherwise. Passes the raw inner rect to super so that base Group's\n * updateHitBox can apply padding exactly once.\n */\n protected updateHitBox(rect: TRect): void;\n /**\n * Remember inner rect and collapsed rect so {@link handleDrag} can translate\n * `collapsedRect` by the same snapped delta as `rect` (grid snapping in Group).\n */\n handleDragStart(context: DragContext): void;\n handleDragEnd(context: DragContext): void;\n /**\n * When dragging a collapsed group, move `collapsedRect` in lockstep with the snapped\n * `rect` movement computed by {@link Group.handleDrag} (not per-frame mouse deltas).\n */\n handleDrag(diff: DragDiff, context: DragContext): void;\n /** Whether this group is currently in the collapsed state. */\n isCollapsed(): boolean;\n protected getGroupBlocks(): BlockState[];\n /**\n * Returns the number of ports currently delegated to the left and right\n * group edge ports. Only meaningful when the group is collapsed.\n *\n * Cost is O(blocks × anchors) per call when the cache is cold — typical group\n * sizes keep this cheap. The result is cached until delegation changes\n * ({@link delegatePorts} / {@link undelegatePorts}); override {@link renderCollapsedView}\n * and call a custom counter if you need different invalidation rules.\n */\n protected getPortDelegationCounts(): TPortDelegationCounts;\n private invalidatePortDelegationCountsCache;\n /**\n * Compute the collapsed rect for a given full rect.\n *\n * Uses the user-provided `getCollapseRect` if available, otherwise falls\n * back to the direction-based default.\n */\n private computeCollapsedRect;\n /**\n * Collapse the group: set collapsedRect, hide member blocks,\n * and redirect their ports to the group edges.\n *\n * Emits a cancelable `group-collapse-change` event before applying changes.\n * If a listener calls `event.preventDefault()`, the collapse is cancelled.\n */\n collapse(): void;\n /**\n * Expand the group: remove collapsedRect, show member blocks, and let\n * them resume managing their own ports.\n *\n * Emits a cancelable `group-collapse-change` event before applying changes.\n * If a listener calls `event.preventDefault()`, the expand is cancelled.\n */\n expand(): void;\n protected unmount(): void;\n private applyBlockVisibility;\n /**\n * Get (or create) the group's left-edge port used as a delegation target.\n * Input ports and IN anchors delegate to this port when collapsed.\n */\n private getLeftEdgePort;\n /**\n * Get (or create) the group's right-edge port used as a delegation target.\n * Output ports and OUT anchors delegate to this port when collapsed.\n */\n private getRightEdgePort;\n /**\n * Update the group's edge port positions to match the given rect.\n */\n private updateGroupPortPositions;\n /**\n * Delegate all ports of group blocks to the group's edge ports.\n *\n * - Input port → left-edge port\n * - Output port → right-edge port\n * - IN anchors → left-edge port\n * - OUT anchors → right-edge port\n *\n * While delegated, block ports mirror the group edge positions.\n * When the group is dragged, only the group edge ports need to be\n * updated — all delegated ports follow automatically.\n */\n private delegatePorts;\n /**\n * Remove delegation from all ports of group blocks, restoring their\n * original positions (saved automatically by the delegation mechanism).\n */\n private undelegatePorts;\n protected render(): void;\n /**\n * Render the compact header shown when the group is collapsed.\n * Override this method to customise the collapsed appearance.\n */\n protected renderCollapsedView(ctx: CanvasRenderingContext2D): void;\n}" + } + ] + }, + { + "name": "Component", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class Component extends CoreComponent {\n protected firstIterate: boolean;\n protected firstRender: boolean;\n protected firstUpdateChildren: boolean;\n protected shouldRender: boolean;\n protected shouldUpdateChildren: boolean;\n protected shouldRenderChildren: boolean;\n state: State;\n protected __data: {\n nextProps: Props | undefined;\n nextState: State | undefined;\n };\n constructor(props: Props, parent: CoreComponent);\n protected willMount(): void;\n setContext(context: Partial>): void;\n setProps(props?: Pick): void;\n getState(): State;\n protected setState(state: Pick): void;\n protected propsChanged(_nextProps: Props): void;\n protected stateChanged(_nextState: State): void;\n protected contextChanged(_nextContext: Context): void;\n protected checkData(): boolean;\n protected willRender(): void;\n protected didRender(): void;\n protected renderLifeCycle(): void;\n protected willUpdateChildren(): void;\n protected didUpdateChildren(): void;\n protected childrenLifeCycle(): void;\n protected willIterate(): void;\n protected didIterate(): void;\n protected willNotRender(): void;\n iterate(): boolean;\n}" + } + ] + }, + { + "name": "computeDefaultCollapseRect", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function computeDefaultCollapseRect(expandedRect: TRect, direction?: {\n x?: TCollapseDirection;\n y?: TCollapseDirection;\n}, collapsedWidth?: number, collapsedHeight?: number): TRect;" + } + ] + }, + { + "name": "ConnectionArrow", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class ConnectionArrow implements Path2DRenderInstance {\n protected connection: BlockConnection;\n constructor(connection: BlockConnection);\n getPath(): Path2D;\n style(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined;\n}" + } + ] + }, + { + "name": "ConnectionLayer", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class ConnectionLayer extends Layer {\n private startState;\n private endState;\n protected target?: Block | Anchor;\n protected sourceComponent?: BlockState | AnchorState;\n protected enabled: boolean;\n private eventAborter;\n constructor(props: ConnectionLayerProps);\n /**\n * Called after initialization and when the layer is reattached.\n * This is where we set up event subscriptions to ensure they work properly\n * after the layer is unmounted and reattached.\n */\n protected afterInit(): void;\n enable: () => void;\n disable: () => void;\n protected checkIsShouldStartCreationConnection(target: GraphComponent, initEvent: MouseEvent): boolean;\n protected handleMouseDown: (nativeEvent: GraphMouseEvent) => void;\n protected renderEndpoint(ctx: CanvasRenderingContext2D): void;\n protected render(): void;\n private getBlockId;\n private getAnchorId;\n private onStartConnection;\n private onMoveNewConnection;\n private onEndNewConnection;\n}" + } + ] + }, + { + "name": "ConnectionState", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class ConnectionState {\n store: ConnectionsStore;\n private readonly connectionSelectionBucket;\n protected $rawState: import(\"@preact/signals-core\").Signal;\n /**\n * Computed signal that reactively determines if this connection is selected\n * by checking if its ID exists in the selection bucket\n */\n readonly $selected: import(\"@preact/signals-core\").ReadonlySignal;\n /**\n * Connection state signal.\n * Derives `selected` from the selection bucket, consistent with BlockState pattern.\n */\n $state: import(\"@preact/signals-core\").ReadonlySignal;\n private isDestroyed;\n get id(): TConnectionId;\n get sourceBlockId(): TBlockId;\n get sourceAnchorId(): string;\n get targetBlockId(): TBlockId;\n get targetAnchorId(): string;\n $sourcePortId: import(\"@preact/signals-core\").ReadonlySignal;\n $targetPortId: import(\"@preact/signals-core\").ReadonlySignal;\n readonly $sourcePortState: import(\"@preact/signals-core\").ReadonlySignal>;\n readonly $targetPortState: import(\"@preact/signals-core\").ReadonlySignal>;\n readonly $sourcePort: import(\"@preact/signals-core\").ReadonlySignal>;\n readonly $targetPort: import(\"@preact/signals-core\").ReadonlySignal>;\n readonly $sourceBlock: import(\"@preact/signals-core\").ReadonlySignal>;\n readonly $targetBlock: import(\"@preact/signals-core\").ReadonlySignal>;\n /**\n * True when both source and target blocks are hidden (e.g. both inside a\n * collapsed group). When true the connection should not be rendered.\n */\n readonly $hidden: import(\"@preact/signals-core\").ReadonlySignal;\n $geometry: import(\"@preact/signals-core\").ReadonlySignal;\n static getConnectionId(connection: TConnection): TConnectionId;\n private viewComponent;\n constructor(store: ConnectionsStore, connectionState: T, connectionSelectionBucket: ISelectionBucket);\n /**\n * Sets the view component for this connection state\n * @param viewComponent - The BaseConnection component instance\n * @returns {void}\n */\n setViewComponent

(viewComponent: BaseConnection): void;\n /**\n * Gets the view component associated with this connection state.\n * @returns The BaseConnection view component or undefined if not set.\n */\n getViewComponent(): BaseConnection;\n /**\n * Checks if the connection is currently selected.\n * @returns True if the connection is selected, false otherwise.\n */\n isSelected(): boolean;\n setSelection(selected: boolean, strategy?: ESelectionStrategy): void;\n /**\n * @deprecated Use `toJSON` instead.\n * @returns {TConnection} A deep copy of the connection data\n */\n asTConnection(): TConnection;\n /**\n * Converts the connection state to a plain JSON object\n * @returns {TConnection} A deep copy of the connection data\n */\n toJSON(): TConnection;\n /**\n * Updates the connection with new data\n * @param connection - Partial connection data to update\n * @returns {void}\n */\n updateConnection(connection: Partial): void;\n /**\n * Clean up port observers when connection is destroyed\n * @returns {void}\n */\n destroy(): void;\n}" + } + ] + }, + { + "name": "ConverterResult", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type ConverterResult = {\n edges: Record>;\n blocks: Record;\n};" + } + ] + }, + { + "name": "createAnchorPortId", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "createAnchorPortId: (blockId: TBlockId, anchorId: string) => string" + } + ] + }, + { + "name": "createBlockPointPortId", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "createBlockPointPortId: (blockId: TBlockId, isInput?: boolean) => string" + } + ] + }, + { + "name": "createPortId", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "createPortId: (blockId: TBlockId, anchorId?: string, isInput?: boolean) => string" + } + ] + }, + { + "name": "createWheelIntentResolver", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function createWheelIntentResolver(): TResolveWheelIntent;" + } + ] + }, + { + "name": "CSS_VARIABLE_MAPPINGS", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "CSS_VARIABLE_MAPPINGS: CSSVariableMappings" + } + ] + }, + { + "name": "CSSVariableChange", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface CSSVariableChange {\n /** The CSS variable name (e.g., '--graph-block-background') */\n name: string;\n /** The new value of the CSS variable */\n value: string;\n /** The previous value of the CSS variable (if any) */\n oldValue?: string;\n}" + } + ] + }, + { + "name": "CSSVariableMapping", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface CSSVariableMapping {\n /** CSS variable name */\n cssVariable: string;\n /** Path in TGraphColors or TGraphConstants (e.g., 'block.background') */\n graphPath: string;\n /** Type converter for the CSS variable value */\n typeConverter: CSSVariableType;\n}" + } + ] + }, + { + "name": "CSSVariableMappings", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type CSSVariableMappings = CSSVariableMapping[];" + } + ] + }, + { + "name": "CSSVariablesLayer", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class CSSVariablesLayer extends Layer {\n state: CSSVariablesLayerState;\n private containerElement;\n private styleObserver;\n constructor(props: CSSVariablesLayerProps);\n protected propsChanged(nextProps: CSSVariablesLayerProps): void;\n protected afterInit(): void;\n protected unmount(): void;\n /**\n * Creates the container HTML element with specified CSS class\n */\n private createContainerElement;\n /**\n * Removes the container element from DOM\n */\n private removeContainerElement;\n protected createStyleObserver(): StyleObserver;\n /**\n * Reads initial CSS variable values and applies them to the graph\n * This ensures that CSS variables set before the observer starts are applied\n */\n private readInitialCSSVariables;\n /**\n * Starts observing CSS variable changes using style-observer\n */\n private startObserving;\n /**\n * Stops observing CSS variable changes\n */\n private stopObserving;\n /**\n * Handles CSS variable changes from style-observer\n */\n private handleCSSVariableChanges;\n /**\n * Applies CSS variable changes to graph colors and constants\n */\n private applyChangesToGraph;\n}" + } + ] + }, + { + "name": "CSSVariablesLayerProps", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface CSSVariablesLayerProps extends LayerProps {\n /** CSS class name to apply to the container div */\n containerClass: string;\n /** Optional callback for CSS variable changes */\n onChange?: (changes: CSSVariableChange[]) => void;\n /** Whether to enable debug logging */\n debug?: boolean;\n}" + } + ] + }, + { + "name": "CSSVariablesLayerState", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface CSSVariablesLayerState extends TComponentState {\n /** Whether the layer is actively observing changes */\n isObserving: boolean;\n colors: RecursivePartial;\n constants: RecursivePartial;\n}" + } + ] + }, + { + "name": "curvePolyline", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function curvePolyline(points: {\n x: number;\n y: number;\n}[], baseRadius: number): Path2D;" + } + ] + }, + { + "name": "debounce", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "debounce: ) => void>(fn: T, { priority, frameInterval, frameTimeout }?: TDebounceOptions) => T & {\n cancel: () => void;\n flush: () => void;\n isScheduled: () => boolean;\n}" + } + ] + }, + { + "name": "DEFAULT_CSS_VARIABLES_LAYER_PROPS", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "DEFAULT_CSS_VARIABLES_LAYER_PROPS: Partial" + } + ] + }, + { + "name": "DEFAULT_NODE_HEIGHT", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "DEFAULT_NODE_HEIGHT = 100" + } + ] + }, + { + "name": "DEFAULT_NODE_WIDTH", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "DEFAULT_NODE_WIDTH = 100" + } + ] + }, + { + "name": "defaultGetCameraBlockScaleLevel", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function defaultGetCameraBlockScaleLevel(graph: Graph, scale: number): ECameraScaleLevel;" + } + ] + }, + { + "name": "EAnchorType", + "declarations": [ + { + "kind": "EnumDeclaration", + "text": "export declare enum EAnchorType {\n IN = \"IN\",\n OUT = \"OUT\"\n}" + } + ] + }, + { + "name": "ECameraScaleLevel", + "declarations": [ + { + "kind": "EnumDeclaration", + "text": "export declare enum ECameraScaleLevel {\n Minimalistic = 100,\n Schematic = 200,\n Detailed = 300\n}" + } + ] + }, + { + "name": "ECanChangeBlockGeometry", + "declarations": [ + { + "kind": "EnumDeclaration", + "text": "export declare enum ECanChangeBlockGeometry {\n ALL = \"all\",\n ONLY_SELECTED = \"onlySelected\",\n NONE = \"none\"\n}" + } + ] + }, + { + "name": "ECanDrag", + "declarations": [ + { + "kind": "EnumDeclaration", + "text": "export declare enum ECanDrag {\n /** Any component can be dragged. If component is in selection, all selected draggable components move together */\n ALL = \"all\",\n /** Only selected components can be dragged */\n ONLY_SELECTED = \"onlySelected\",\n /** Drag is disabled for all components (except manual drag via startDrag) */\n NONE = \"none\"\n}" + } + ] + }, + { + "name": "Edge", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type Edge = {\n from: NodeId;\n to: NodeId;\n arrows?: {\n from?: boolean;\n to?: boolean;\n };\n};" + } + ] + }, + { + "name": "enableWheelIntentDebug", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function enableWheelIntentDebug(logger?: TWheelIntentDebugLogger | null): void;" + } + ] + }, + { + "name": "ESchedulerPriority", + "declarations": [ + { + "kind": "EnumDeclaration", + "text": "export declare enum ESchedulerPriority {\n HIGHEST = 0,\n HIGH = 1,\n MEDIUM = 2,\n LOW = 3,\n LOWEST = 4\n}" + } + ] + }, + { + "name": "ESelectionStrategy", + "declarations": [ + { + "kind": "EnumDeclaration", + "text": "export declare enum ESelectionStrategy {\n REPLACE = \"replace\",\n APPEND = \"add\",\n SUBTRACT = \"subtract\",\n TOGGLE = \"toggle\"\n}" + } + ] + }, + { + "name": "EVENTS", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "EVENTS: {\n DRAG_START: string;\n DRAG_UPDATE: string;\n DRAG_END: string;\n RESIZE_START: string;\n RESIZE_UPDATE: string;\n RESIZE_END: string;\n RESIZER_MOUSEDOWN: string;\n SELECTION_START: string;\n SELECTION_UPDATE: string;\n SELECTION_END: string;\n NEW_CONNECTION_START: string;\n NEW_CONNECTION_UPDATE: string;\n NEW_CONNECTION_END: string;\n NEW_BLOCK_START: string;\n NEW_BLOCK_UPDATE: string;\n NEW_BLOCK_END: string;\n}" + } + ] + }, + { + "name": "EWheelIntent", + "declarations": [ + { + "kind": "EnumDeclaration", + "text": "export declare enum EWheelIntent {\n Pan = \"pan\",\n Zoom = \"zoom\"\n}" + } + ] + }, + { + "name": "filterSupportedCSSChanges", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function filterSupportedCSSChanges(changes: CSSVariableChange[]): CSSVariableChange[];" + } + ] + }, + { + "name": "getFontSize", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function getFontSize(fontSize: any, scale: any): number;" + } + ] + }, + { + "name": "getLabelCoords", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function getLabelCoords(x1: number, // source anchor x\ny1: number, // source anchor y\nx2: number, // target anchor x\ny2: number, // target anchor y\nwidth: number, // label width\nheight: number, // label height\nGRID_SIZE: number): {\n x: number;\n y: number;\n aligment: CanvasTextAlign;\n};" + } + ] + }, + { + "name": "Graph", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class Graph {\n readonly scheduler: import(\"./lib/Scheduler\").GlobalScheduler;\n readonly cameraService: CameraService;\n readonly layers: Layers;\n readonly api: PublicGraphApi;\n readonly eventEmitter: EventTarget;\n readonly rootStore: RootStore;\n hitTest: HitTest;\n /**\n * Service that manages drag operations for all draggable GraphComponents.\n * Handles autopanning, cursor locking, and coordinates drag lifecycle across selected components.\n */\n readonly dragService: DragService;\n readonly keyboardService: KeyboardService;\n protected readonly graphLayer: GraphLayer;\n protected readonly belowLayer: BelowLayer;\n protected readonly selectionLayer: SelectionLayer;\n protected readonly cursorLayer: CursorLayer;\n getGraphCanvas(): HTMLCanvasElement;\n get graphColors(): TGraphColors;\n $graphColors: import(\"@preact/signals-core\").Signal;\n get graphConstants(): TGraphConstants;\n $graphConstants: import(\"@preact/signals-core\").Signal;\n /**\n * Committed camera state. Updated only after a non-prevented `camera-change` event.\n * Subscribe via `signal.subscribe()` or Layer's `onSignal()` to react to applied changes.\n */\n $camera: import(\"@preact/signals-core\").Signal;\n state: GraphState;\n protected config: TGraphConfig;\n protected startRequested: boolean;\n get blocks(): import(\"./store/block/BlocksList\").BlockListStore;\n get connections(): import(\"./store/connection\").ConnectionsStore;\n get selectionService(): import(\"./services/selection\").SelectionService;\n constructor(config: TGraphConfig, rootEl?: HTMLDivElement, graphColors?: TGraphColors, graphConstants?: TGraphConstants);\n protected onUpdateSize: (event: IRect) => void;\n getGraphLayer(): GraphLayer;\n setColors(colors: RecursivePartial): void;\n setConstants(constants: RecursivePartial): void;\n /**\n * Zoom to center of camera\n * @param zoomConfig - zoom config\n * @param zoomConfig.x if set - zoom to x coordinate, else - zoom to center\n * @param zoomConfig.y if set - zoom to y coordinate, else - zoom to center\n * @param zoomConfig.scale camera scale\n *\n * @returns {undefined}\n * */\n zoom(zoomConfig: {\n x?: number;\n y?: number;\n scale: number;\n }): void;\n /**\n * Zooms to the target\n *\n * - If target is rectangle, it will be zoomed({@link PublicGraphApi.zoomToRect}) to the rectangle and returns true.\n * - If target is array of block ids, it will be zoomed({@link PublicGraphApi.zoomToBlocks}) to the blocks, if at least one block is found, returns true, otherwise returns false.\n * - If target is array of {@link GraphComponent} instances, it will be zoomed({@link PublicGraphApi.zoomToElements}) to the rect containing all components and returns true.\n * - If target is center, it will be zoomed({@link PublicGraphApi.zoomToViewPort}) to the center and returns true.\n *\n * @example\n * ```typescript\n * graph.zoomTo(\"center\");\n * graph.zoomTo([block1.id, block2.id]);\n * graph.zoomTo([block1, block2]);\n * graph.zoomTo({x: 100, y: 100, width: 100, height: 100 });\n * ```\n * @param target - target to zoom to\n * @param config - zoom config, optional, optional\n * @returns {boolean} true if zoom is successful, false otherwise\n *\n * */\n zoomTo(target: TGraphZoomTarget, config?: ZoomConfig): boolean;\n getElementsOverPoint>(point: IPoint, filter?: T[]): InstanceType[];\n getElementOverPoint>(point: IPoint, filter?: T[]): InstanceType | undefined;\n /**\n * Returns the current viewport rectangle in camera space, expanded by threshold.\n * @returns {TRect} Viewport rect in camera-relative coordinates\n */\n getViewportRect(): TRect;\n getElementsInViewport>(filter?: T[]): InstanceType[];\n getElementsOverRect>(rect: TRect, filter?: T[]): InstanceType[];\n getPointInCameraSpace(event: MouseEvent): Point;\n updateEntities({ blocks, connections, }: Partial<{\n blocks?: TBlock[];\n connections?: TConnection[];\n }>): void;\n setEntities({ blocks, connections, }: Partial<{\n blocks?: TBlock[];\n connections?: TConnection[];\n }>): void;\n on(type: EventName, cb: Cb, options?: AddEventListenerOptions | boolean): () => void;\n off(type: EventName, cb: Cb): void;\n emit[0] = Parameters[0]>(eventName: EventName, detail: GraphEventParams

): GraphEvent>;\n executеDefaultEventAction[0] = Parameters[0]>(eventName: EventName, detail: GraphEventParams

, defaultCb: () => void): void;\n addLayer = Constructor>(layerCtor: T, props: LayerPublicProps): InstanceType;\n detachLayer(layer: Layer): void;\n setupGraph(config?: TGraphConfig): void;\n updateSettings(settings: Partial): void;\n updateSize(): void;\n attach(rootEl: HTMLDivElement): void;\n start(rootEl?: HTMLDivElement): void;\n /**\n * Graph is ready when the hitboxes are stable.\n * In order to initialize hitboxes we need to start scheduler and wait untils every component registered in hitTest service\n * Immediatelly after registering startign a rendering process.\n * @param cb - Callback to run after graph is ready\n */\n runAfterGraphReady(cb: () => void): void;\n stop(full?: boolean): void;\n protected setGraphState(state: GraphState): void;\n protected clear(): void;\n detach(): void;\n unmount(): void;\n /**\n * Locks the cursor to a specific type, disabling automatic cursor changes.\n *\n * When the cursor is locked, it will remain fixed to the specified type\n * and will not change automatically based on component interactions until\n * unlockCursor() is called. This is useful during drag operations, loading\n * states, or other situations where you want to override the default\n * interactive cursor behavior.\n *\n * @param cursor - The cursor type to lock to\n *\n * @example\n * ```typescript\n * // Lock to loading cursor during async operation\n * graph.lockCursor(\"wait\");\n *\n * // Lock to grabbing cursor during drag operation\n * graph.lockCursor(\"grabbing\");\n *\n * // Lock to copy cursor for duplication operations\n * graph.lockCursor(\"copy\");\n * ```\n *\n * @see {@link CursorLayer.lockCursor} for more details\n * @see {@link unlockCursor} to return to automatic behavior\n */\n lockCursor(cursor: CursorLayerCursorTypes): void;\n /**\n * Unlocks the cursor and returns to automatic cursor management.\n *\n * The cursor will immediately update to reflect the current state\n * based on the component under the mouse (if any). This provides\n * smooth transitions when ending drag operations or async tasks.\n *\n * @example\n * ```typescript\n * // After completing a drag operation\n * graph.unlockCursor(); // Will show appropriate cursor for current hover state\n *\n * // After finishing an async operation\n * await someAsyncTask();\n * graph.unlockCursor(); // Returns to interactive cursor behavior\n * ```\n *\n * @see {@link CursorLayer.unlockCursor} for more details\n * @see {@link lockCursor} to override automatic behavior\n */\n unlockCursor(): void;\n /**\n * Returns the CursorLayer instance for advanced cursor management.\n *\n * Use this method when you need direct access to cursor layer functionality\n * beyond the basic setCursor/unsetCursor API, such as checking the current\n * mode or getting the component under the cursor.\n *\n * @returns The CursorLayer instance\n *\n * @example\n * ```typescript\n * const cursorLayer = graph.getCursorLayer();\n *\n * // Check current mode\n * if (cursorLayer.isManual()) {\n * console.log(\"Manual cursor:\", cursorLayer.getManualCursor());\n * }\n *\n * // Get component under cursor for debugging\n * const target = cursorLayer.getCurrentTarget();\n * console.log(\"Hovering over:\", target?.constructor.name);\n * ```\n *\n * @see {@link CursorLayer} for available methods and properties\n */\n getCursorLayer(): CursorLayer;\n}" + } + ] + }, + { + "name": "GraphComponent", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class GraphComponent extends EventedComponent {\n hitBox: HitBox;\n private unsubscribe;\n protected ports: Map;\n getEntityId(): number | string;\n /**\n * Returns whether this component can be dragged.\n * Override in subclasses to enable drag behavior.\n * Components that return true will participate in drag operations managed by DragService.\n *\n * @returns true if the component is draggable, false otherwise\n */\n isDraggable(): boolean;\n /**\n * Called when a drag operation starts on this component.\n * Override in subclasses to handle drag start logic.\n *\n * @param _context - The drag context containing coordinates and participating components\n */\n handleDragStart(_context: DragContext): void;\n /**\n * Called on each frame during a drag operation.\n * Override in subclasses to update component position.\n *\n * @param _diff - The diff containing coordinate changes (deltaX/deltaY for incremental, diffX/diffY for absolute)\n * @param _context - The drag context containing coordinates and participating components\n */\n handleDrag(_diff: DragDiff, _context: DragContext): void;\n /**\n * Called when a drag operation ends.\n * Override in subclasses to finalize drag state.\n *\n * @param _context - The drag context containing final coordinates and participating components\n */\n handleDragEnd(_context: DragContext): void;\n get affectsUsableRect(): boolean;\n private mounted;\n protected hidden: boolean;\n constructor(props: Props, parent: Component);\n adoptColor(color: string, { alpha }?: {\n alpha?: number;\n }): string;\n createPort(id: TPortId): PortState;\n getPort(id: TPortId): PortState;\n /**\n * Get all ports of this component\n * @returns Array of all port states\n */\n getPorts(): PortState[];\n /**\n * Update port position and metadata\n * @param id Port identifier\n * @param portChanges port changes {x?, y?, meta?}\n */\n updatePort(id: TPortId, portChanges: Partial>): void;\n protected setAffectsUsableRect(affectsUsableRect: boolean): void;\n protected propsChanged(_nextProps: Props): void;\n protected contextChanged(_nextContext: Context): void;\n onChange(cb: (v: this) => void): () => void;\n protected checkData(): boolean;\n protected onDrag({ onDragStart, onDragUpdate, onDrop, isDraggable, autopanning, dragCursor, }: {\n onDragStart?: (_event: MouseEvent) => void | boolean;\n onDragUpdate?: (diff: {\n startCoords: [number, number];\n prevCoords: [number, number];\n currentCoords: [number, number];\n diffX: number;\n diffY: number;\n deltaX: number;\n deltaY: number;\n }, _event: MouseEvent) => void;\n onDrop?: (_event: MouseEvent) => void;\n isDraggable?: (event: MouseEvent) => boolean;\n autopanning?: boolean;\n dragCursor?: CursorLayerCursorTypes;\n }): () => void;\n isMounted(): boolean;\n protected willMount(): void;\n /**\n * Subscribes to a graph event and automatically unsubscribes on component unmount.\n *\n * This is a convenience wrapper around this.context.graph.on that also registers the\n * returned unsubscribe function in the internal unsubscribe list, ensuring proper cleanup.\n *\n * @param eventName - Graph event name to subscribe to\n * @param handler - Event handler callback\n * @param options - Additional AddEventListener options\n * @returns Unsubscribe function\n */\n protected onGraphEvent(eventName: EventName, handler: Cb, options?: AddEventListenerOptions | boolean): () => void;\n /**\n * Subscribes to a DOM event on the graph root element and automatically unsubscribes on unmount.\n *\n * @param eventName - DOM event name to subscribe to\n * @param handler - Event handler callback\n * @param options - Additional AddEventListener options\n * @returns Unsubscribe function\n */\n protected onRootEvent(eventName: K, handler: ((this: HTMLElement, ev: HTMLElementEventMap[K]) => void) | EventListenerObject, options?: AddEventListenerOptions | boolean): () => void;\n protected subscribeSignal(signal: Signal, cb: (v: T) => void): void;\n onUnmounted(cb: () => void): () => void;\n protected unmount(): void;\n setHitBox(minX: number, minY: number, maxX: number, maxY: number, force?: boolean): void;\n protected willIterate(): void;\n protected isVisible(): boolean;\n protected getHitBoxRect(): TRect;\n protected setVisibility(visible: boolean, { removeHitbox }: {\n removeHitbox: boolean;\n }): void;\n setRenderDelegated(delegated: boolean): void;\n getHitBox(): [number, number, number, number];\n removeHitBox(): void;\n destroyHitBox(): void;\n onHitBox(data: HitBoxData): boolean;\n}" + } + ] + }, + { + "name": "GraphState", + "declarations": [ + { + "kind": "EnumDeclaration", + "text": "export declare enum GraphState {\n INIT = 0,\n ATTACHED = 1,\n READY = 2\n}" + } + ] + }, + { + "name": "Group", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class Group extends GraphComponent, TGraphLayerContext> {\n static define(config: {\n style?: Partial;\n geometry?: Partial;\n }): typeof Group;\n get zIndex(): number;\n context: TGraphLayerContext;\n protected blocks: BlockState[];\n protected groupState: GroupState;\n cursor: string;\n protected style: TGroupStyle;\n protected geometry: TGroupGeometry;\n /** Whether the group is highlighted (block is being dragged over it) */\n protected highlighted: boolean;\n /** Whether the group is currently being dragged */\n protected isDragging: boolean;\n protected dragStartRect: {\n x: number;\n y: number;\n } | null;\n protected lastSnappedPos: {\n x: number;\n y: number;\n } | null;\n constructor(props: TGroupProps, parent: BlockGroups);\n protected handleClick: (event: MouseEvent) => void;\n /**\n * Set the highlighted state of the group\n */\n setHighlighted(highlighted: boolean): void;\n /**\n * Check if the group is currently highlighted (block is being dragged over it).\n *\n * This method is useful for custom group components that override the `render()` method\n * and need to apply different styling when the group is highlighted during transfer mode.\n *\n * @returns `true` if the group is highlighted, `false` otherwise\n *\n * @example\n * ```typescript\n * class CustomGroup extends Group {\n * protected override render() {\n * const ctx = this.context.ctx;\n * const rect = this.getRect();\n *\n * // Apply different styles based on state\n * if (this.isHighlighted()) {\n * ctx.strokeStyle = 'rgba(100, 200, 100, 1)';\n * ctx.lineWidth = 3;\n * } else if (this.state.selected) {\n * ctx.strokeStyle = 'rgba(100, 100, 100, 1)';\n * ctx.lineWidth = 2;\n * } else {\n * ctx.strokeStyle = 'rgba(100, 100, 100, 0.4)';\n * ctx.lineWidth = 1;\n * }\n *\n * ctx.beginPath();\n * ctx.roundRect(rect.x, rect.y, rect.width, rect.height, 8);\n * ctx.stroke();\n * }\n * }\n * ```\n */\n isHighlighted(): boolean;\n getEntityId(): string;\n /**\n * Check if group can be dragged based on props.draggable and canDrag setting\n */\n isDraggable(): boolean;\n /**\n * Override to apply snapping or other position transforms during drag.\n * Called with the raw (pre-snap) target position computed from the drag start + cumulative diff.\n * The default implementation returns the position unchanged (no snapping).\n *\n * @example\n * ```typescript\n * protected override snapPosition(x: number, y: number) {\n * const grid = 16;\n * return { x: Math.round(x / grid) * grid, y: Math.round(y / grid) * grid };\n * }\n * ```\n */\n protected snapPosition(x: number, y: number): {\n x: number;\n y: number;\n };\n /**\n * Handle drag start - stores the initial rect position and sets isDragging flag.\n * Subclasses that override this method should call super.handleDragStart() to preserve this behavior.\n */\n handleDragStart(_context: DragContext): void;\n /**\n * Handle drag update - moves the group rect and notifies via onDragUpdate.\n * Uses the cumulative diff from drag start so that snapPosition always operates\n * on the absolute target position (avoids error accumulation from per-frame deltas).\n * onDragUpdate is called only when the position actually changes.\n */\n handleDrag(diff: DragDiff, _context: DragContext): void;\n /**\n * Handle drag end - clears isDragging flag and drag tracking state.\n * Subclasses that override this method should call super.handleDragEnd() to preserve this behavior.\n */\n handleDragEnd(_context: DragContext): void;\n protected getRect(rect?: TRect): {\n x: number;\n y: number;\n width: number;\n height: number;\n };\n protected subscribeToGroup(): void;\n protected unmount(): void;\n protected updateHitBox(rect: TRect): void;\n protected layoutText(text: string, textParams?: TMeasureTextOptions): {\n measures: import(\"../../../utils/functions/text\").TWrapText;\n lines: any[];\n lineHeight: number;\n };\n protected renderBody(ctx: CanvasRenderingContext2D, rect?: {\n x: number;\n y: number;\n width: number;\n height: number;\n }): void;\n protected render(): void;\n}" + } + ] + }, + { + "name": "GroupingFn", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type GroupingFn = (blocks: BlockState[]) => Record;" + } + ] + }, + { + "name": "IEntityWithComponent", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface IEntityWithComponent {\n getViewComponent(): T | undefined;\n}" + } + ] + }, + { + "name": "IPortConnectionMeta", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface IPortConnectionMeta {\n /** Enable snapping for this port. If false or undefined, port will not participate in snapping */\n snappable?: boolean;\n /** Custom condition for snapping - validates if connection is allowed */\n snapCondition?: TPortSnapCondition;\n}" + } + ] + }, + { + "name": "ISelectionBucket", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface ISelectionBucket extends BaseSelectionBucket {\n}" + } + ] + }, + { + "name": "isI3WheelIntentRule", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function isI3WheelIntentRule(rule: TWheelIntentRule): boolean;" + } + ] + }, + { + "name": "isI4WheelIntentRule", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function isI4WheelIntentRule(rule: TWheelIntentRule): boolean;" + } + ] + }, + { + "name": "isPinchZoomGesture", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function isPinchZoomGesture(event: WheelEvent): boolean;" + } + ] + }, + { + "name": "Layer", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class Layer extends Component {\n static id?: string;\n protected canvas: HTMLCanvasElement;\n protected html: HTMLElement;\n protected root?: HTMLDivElement;\n protected attached: boolean;\n /**\n * Indicates whether the HTML layer is currently active based on camera scale.\n * When false, the HTML layer is hidden and won't receive updates.\n */\n protected htmlActive: boolean;\n /**\n * AbortController used to manage event listeners.\n * All event listeners (both graph.on and DOM addEventListener) are registered with this controller's signal.\n * When the layer is unmounted, the controller is aborted, which automatically removes all event listeners.\n */\n protected eventAbortController: AbortController;\n /**\n * A wrapper for this.props.graph.on that automatically includes the AbortController signal.\n * The method is named onGraphEvent to indicate it's specifically for graph events.\n * This simplifies event subscription and ensures proper cleanup when the layer is unmounted.\n *\n * IMPORTANT: Always use this method in the afterInit() method, NOT in the constructor.\n * This ensures that event subscriptions are properly set up when the layer is reattached.\n * When a layer is unmounted, the AbortController is aborted and a new one is created.\n * When the layer is reattached, afterInit() is called again, which sets up new subscriptions\n * with the new AbortController.\n *\n * @param eventName - The name of the event to subscribe to\n * @param handler - The event handler function\n * @param options - Additional options (optional)\n * @returns The result of graph.on call (an unsubscribe function)\n */\n protected onGraphEvent(eventName: EventName, handler: Cb, options?: Omit): () => void;\n hide(): void;\n isHidden(): boolean;\n show(): void;\n /**\n * A wrapper for HTMLElement.addEventListener that automatically includes the AbortController signal.\n * This method is for adding event listeners to the HTML element of the layer.\n * It simplifies event subscription and ensures proper cleanup when the layer is unmounted.\n *\n * IMPORTANT: Always use this method in the afterInit() method, NOT in the constructor.\n * This ensures that event subscriptions are properly set up when the layer is reattached.\n * When a layer is unmounted, the AbortController is aborted and a new one is created.\n * When the layer is reattached, afterInit() is called again, which sets up new subscriptions\n * with the new AbortController.\n *\n * @param eventName - The name of the DOM event to subscribe to\n * @param handler - The event handler function\n * @param options - Additional options (optional)\n */\n protected onHtmlEvent(eventName: K, handler: ((this: HTMLElement, ev: HTMLElementEventMap[K]) => void) | EventListenerObject, options?: Omit): void;\n /**\n * A wrapper for HTMLCanvasElement.addEventListener that automatically includes the AbortController signal.\n * This method is for adding event listeners to the canvas element of the layer.\n * It simplifies event subscription and ensures proper cleanup when the layer is unmounted.\n *\n * IMPORTANT: Always use this method in the afterInit() method, NOT in the constructor.\n * This ensures that event subscriptions are properly set up when the layer is reattached.\n * When a layer is unmounted, the AbortController is aborted and a new one is created.\n * When the layer is reattached, afterInit() is called again, which sets up new subscriptions\n * with the new AbortController.\n *\n * @param eventName - The name of the DOM event to subscribe to\n * @param handler - The event handler function\n * @param options - Additional options (optional)\n */\n protected onCanvasEvent(eventName: K, handler: ((this: HTMLCanvasElement, ev: HTMLElementEventMap[K]) => void) | EventListenerObject, options?: Omit): void;\n /**\n * A wrapper for HTMLElement.addEventListener that automatically includes the AbortController signal.\n * This method is for adding event listeners to the root element of the layer.\n * It simplifies event subscription and ensures proper cleanup when the layer is unmounted.\n *\n * IMPORTANT: Always use this method in the afterInit() method, NOT in the constructor.\n * This ensures that event subscriptions are properly set up when the layer is reattached.\n * When a layer is unmounted, the AbortController is aborted and a new one is created.\n * When the layer is reattached, afterInit() is called again, which sets up new subscriptions\n * with the new AbortController.\n *\n * @param eventName - The name of the DOM event to subscribe to\n * @param handler - The event handler function\n * @param options - Additional options (optional)\n */\n protected onRootEvent(eventName: K, handler: ((this: HTMLElement, ev: HTMLElementEventMap[K]) => void) | EventListenerObject, options?: Omit): void;\n /**\n * Subscribes to a signal (with .subscribe) and automatically unsubscribes when the layer's AbortController is aborted.\n *\n * Usage:\n * this.onSignal(signal, handler)\n *\n * @template S - Signal type (must have .subscribe method)\n * @template T - Value type of the signal\n * @param signal - Signal with .subscribe method (returns unsubscribe function)\n * @param handler - Handler function to call on signal change\n * @returns The unsubscribe function (called automatically on abort)\n */\n protected onSignal void) => () => void;\n }, T = S extends {\n subscribe: (handler: (value: infer U) => void) => () => void;\n } ? U : unknown>(signal: S, handler: (value: T) => void): () => void;\n constructor(props: Props, parent?: CoreComponent);\n protected sizeTouched: boolean;\n updateSize: () => void;\n /**\n * Called after initialization and when the layer is reattached.\n * This is the proper place to set up event subscriptions using onGraphEvent().\n *\n * When a layer is unmounted, the AbortController is aborted and a new one is created.\n * When the layer is reattached, this method is called again, which sets up new subscriptions\n * with the new AbortController.\n *\n * All derived Layer classes should call super.afterInit() at the end of their afterInit method.\n */\n protected afterInit(): void;\n private readonly stopCameraMoving;\n protected moving: boolean;\n protected scheduleCameraChange(camera: TCameraState): void;\n private handleCommittedCameraChange;\n private applyCameraTransform;\n /**\n * Called when committed camera state changes (`graph.$camera` signal).\n * Override in derived layers to react to pan, zoom, and resize.\n *\n * Built-in HTML/canvas transforms (`transformByCameraPosition`, `activationScale`)\n * are applied before this hook via `applyCameraTransform`.\n *\n * @param camera - Committed camera state (same as `graph.$camera.value`)\n */\n protected onCameraChange(_camera: TCameraState): void;\n /**\n * Called when the HTML layer's active state changes based on camera scale.\n * Override this method to implement custom behavior when the layer activates/deactivates.\n *\n * @param active - Whether the HTML layer is now active\n */\n protected onHtmlActiveChange(active: boolean): void;\n /**\n * Returns whether the HTML layer is currently active.\n * The layer is inactive when camera scale is below the activationScale threshold.\n */\n isHtmlActive(): boolean;\n protected init(): void;\n protected unmountLayer(): void;\n protected unmount(): void;\n getCanvas(): HTMLCanvasElement;\n getHTML(): HTMLElement;\n attachLayer(root: HTMLElement): void;\n detachLayer(): void;\n protected createCanvas(params: LayerProps[\"canvas\"]): HTMLCanvasElement;\n protected createHTML(params: LayerProps[\"html\"]): HTMLDivElement;\n getDRP(): number;\n protected applyTransform(x: number, y: number, scale: number, respectPixelRatio?: boolean): void;\n protected updateCanvasSize(): void;\n resetTransform(): void;\n protected render(): void;\n}" + } + ] + }, + { + "name": "LayerConfig", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type LayerConfig = Constructor> = [T, LayerPublicProps];" + } + ] + }, + { + "name": "LayerConstructor", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type LayerConstructor> = T extends Constructor ? T : never;" + } + ] + }, + { + "name": "LayerContext", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type LayerContext = {\n graph: Graph;\n camera: ICamera;\n constants: TGraphConstants;\n colors: TGraphColors;\n graphCanvas: HTMLCanvasElement;\n ctx: CanvasRenderingContext2D;\n layer: Layer;\n};" + } + ] + }, + { + "name": "LayeredLayoutInput", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type LayeredLayoutInput = {\n blocks: Array<{\n id: TConnectionId;\n width: number;\n height: number;\n level?: number;\n }>;\n connections: Array<{\n id?: TConnectionId;\n sourceBlockId: TConnectionId;\n targetBlockId: TConnectionId;\n }>;\n};" + } + ] + }, + { + "name": "LayeredLayoutOptions", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type LayeredLayoutOptions = {\n /** Horizontal gap between nodes in the same layer. Default: defaultNodeWidth * 2 */\n nodeHorizontalGap?: number;\n /** Vertical gap between nodes in adjacent layers. Default: 200 */\n nodeVerticalGap?: number;\n /** Default node width when not provided. Default: 100 */\n defaultNodeWidth?: number;\n /** Default node height when not provided. Default: 100 */\n defaultNodeHeight?: number;\n /** Multiplier for spacing between layers (columns). Default: 1.7 */\n layerSpacingFactor?: number;\n /**\n * Node count above which \"fast\" ordering is used (transpose skipped). Default: 700\n * @internal\n */\n enormousGraphNodeThreshold?: number;\n /**\n * Edge count above which \"fast\" ordering is used. Default: 3000\n * @internal\n */\n enormousGraphEdgeThreshold?: number;\n};" + } + ] + }, + { + "name": "LayerProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type LayerProps = {\n canvas?: LayerPropsElementProps & {\n respectPixelRatio?: boolean;\n alpha?: boolean;\n desynchronized?: boolean;\n willReadFrequently?: boolean;\n };\n html?: LayerPropsHtmlElementProps;\n root?: HTMLElement;\n camera: ICamera;\n graph: Graph;\n};" + } + ] + }, + { + "name": "LayerPropsElementProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type LayerPropsElementProps = {\n zIndex: number;\n classNames?: string[];\n root?: HTMLElement;\n transformByCameraPosition?: boolean;\n};" + } + ] + }, + { + "name": "LayerPropsHtmlElementProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type LayerPropsHtmlElementProps = LayerPropsElementProps & {\n /**\n * Minimum camera scale at which the HTML layer becomes active.\n * When camera scale is below this value, the HTML layer is disabled\n * and won't receive updates (improving performance when zoomed out).\n *\n * @example\n * ```typescript\n * // HTML layer only active when zoomed in (scale >= 0.5)\n * html: { zIndex: 1, activationScale: 0.5 }\n * ```\n */\n activationScale?: number;\n};" + } + ] + }, + { + "name": "LayerPublicProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type LayerPublicProps> = T extends Constructor> ? Omit & {\n root?: Props[\"root\"];\n} : never;" + } + ] + }, + { + "name": "layoutGraph", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function layoutGraph = Node>({ nodes, edges, options, }: LayoutGraphParams): Promise<{\n nodes: T[];\n edges: Edge[];\n}>;" + } + ] + }, + { + "name": "LayoutGraphParams", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type LayoutGraphParams> = {\n nodes: T[];\n edges: Edge[];\n options?: LayeredLayoutOptions;\n};" + } + ] + }, + { + "name": "layoutText", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function layoutText(text: string, ctx: CanvasRenderingContext2D, rect: TTExtRect, params: TMeasureTextOptions): {\n measures: TWrapText;\n lines: any[];\n lineHeight: number;\n};" + } + ] + }, + { + "name": "mapCSSChangesToGraphColors", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function mapCSSChangesToGraphColors(changes: CSSVariableChange[]): RecursivePartial;" + } + ] + }, + { + "name": "mapCSSChangesToGraphConstants", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function mapCSSChangesToGraphConstants(changes: CSSVariableChange[]): RecursivePartial;" + } + ] + }, + { + "name": "mapGraphColorsToCSSVariables", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function mapGraphColorsToCSSVariables(colors: TGraphColors): Record;" + } + ] + }, + { + "name": "mapGraphConstantsToCSSVariables", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function mapGraphConstantsToCSSVariables(constants: TGraphConstants): Record;" + } + ] + }, + { + "name": "MapToGroupsFn", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type MapToGroupsFn = (key: string, params: {\n blocks: BlockState[];\n rect: TRect;\n}) => TGroup;" + } + ] + }, + { + "name": "MiniMapLayer", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class MiniMapLayer extends Layer {\n context: Omit;\n private minimapWidth;\n private minimapHeight;\n private relativeX;\n private relativeY;\n private scale;\n private cameraBorderSize;\n private cameraBorderColor;\n constructor(props: MiniMapLayerProps);\n protected afterInit(): void;\n protected onCameraChange(_camera: TCameraState): void;\n protected updateCanvasSize(): void;\n protected willRender(): void;\n protected render(): void;\n private injectPositionStyle;\n private calculateViewPortCoords;\n private drawCameraBorderFrame;\n protected getPositionOfMiniMap(location: TMiniMapLocation): Pick;\n private renderUsableRectBelow;\n private renderBlocks;\n private onCameraDrag;\n private handleMouseDownEvent;\n}" + } + ] + }, + { + "name": "MiniMapLayerContext", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type MiniMapLayerContext = LayerContext & {\n canvas: HTMLCanvasElement;\n ctx: CanvasRenderingContext2D;\n};" + } + ] + }, + { + "name": "MiniMapLayerProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type MiniMapLayerProps = LayerProps & {\n width?: number;\n height?: number;\n classNames?: string[];\n cameraBorderSize?: number;\n cameraBorderColor?: string;\n location?: TMiniMapLocation;\n};" + } + ] + }, + { + "name": "MultipleSelectionBucket", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class MultipleSelectionBucket extends BaseSelectionBucket {\n updateSelection(ids: IDType[], select: boolean, strategy?: ESelectionStrategy, silent?: boolean): void;\n}" + } + ] + }, + { + "name": "MultipointConnection", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class MultipointConnection extends BlockConnection {\n private labelsGeometry;\n createPath(): Path2D;\n createArrowPath(): Path2D;\n styleArrow(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult;\n getPoints(): import(\"../../../utils/types/shapes\").TPoint[];\n afterRender?(ctx: CanvasRenderingContext2D): void;\n updatePoints(): void;\n getBBox(): readonly [sourceX: number, sourceY: number, targetX: number, targetY: number];\n protected getHitBoxRect(): TRect;\n onHitBox(shape: HitBoxData): boolean;\n private renderLabelsText;\n}" + } + ] + }, + { + "name": "NewBlockLayer", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class NewBlockLayer extends Layer {\n private copyBlocks;\n private initialPoint;\n private blockStates;\n private enabled;\n private eventAborter;\n constructor(props: NewBlockLayerProps);\n /**\n * Called after initialization and when the layer is reattached.\n * This is where we set up event subscriptions to ensure they work properly\n * after the layer is unmounted and reattached.\n */\n protected afterInit(): void;\n protected handleMouseDown: (nativeEvent: GraphMouseEvent) => void;\n protected render(): void;\n private onStartNewBlock;\n private lastMouseX;\n private lastMouseY;\n private onMoveNewBlock;\n private onEndNewBlock;\n enable(): void;\n disable(): void;\n isEnabled(): boolean;\n}" + } + ] + }, + { + "name": "NewBlockLayerProps", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface NewBlockLayerProps extends LayerProps {\n /**\n * Background color for displaying the block shadow\n * By default, the block border color from the theme is used\n */\n ghostBackground?: string;\n /**\n * Function to check if block duplication is allowed\n * @param block The block being duplicated\n * @returns true if duplication is allowed, false if not allowed\n */\n isDuplicateAllowed?: (block: Block) => boolean;\n}" + } + ] + }, + { + "name": "Node", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type Node = {\n id: NodeId;\n level: number;\n x?: number;\n y?: number;\n width?: number;\n height?: number;\n shape?: string;\n};" + } + ] + }, + { + "name": "OnBlockGroupChange", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type OnBlockGroupChange = (changes: TBlockGroupsTransferGroupChange[]) => void;" + } + ] + }, + { + "name": "OnTransferEnd", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type OnTransferEnd = (blockIds: TBlock[\"id\"][], targetGroupId: string | null) => void;" + } + ] + }, + { + "name": "OnTransferStart", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type OnTransferStart = (blockIds: TBlock[\"id\"][], sourceGroupIds: Set) => void;" + } + ] + }, + { + "name": "Path2DRenderInstance", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface Path2DRenderInstance {\n getPath(): Path2D | undefined | null;\n style(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined;\n afterRender?(ctx: CanvasRenderingContext2D): void;\n isPathVisible?(): boolean;\n}" + } + ] + }, + { + "name": "Path2DRenderStyleResult", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type Path2DRenderStyleResult = {\n type: \"stroke\";\n} | {\n type: \"fill\";\n fillRule?: CanvasFillRule;\n} | {\n type: \"both\";\n fillRule?: CanvasFillRule;\n};" + } + ] + }, + { + "name": "polyline", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function polyline(points: {\n x: number;\n y: number;\n}[]): Path2D;" + } + ] + }, + { + "name": "PortConnectionLayer", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class PortConnectionLayer extends Layer {\n /**\n * Unique key for port metadata\n * Using a symbol prevents conflicts with other layers\n */\n static readonly PortMetaKey: unique symbol;\n private startState;\n private endState;\n private sourcePort?;\n private targetPort?;\n private snappingPortsTree;\n private isSnappingTreeOutdated;\n private portsUnsubscribe?;\n protected enabled: boolean;\n constructor(props: PortConnectionLayerProps);\n protected afterInit(): void;\n protected onCameraChange(_camera: TCameraState): void;\n enable: () => void;\n disable: () => void;\n protected currentListener: Emitter | null;\n private isSnappablePort;\n protected handleMouseDown: (nativeEvent: GraphMouseEvent) => void;\n protected renderEndpoint(ctx: CanvasRenderingContext2D): void;\n protected render(): void;\n private onStartConnection;\n private onMoveNewConnection;\n protected selectPort(port: PortState, select: boolean): void;\n protected cancelNewConnection(): void;\n private onEndNewConnection;\n private findNearestSnappingPort;\n /**\n * Rebuild the RBush spatial index for snapping ports\n * Optimization: Only includes ports from components visible in viewport + padding\n */\n private rebuildSnappingTree;\n /**\n * Determine the port type (IN or OUT)\n * @param port Port to check\n * @returns EAnchorType.IN, EAnchorType.OUT, or null if the port is a block point (no specific direction)\n */\n private getPortType;\n /**\n * Get full event parameters from a port\n * Includes both legacy parameters (blockId, anchorId) and new port reference\n */\n protected getEventParams(port?: PortState): {\n blockId?: TBlockId;\n anchorId?: string;\n };\n unmount(): void;\n}" + } + ] + }, + { + "name": "renderText", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function renderText(text: string, ctx: CanvasRenderingContext2D, rect: TTExtRect, params: TMeasureTextOptions): TWrapText;" + } + ] + }, + { + "name": "RootStore", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class RootStore {\n blocksList: BlockListStore;\n configurationName: string;\n connectionsList: ConnectionsStore;\n settings: GraphEditorSettings;\n groupsList: GroupsListStore;\n selectionService: SelectionService;\n constructor(graph: Graph);\n getAsConfig(): TGraphConfig;\n reset(): void;\n}" + } + ] + }, + { + "name": "schedule", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "schedule: (fn: Function, options: TScheduleOptions) => () => void" + } + ] + }, + { + "name": "SelectionEvent", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type SelectionEvent = CustomEvent>;" + } + ] + }, + { + "name": "SingleSelectionBucket", + "declarations": [ + { + "kind": "ClassDeclaration", + "text": "export declare class SingleSelectionBucket extends BaseSelectionBucket {\n updateSelection(ids: IDType[], select: boolean, strategy: ESelectionStrategy, silent?: boolean): void;\n}" + } + ] + }, + { + "name": "SUPPORTED_CSS_VARIABLES", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "SUPPORTED_CSS_VARIABLES: Set" + } + ] + }, + { + "name": "TAnchor", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TAnchor = {\n id: string;\n blockId: TBlockId;\n type: EAnchorType | string;\n index?: number;\n};" + } + ] + }, + { + "name": "TAnchorProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TAnchorProps = TGraphComponentProps & TAnchor & {\n size: number;\n lineWidth: number;\n zIndex: number;\n port: PortState;\n};" + } + ] + }, + { + "name": "TBaseConnectionProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TBaseConnectionProps = {\n /** Unique identifier for the connection */\n id: TConnectionId;\n};" + } + ] + }, + { + "name": "TBaseConnectionState", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TBaseConnectionState = TComponentState & TConnection & {\n /** Whether the connection is currently being hovered */\n hovered?: boolean;\n};" + } + ] + }, + { + "name": "TBlock", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TBlock = {}> = {\n id: TBlockId;\n is: string;\n x: number;\n y: number;\n group?: string;\n width: number;\n height: number;\n selected?: boolean;\n name: string;\n anchors?: TAnchor[];\n settings?: TBlockSettings;\n meta?: T;\n};" + } + ] + }, + { + "name": "TBlockConnection", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TBlockConnection = {\n id: string;\n addInRenderOrder(cmp: any, setting: object): void;\n removeFromRenderOrder(cmp: any): void;\n};" + } + ] + }, + { + "name": "TBlockGeometrySnapshot", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TBlockGeometrySnapshot = {\n id: TBlockId;\n x: number;\n y: number;\n width: number;\n height: number;\n};" + } + ] + }, + { + "name": "TBlockGroupsTransferGroupChange", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TBlockGroupsTransferGroupChange = {\n blockId: TBlock[\"id\"];\n sourceGroup?: string | null;\n targetGroup?: string | null;\n};" + } + ] + }, + { + "name": "TBlockId", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TBlockId = string | number;" + } + ] + }, + { + "name": "TCollapseDirection", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TCollapseDirection = \"start\" | \"center\" | \"end\";" + } + ] + }, + { + "name": "TCollapsibleGroup", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface TCollapsibleGroup extends TGroup {\n /** Whether this group is currently collapsed */\n collapsed?: boolean;\n /**\n * The visual rect used when collapsed. When set, the group renders and\n * responds to hit-tests using this rect instead of the normal `rect`.\n *\n * `rect` itself continues to be managed by `withBlockGrouping` (or\n * manually) and always reflects the real block bounding box.\n */\n collapsedRect?: TRect;\n /**\n * Where the collapsed header appears relative to the full group rect.\n *\n * - `x`: `\"start\"` → left edge | `\"center\"` → centered | `\"end\"` → right edge\n * - `y`: `\"start\"` → top edge | `\"center\"` → centered | `\"end\"` → bottom edge\n *\n * Defaults to `{ x: \"start\", y: \"start\" }` (top-left corner).\n *\n * Only used by the default collapse rect computation. Ignored when\n * {@link getCollapseRect} is provided.\n */\n collapseDirection?: {\n x?: TCollapseDirection;\n y?: TCollapseDirection;\n };\n /**\n * User-defined function to compute the collapsed rect from the expanded rect.\n *\n * When not provided, a default implementation computes a\n * {@link DEFAULT_COLLAPSED_WIDTH}×{@link DEFAULT_COLLAPSED_HEIGHT} rect\n * pinned at the position determined by {@link collapseDirection}.\n *\n * @example\n * ```typescript\n * const group: TCollapsibleGroup = {\n * id: \"my-group\",\n * rect: { x: 0, y: 0, width: 400, height: 300 },\n * component: CollapsibleGroup,\n * getCollapseRect: (_group, rect) => ({\n * x: rect.x + rect.width / 2 - 100,\n * y: rect.y,\n * width: 200,\n * height: 48,\n * }),\n * };\n * ```\n */\n getCollapseRect?: (group: TCollapsibleGroup, expandedRect: TRect) => TRect;\n}" + } + ] + }, + { + "name": "TComponentContext", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TComponentContext = CoreComponentContext;" + } + ] + }, + { + "name": "TComponentProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TComponentProps = CoreComponentProps;" + } + ] + }, + { + "name": "TComponentState", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TComponentState = Record;" + } + ] + }, + { + "name": "TConnection", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TConnection = {\n id?: TConnectionId;\n sourceBlockId?: TBlockId;\n targetBlockId?: TBlockId;\n sourceAnchorId?: string;\n targetAnchorId?: string;\n sourcePortId?: TPortId;\n targetPortId?: TPortId;\n label?: string;\n styles?: Partial & {\n dashes?: number[];\n };\n dashed?: boolean;\n selected?: boolean;\n} & (TConnectionBlockPoint | TConnectionPortPoint);" + } + ] + }, + { + "name": "TConnectionId", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TConnectionId = string | number;" + } + ] + }, + { + "name": "TConnectionProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TConnectionProps = TBaseConnectionProps & {\n useBezier: boolean;\n bezierDirection: \"vertical\" | \"horizontal\";\n showConnectionArrows: boolean;\n showConnectionLabels: boolean;\n};" + } + ] + }, + { + "name": "TDefinitionGroup", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TDefinitionGroup = Omit & {\n blocksIds: BlockState[\"id\"][];\n};" + } + ] + }, + { + "name": "TGetCameraBlockScaleLevel", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TGetCameraBlockScaleLevel = (graph: Graph, scale: number) => ECameraScaleLevel;" + } + ] + }, + { + "name": "TGraphColors", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TGraphColors = {\n canvas?: Partial;\n block?: Partial;\n anchor?: Partial;\n connection?: Partial;\n connectionLabel?: Partial;\n selection?: Partial;\n};" + } + ] + }, + { + "name": "TGraphConfig", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TGraphConfig = {\n configurationName?: string;\n blocks?: Block[];\n connections?: TConnection[];\n /**\n * @deprecated use Graph.zoom api\n */\n rect?: TRect;\n /**\n * @deprecated use Graph.zoom api\n * */\n cameraXY?: TPoint;\n /**\n * @deprecated use Graph.zoom api\n * */\n cameraScale?: number;\n settings?: Partial>;\n layers?: LayerConfig[];\n};" + } + ] + }, + { + "name": "TGraphConstants", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TGraphConstants = {\n /**\n * Configuration for the selection layer behavior.\n * The selection layer is responsible for rendering the selection rectangle\n * and managing which entities can be selected when the user draws a selection box on the canvas.\n */\n selectionLayer: {\n /**\n * List of entity types that can be selected via the selection rectangle.\n *\n * Only entities whose constructors are included in this array will be selectable\n * when the user draws a selection rectangle on the canvas. This allows fine-grained\n * control over which types of graph components can be multi-selected.\n *\n * @remarks\n * - By default, only `Block` entities are selectable\n * - You can extend this to include other entity types like connections, anchors, etc.\n * - Each entry must be a constructor (class) that extends `GraphComponent`\n *\n * @example\n * ```typescript\n * // Allow selecting both blocks and connections\n * selectionLayer: {\n * SELECTABLE_ENTITY_TYPES: [GraphComponent, Block, Connection]\n * }\n * ```\n *\n * @default [Block]\n */\n SELECTABLE_ENTITY_TYPES: GraphComponentConstructor[];\n /**\n * Selection strategy that determines how newly selected entities interact with existing selection.\n *\n * Available strategies:\n * - **`REPLACE`** - New selection replaces the current selection entirely\n * - **`APPEND`** - New selection is added to the current selection\n * - **`SUBTRACT`** - New selection is removed from the current selection\n * - **`TOGGLE`** - New selection toggles the selection state of entities\n *\n * @remarks\n * This strategy is applied when the user completes drawing a selection rectangle\n * and determines how the entities inside the rectangle affect the overall selection state.\n *\n * @example\n * ```typescript\n * // Additive selection mode\n * selectionLayer: {\n * STRATEGY: ESelectionStrategy.APPEND\n * }\n * ```\n *\n * @default ESelectionStrategy.REPLACE\n */\n STRATEGY?: ESelectionStrategy;\n /**\n * Selection strategy that determines how newly selected entities interact with existing selection when Shift key is pressed.\n *\n * Available strategies:\n * - **`REPLACE`** - New selection replaces the current selection entirely\n * - **`APPEND`** - New selection is added to the current selection\n * - **`SUBTRACT`** - New selection is removed from the current selection\n * - **`TOGGLE`** - New selection toggles the selection state of entities\n *\n * @default ESelectionStrategy.APPEND\n */\n SHIFT_STRATEGY?: ESelectionStrategy;\n };\n system: {\n GRID_SIZE: number;\n /**\n * @deprecated this config is not used anymore, Layers checks devicePixelRatio internally\n */\n PIXEL_RATIO: number;\n USABLE_RECT_GAP: number;\n /** For preload blocks on the html layer (camera dimensions * (1 + this value)) */\n CAMERA_VIEWPORT_TRESHOLD: number;\n };\n camera: {\n SPEED: number;\n STEP: number;\n /**\n * Auto-panning threshold: distance from edge in pixels to activate auto-panning\n * @default 50\n */\n AUTO_PAN_THRESHOLD: number;\n /**\n * Auto-panning speed: base pixels per frame for camera movement\n * @default 10\n */\n AUTO_PAN_SPEED: number;\n /**\n * Controls the behavior of mouse wheel events.\n *\n * - **\"zoom\"**: Mouse wheel will zoom in/out the graph\n * - **\"scroll\"**: Mouse wheel will scroll the graph vertically by default, or horizontally when Shift is pressed\n *\n * @remarks\n * **Mouse wheel scrolling behavior:**\n * - Default scroll direction is vertical (up/down)\n * - Holding Shift key switches to horizontal scrolling (left/right)\n * - This is an environment-dependent behavior as per W3C UI Events specification\n * - Different browsers and operating systems may handle Shift+wheel differently\n *\n * **Trackpad behavior (via `resolveWheelIntent`):**\n * - Integer PIXEL two-finger swipe resolves to pan (I3) — not affected by this constant\n * - Pinch-to-zoom (Cmd/Ctrl + scroll) resolves to zoom with {@link PINCH_ZOOM_SPEED}\n * - Horizontal / diagonal swipe resolves to pan (I2)\n * - Override ambiguous Mac Chrome input with camera constant `WHEEL_INPUT_DEVICE`\n * - See `docs/system/wheel-intent.md` for classification rules\n *\n * @default \"zoom\"\n * @see https://w3c.github.io/uievents/#events-wheelevents - W3C UI Events Wheel Events specification\n */\n MOUSE_WHEEL_BEHAVIOR: TMouseWheelBehavior;\n /**\n * Explicit primary wheel input device passed to {@link TResolveWheelIntent}.\n * When `\"auto\"`, the resolver infers trackpad vs mouse from gesture shape.\n * Set to `\"trackpad\"` or `\"mouse\"` when the app knows the device (e.g. Mac Chrome\n * where both emit integer PIXEL + linear wheelDelta).\n *\n * @default \"auto\"\n */\n WHEEL_INPUT_DEVICE: TWheelInputDevice;\n /**\n * Multiplier for trackpad pinch-to-zoom gesture speed.\n * Applied when zooming with trackpad using pinch gesture (Cmd/Ctrl + scroll).\n *\n * @default 1\n */\n PINCH_ZOOM_SPEED: number;\n /**\n * Multiplier for camera pan speed applied to trackpad two-finger swipe (and\n * mouse-wheel scroll when MOUSE_WHEEL_BEHAVIOR is \"scroll\"). Pointer drag\n * (mouse drag and single-finger trackpad drag) tracks the cursor 1:1 and\n * is not affected — applying a multiplier there would slide the canvas\n * out from under the cursor. Does not affect auto-panning (see AUTO_PAN_SPEED)\n * or zoom speed (see SPEED, PINCH_ZOOM_SPEED).\n *\n * @default 1\n */\n PAN_SPEED: number;\n };\n block: {\n WIDTH_MIN: number;\n BORDER_WIDTH: number;\n HEAD_HEIGHT: number;\n BODY_PADDING: number;\n SCALES: [number, number, number];\n DEFAULT_Z_INDEX: number;\n INCRIMENT_Z_INDEX: number;\n GHOST_BLOCK_OPACITY: number;\n WIDTH: number;\n HEIGHT: number;\n SNAPPING_GRID_SIZE: number;\n };\n connection: {\n MUTED_CANVAS_CONNECTION_WIDTH: number;\n SCALES: [number, number, number];\n DEFAULT_Z_INDEX: number;\n THRESHOLD_LINE_HIT: number;\n MIN_ZOOM_FOR_CONNECTION_ARROW_AND_LABEL: number;\n /** Size of Path2D chunks for batch rendering */\n PATH2D_CHUNK_SIZE: number;\n LABEL: {\n INNER_PADDINGS: [number, number, number, number];\n };\n };\n text: {\n BASE_FONT_SIZE: number;\n PADDING: number;\n };\n};" + } + ] + }, + { + "name": "TGraphSettingsConfig", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TGraphSettingsConfig = {\n canDragCamera: boolean;\n canZoomCamera: boolean;\n /** @deprecated Use NewBlockLayer parameters instead */\n canDuplicateBlocks?: boolean;\n /** @deprecated Use canDrag instead */\n canChangeBlockGeometry?: ECanChangeBlockGeometry;\n /** Controls which components can be dragged */\n canDrag?: ECanDrag;\n /**\n * Minimum distance in pixels the mouse must move before a drag operation starts.\n * Helps prevent accidental drags during clicks. Default: 3\n */\n dragThreshold?: number;\n /**\n * Controls if connections can be created via anchors\n * If this connection is enabled, then anchors are not draggable and connection creation is handled by ConnectionLayer.\n * */\n canCreateNewConnections: boolean;\n scaleFontSize: number;\n showConnectionArrows: boolean;\n useBezierConnections: boolean;\n bezierConnectionDirection: \"vertical\" | \"horizontal\";\n useBlocksAnchors: boolean;\n connectivityComponentOnClickRaise: boolean;\n showConnectionLabels: boolean;\n blockComponents: Record>;\n connection?: typeof BlockConnection;\n background?: typeof Component;\n /**\n * When enabled, mouseenter/mouseleave events are re-evaluated after each camera change.\n * Useful for trackpads where panning does not trigger native mousemove events,\n * so hovering over elements requires this emulation to work correctly.\n * Default: false\n */\n emulateMouseEventsOnCameraChange?: boolean;\n /**\n * Classifies wheel input as pan or zoom intent so Camera can route without knowing device type.\n * Receives `mouseWheelBehavior` and `wheelInputDevice` from camera constants so wheel policy\n * stays in the input layer, not Camera.\n *\n * Transitional: today Camera calls {@link GraphEditorSettings.wheelIntentFromEvent} from a raw\n * `wheel` listener. A future input layer will normalize DOM events and emit semantic graph events\n * (`camera:pan`, `camera:zoom`, …); this callback is the classification hook until then.\n *\n * @default {@link createWheelIntentResolver} — gesture-shape heuristics (pan vs zoom intent).\n */\n resolveWheelIntent: TResolveWheelIntent;\n /**\n * Maps camera scale to block zoom tier (minimalistic / schematic / detailed).\n * Always set at runtime; `setupSettings` falls back to the exported `defaultGetCameraBlockScaleLevel` when omitted.\n */\n getCameraBlockScaleLevel: TGetCameraBlockScaleLevel;\n};" + } + ] + }, + { + "name": "TGraphZoomTarget", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TGraphZoomTarget = \"center\" | TRect | TBlockId[] | GraphComponent[];" + } + ] + }, + { + "name": "TGroup", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface TGroup {\n id: TGroupId;\n rect: TRect;\n selected?: boolean;\n component?: typeof Group;\n}" + } + ] + }, + { + "name": "TGroupGeometry", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TGroupGeometry = {\n padding: [number, number, number, number];\n};" + } + ] + }, + { + "name": "TGroupId", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TGroupId = string;" + } + ] + }, + { + "name": "TGroupProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TGroupProps = {\n id: TGroupId;\n onDragUpdate: (groupId: string, diff: {\n deltaX: number;\n deltaY: number;\n }) => void;\n style?: Partial;\n geometry?: Partial;\n draggable?: boolean;\n};" + } + ] + }, + { + "name": "TGroupStyle", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TGroupStyle = {\n background: string;\n border: string;\n borderWidth: number;\n selectedBackground: string;\n selectedBorder: string;\n /** Background color when group is highlighted (block is being dragged over it) */\n highlightedBackground: string;\n /** Border color when group is highlighted */\n highlightedBorder: string;\n};" + } + ] + }, + { + "name": "throttle", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "throttle: ) => void>(fn: T, { priority, frameInterval, frameTimeout }?: TDebounceOptions) => T & {\n cancel: () => void;\n flush: () => void;\n}" + } + ] + }, + { + "name": "TLabel", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TLabel = {\n height?: number;\n width?: number;\n x?: number;\n y?: number;\n text?: string;\n};" + } + ] + }, + { + "name": "TMeasureTextOptions", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TMeasureTextOptions = {\n font?: string;\n wordWrap?: boolean;\n maxWidth?: number;\n maxHeight?: number;\n lineHeight?: number;\n};" + } + ] + }, + { + "name": "TMiniMapLocation", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TMiniMapLocation = \"topLeft\" | \"topRight\" | \"bottomLeft\" | \"bottomRight\" | Pick;" + } + ] + }, + { + "name": "TMouseWheelBehavior", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TMouseWheelBehavior = \"zoom\" | \"scroll\";" + } + ] + }, + { + "name": "TMultiEntitySelection", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TMultiEntitySelection = Record;" + } + ] + }, + { + "name": "TMultipointConnection", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TMultipointConnection = TConnection & {\n points?: TPoint[];\n labels?: TLabel[];\n};" + } + ] + }, + { + "name": "TPoint", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TPoint = {\n x: number;\n y: number;\n};" + } + ] + }, + { + "name": "TPort", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TPort = {\n /** Unique identifier for the port */\n id: TPortId;\n /** X coordinate of the port */\n x: number;\n /** Y coordinate of the port */\n y: number;\n /** Component that owns this port (block, anchor, etc.) */\n component?: Component;\n /** Whether the port is waiting for position data from its component */\n lookup?: boolean;\n /** Arbitrary metadata (port doesn't know what's inside) */\n meta?: T;\n};" + } + ] + }, + { + "name": "TPortId", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TPortId = string | number | symbol;" + } + ] + }, + { + "name": "TPortSnapCondition", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TPortSnapCondition = (context: TPortSnapConditionContext) => boolean;" + } + ] + }, + { + "name": "trangleArrowForVector", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function trangleArrowForVector(start: {\n x: number;\n y: number;\n}, end: {\n x: number;\n y: number;\n}, height?: number, baseWidth?: number, t?: number): Path2D;" + } + ] + }, + { + "name": "TRect", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TRect = {\n x: number;\n y: number;\n width: number;\n height: number;\n};" + } + ] + }, + { + "name": "TResolveWheelIntent", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TResolveWheelIntent = (event: WheelEvent, options: TResolveWheelIntentOptions) => EWheelIntent;" + } + ] + }, + { + "name": "TResolveWheelIntentOptions", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TResolveWheelIntentOptions = {\n mouseWheelBehavior: TMouseWheelBehavior;\n wheelInputDevice?: TWheelInputDevice;\n};" + } + ] + }, + { + "name": "TSelectionDiff", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TSelectionDiff = {\n /** List of next selection state */\n list: IDType[];\n /** Details of changes */\n changes: {\n add: IDType[];\n removed: IDType[];\n };\n};" + } + ] + }, + { + "name": "TSelectionEntity", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TSelectionEntity = GraphComponent | IEntityWithComponent | object;" + } + ] + }, + { + "name": "TSelectionEntityId", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TSelectionEntityId = string | number;" + } + ] + }, + { + "name": "TTExtRect", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TTExtRect = Omit & Partial>;" + } + ] + }, + { + "name": "TWheelInputDevice", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TWheelInputDevice = \"auto\" | \"mouse\" | \"trackpad\";" + } + ] + }, + { + "name": "TWheelIntentDebugEntry", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TWheelIntentDebugEntry = {\n /** {@link TResolveWheelIntentOptions.mouseWheelBehavior}. */\n mouseWheelBehavior: TMouseWheelBehavior;\n /** {@link TResolveWheelIntentOptions.wheelInputDevice} (defaults to `\"auto\"`). */\n inputDevice: TWheelInputDevice;\n /** Raw {@link WheelEvent} fields passed into the resolver. */\n input: {\n deltaX: number;\n deltaY: number;\n deltaMode: number;\n deltaModeLabel: string;\n ctrlKey: boolean;\n metaKey: boolean;\n shiftKey: boolean;\n altKey: boolean;\n };\n /** Pixel-equivalent deltas after {@link normalizeWheelDelta}. */\n normalized: {\n deltaX: number;\n deltaY: number;\n /** min(|x|, |y|) / max(|x|, |y|); null when both axes are ~0. */\n diagonalAxisRatio: number | null;\n };\n /** Timing and resolver session state at classification time. */\n session: {\n timeSinceLastMs: number;\n isRapidStream: boolean;\n isInMouseWheelBurst: boolean;\n mouseWheelBurstRemainingMs: number | null;\n lastIntentBefore: EWheelIntent;\n };\n /** Boolean predicates evaluated for I1–I5 (see `docs/system/wheel-intent.md`). */\n signals: {\n isPinchZoom: boolean;\n isDiagonalScroll: boolean;\n isPredominantHorizontalScroll: boolean;\n isClassicMouseWheelStep: boolean;\n isDominantAxisLargeWheel: boolean;\n isVerticalOnly: boolean;\n hasFractionalDelta: boolean;\n isSmallDelta: boolean;\n /** Trackpads always emit `deltaMode === DOM_DELTA_PIXEL` (0); mice use LINE/PAGE or PIXEL. */\n isPixelDeltaMode: boolean;\n /** Deprecated `wheelDelta(Y)` ≈ ±120 on Chromium mechanical mouse wheels (Mac Chrome 3× ratio excluded). */\n hasLegacyMouseWheelDelta: boolean;\n };\n /** Winning rule id and resolved intent. */\n rule: TWheelIntentRule;\n result: EWheelIntent;\n};" + } + ] + }, + { + "name": "TWheelIntentRule", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TWheelIntentRule = (typeof WHEEL_INTENT_RULE)[keyof typeof WHEEL_INTENT_RULE];" + } + ] + }, + { + "name": "TWrapText", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TWrapText = {\n width: number;\n height: number;\n lineHeight: number;\n linesWords: string[];\n linesWidths: number[];\n};" + } + ] + }, + { + "name": "UnwrapGraphEventsDetail", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type UnwrapGraphEventsDetail[0] = Parameters[0]> = UnwrapGraphEvents[\"detail\"];" + } + ] + }, + { + "name": "useLayeredLayout", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useLayeredLayout(params: UseLayeredLayoutParams): {\n result: ConverterResult;\n isLoading: boolean;\n};" + } + ] + }, + { + "name": "UseLayeredLayoutParams", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type UseLayeredLayoutParams = LayeredLayoutInput & {\n /** Layout algorithm options (gaps, default sizes, spacing factor, etc.) */\n layoutOptions?: LayeredLayoutOptions;\n onError?: (e: Error) => void;\n};" + } + ] + }, + { + "name": "WHEEL_INTENT_RULE", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "WHEEL_INTENT_RULE: {\n readonly I1_PINCH: \"I1:pinch\";\n readonly I2_HORIZONTAL_OR_DIAGONAL: \"I2:horizontal-or-diagonal\";\n readonly I3_INPUT_DEVICE_TRACKPAD: \"I3:input-device-trackpad\";\n readonly I3_INTEGER_TRACKPAD: \"I3:integer-trackpad\";\n readonly I3_INTEGER_TRACKPAD_SLOW: \"I3:integer-trackpad-slow\";\n readonly I3_RAPID_SMALL: \"I3:rapid-small\";\n readonly I4_MOUSE_WHEEL_STEP: \"I4:mouse-wheel-step\";\n readonly I4_LARGE_STEP: \"I4:large-step\";\n readonly I4_FRACTIONAL_MOUSE: \"I4:fractional-mouse\";\n readonly I4_BURST_SMOOTHING: \"I4-burst:smoothing\";\n readonly I4_INPUT_DEVICE_MOUSE: \"I4:input-device-mouse\";\n readonly I5_LAST_INTENT: \"I5:last-intent\";\n readonly I5_STICKY_STREAM: \"I5:sticky-stream\";\n}" + } + ] + } + ], + "./react": [ + { + "name": "GraphBlock", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "GraphBlock: (props: TGraphBlockProps & {\n ref?: React.Ref;\n}) => React.ReactElement | null" + } + ] + }, + { + "name": "GraphBlockAnchor", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function GraphBlockAnchor({ graph, anchor, position, children, className, }: {\n graph: Graph;\n anchor: TAnchor;\n position: \"absolute\" | \"fixed\";\n className?: string;\n children?: React.ReactNode | ((anchorState: AnchorState) => React.ReactNode);\n}): React.JSX.Element;" + } + ] + }, + { + "name": "GraphCallbacksMap", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "GraphCallbacksMap: {\n readonly click: \"click\";\n readonly dblclick: \"dblclick\";\n readonly onCameraChange: \"camera-change\";\n readonly onBlockDragStart: \"block-drag-start\";\n readonly onBlockDrag: \"block-drag\";\n readonly onBlockDragEnd: \"block-drag-end\";\n readonly onBlockSelectionChange: \"blocks-selection-change\";\n readonly onBlockAnchorSelectionChange: \"block-anchor-selection-change\";\n readonly onBlockChange: \"block-change\";\n readonly onBlocksGeometryChange: \"blocks-geometry-change\";\n readonly onConnectionSelectionChange: \"connection-selection-change\";\n readonly onStateChanged: \"state-change\";\n}" + } + ] + }, + { + "name": "GraphCanvas", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function GraphCanvas({ graph, className, blockListClassName, renderBlock, reactLayerRef, children, ...cbs }: GraphProps): React.JSX.Element;" + } + ] + }, + { + "name": "GraphContext", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "GraphContext: React.Context" + } + ] + }, + { + "name": "GraphContextProvider", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function GraphContextProvider({ graph, children }: GraphContextProviderProps): React.JSX.Element;" + } + ] + }, + { + "name": "GraphContextProviderProps", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface GraphContextProviderProps {\n graph: Graph;\n children: React.ReactNode;\n}" + } + ] + }, + { + "name": "GraphContextType", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface GraphContextType {\n graph: Graph;\n}" + } + ] + }, + { + "name": "GraphEvent", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type GraphEvent = Parameters[0];" + } + ] + }, + { + "name": "GraphEventDetail", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type GraphEventDetail = Parameters[0];" + } + ] + }, + { + "name": "GraphLayer", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "GraphLayer: React.ForwardRefExoticComponent>>, \"ref\"> & React.RefAttributes>>" + } + ] + }, + { + "name": "GraphLayerProps", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface GraphLayerProps = Constructor> {\n /**\n * Layer constructor class\n */\n layer: TLayer;\n /**\n * Props to pass to layer constructor\n */\n props?: LayerPublicProps;\n /**\n * Ref to access layer instance\n */\n ref?: React.Ref>;\n}" + } + ] + }, + { + "name": "GraphPortal", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "GraphPortal: React.ForwardRefExoticComponent>" + } + ] + }, + { + "name": "GraphPortalLayerProps", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface GraphPortalLayerProps extends LayerProps {\n /**\n * Additional CSS classes for HTML element\n */\n className?: string;\n /**\n * Layer position on Z axis\n */\n zIndex?: number;\n /**\n * Whether HTML element should follow camera position\n */\n transformByCameraPosition?: boolean;\n}" + } + ] + }, + { + "name": "GraphPortalProps", + "declarations": [ + { + "kind": "InterfaceDeclaration", + "text": "export interface GraphPortalProps {\n /**\n * Additional CSS classes for layer\n */\n className?: string;\n /**\n * Layer position on Z axis\n */\n zIndex?: number;\n /**\n * Whether HTML element should follow camera position\n * @default false\n */\n transformByCameraPosition?: boolean;\n /**\n * Function for rendering portal content\n */\n children: React.ReactNode | ((layer: GraphPortalLayer, graph: Graph) => React.ReactNode);\n}" + } + ] + }, + { + "name": "GraphProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type GraphProps = Pick, \"renderBlock\"> & Partial & {\n className?: string;\n blockListClassName?: string;\n graph: Graph;\n reactLayerRef?: React.MutableRefObject;\n children?: React.ReactNode;\n};" + } + ] + }, + { + "name": "HookGraphParams", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type HookGraphParams = Pick & {\n graph?: Graph;\n name?: string;\n viewConfiguration?: {\n colors?: RecursivePartial;\n constants?: RecursivePartial;\n };\n};" + } + ] + }, + { + "name": "MultipointConnection", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "MultipointConnection: typeof CanvasMultipointConnection" + } + ] + }, + { + "name": "TGraphBlockProps", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TGraphBlockProps = {\n /**\n * Graph instance\n */\n graph: Graph;\n /**\n * Block instance\n */\n block: T;\n /**\n * Class name for wrapper div\n * * The difference between the \"containerClassName\" and \"className\" props:\n * - containerClassName is applied to the container div\n * - className is applied to the wrapper div\n * ```jsx\n * // container for position and size\n *

\n * // wrapper for block content\n *
\n * {children}\n *
\n *
\n * ```\n */\n className?: string;\n /**\n * Class name for container div\n * * The difference between the \"containerClassName\" and \"className\" props:\n * - containerClassName is applied to the container div\n * - className is applied to the wrapper div\n * ```jsx\n * // container for position and size\n *
\n * // wrapper for block content\n *
\n * {children}\n *
\n *
\n * ```\n */\n containerClassName?: string;\n /**\n * Flag to hide canvas block automatically when react will render the block.\n * If you want to manage canvas block visibility manually, you can set `autoHideCanvas` to `false`, and pass `canvasVisible` prop to control it.\n */\n autoHideCanvas?: boolean;\n /**\n * Flag to manage visibility of canvas block.\n * If you want to manage visibility of canvas block manually, you can set `autoHideCanvas` to `false`, and pass `canvasVisible` prop to control it.\n */\n canvasVisible?: boolean;\n children: React.ReactNode;\n};" + } + ] + }, + { + "name": "TGraphEventCallbacks", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TGraphEventCallbacks = {\n click: (data: UnwrapGraphEventsDetail<\"click\">, event: UnwrapGraphEvents<\"click\">) => void;\n dblclick: (data: UnwrapGraphEventsDetail<\"dblclick\">, event: UnwrapGraphEvents<\"dblclick\">) => void;\n onCameraChange: (data: UnwrapGraphEventsDetail<\"camera-change\">, event: UnwrapGraphEvents<\"camera-change\">) => void;\n onBlockDragStart: (data: UnwrapGraphEventsDetail<\"block-drag-start\">, event: UnwrapGraphEvents<\"block-drag-start\">) => void;\n onBlockDrag: (data: UnwrapGraphEventsDetail<\"block-drag\">, event: UnwrapGraphEvents<\"block-drag\">) => void;\n onBlockDragEnd: (data: UnwrapGraphEventsDetail<\"block-drag-end\">, event: UnwrapGraphEvents<\"block-drag-end\">) => void;\n onBlockSelectionChange: (data: UnwrapGraphEventsDetail<\"blocks-selection-change\">, event: UnwrapGraphEvents<\"blocks-selection-change\">) => void;\n onBlockAnchorSelectionChange: (data: UnwrapGraphEventsDetail<\"block-anchor-selection-change\">, event: UnwrapGraphEvents<\"block-anchor-selection-change\">) => void;\n onBlockChange: (data: UnwrapGraphEventsDetail<\"block-change\">, event: UnwrapGraphEvents<\"block-change\">) => void;\n onBlocksGeometryChange: (data: UnwrapGraphEventsDetail<\"blocks-geometry-change\">, event: UnwrapGraphEvents<\"blocks-geometry-change\">) => void;\n onConnectionSelectionChange: (data: UnwrapGraphEventsDetail<\"connection-selection-change\">, event: UnwrapGraphEvents<\"connection-selection-change\">) => void;\n onStateChanged: (data: UnwrapGraphEventsDetail<\"state-change\">, event: UnwrapGraphEvents<\"state-change\">) => void;\n};" + } + ] + }, + { + "name": "TRenderBlockFn", + "declarations": [ + { + "kind": "TypeAliasDeclaration", + "text": "export type TRenderBlockFn = (graphObject: Graph, block: T) => React.JSX.Element;" + } + ] + }, + { + "name": "useBlockAnchorPosition", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useBlockAnchorPosition(state: AnchorState | undefined, anchorContainerRef: React.MutableRefObject | undefined): void;" + } + ] + }, + { + "name": "useBlockAnchorState", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useBlockAnchorState(graph: Graph, anchor: TAnchor): AnchorState | undefined;" + } + ] + }, + { + "name": "useBlockState", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useBlockState(graph: Graph, block: T | T[\"id\"]): import(\"../..\").BlockState;" + } + ] + }, + { + "name": "useBlockViewState", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useBlockViewState(graph: Graph, block: T | T[\"id\"]): import(\"../../components/canvas/blocks/Block\").Block, import(\"../../components/canvas/blocks/Block\").TBlockProps>;" + } + ] + }, + { + "name": "useComputedSignal", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useComputedSignal(compute: () => T, deps: DependencyList): T;" + } + ] + }, + { + "name": "useElk", + "declarations": [ + { + "kind": "VariableDeclaration", + "text": "useElk: (config: ElkNode, elk: InstanceType, args?: ElkLayoutArguments & {\n onError?: (e: Error) => void;\n}) => {\n result: ConverterResult;\n isLoading: boolean;\n}" + } + ] + }, + { + "name": "useGraph", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useGraph(config: HookGraphParams): {\n graph: Graph;\n api: import(\"../../api/PublicGraphApi\").PublicGraphApi;\n setSettings: (settings: unknown) => void;\n start: () => void;\n stop: () => void;\n setViewConfiguration: (viewConfig: {\n colors?: RecursivePartial;\n constants?: RecursivePartial;\n }) => void;\n addLayer: = Constructor>>(layerCtor: T, props: LayerPublicProps) => InstanceType;\n setEntities: (entities: {\n blocks?: B[];\n connections?: C[];\n }) => void;\n updateEntities: (entities: {\n blocks?: B[];\n connections?: C[];\n }) => void;\n zoomTo: (target: TGraphZoomTarget, config?: ZoomConfig) => void;\n};" + } + ] + }, + { + "name": "useGraphContext", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useGraphContext(): GraphContextType;" + } + ] + }, + { + "name": "useGraphEvent", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useGraphEvent(graph: Graph | null, event: Event, cb: (data: UnwrapGraphEventsDetail, event: UnwrapGraphEvents) => void, debounceParams?: {\n priority?: ESchedulerPriority;\n frameInterval?: number;\n frameTimeout?: number;\n}): void;" + } + ] + }, + { + "name": "useGraphEvents", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useGraphEvents(graph: Graph | null, events: Partial): void;" + } + ] + }, + { + "name": "useLayer", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useLayer = Constructor>(graph: Graph | null, layerCtor: T, props: LayerPublicProps): InstanceType;" + } + ] + }, + { + "name": "useSceneChange", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useSceneChange(graph: Graph, fn: () => void): void;" + } + ] + }, + { + "name": "useScheduledTask", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useScheduledTask(fn: T, options: Omit): void;" + } + ] + }, + { + "name": "useSchedulerDebounce", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useSchedulerDebounce) => void>(fn: T, options: TDebounceOptions): ((...args: Parameters) => void) & {\n cancel: () => void;\n flush: () => void;\n isScheduled: () => boolean;\n};" + } + ] + }, + { + "name": "useSchedulerThrottle", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useSchedulerThrottle) => void>(fn: T, options: TDebounceOptions): ((...args: Parameters) => void) & {\n cancel: () => void;\n flush: () => void;\n};" + } + ] + }, + { + "name": "useSignal", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useSignal(signal: Signal): T;" + } + ] + }, + { + "name": "useSignalEffect", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useSignalEffect(effectFn: () => void, deps: DependencyList): void;" + } + ] + }, + { + "name": "useSignalLayoutEffect", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useSignalLayoutEffect(effectFn: () => void, deps: DependencyList): void;" + } + ] + }, + { + "name": "useSyncBlockState", + "declarations": [ + { + "kind": "FunctionDeclaration", + "text": "export declare function useSyncBlockState(graph: Graph, block: T | T[\"id\"]): import(\"../..\").BlockState;" + } + ] + } + ] +} diff --git a/tests/package-contract/run.mjs b/tests/package-contract/run.mjs new file mode 100644 index 00000000..64451038 --- /dev/null +++ b/tests/package-contract/run.mjs @@ -0,0 +1,276 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, readdir, rm, cp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { build as bundle } from "esbuild"; +import ts from "typescript"; + +const fixtureRoot = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(fixtureRoot, "../.."); +const baselinePath = join(fixtureRoot, "public-api.json"); +const update = process.argv.includes("--update"); +const artifactOnly = process.argv.includes("--artifact-only"); +const temporaryRoot = await mkdtemp(join(tmpdir(), "gravity-graph-package-contract-")); +const npmCache = join(temporaryRoot, "npm-cache"); +const internalRoots = new Set( + (await readdir(join(repositoryRoot, "src"), { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && entry.name !== "stories") + .map((entry) => entry.name) +); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: repositoryRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + npm_config_cache: npmCache, + npm_config_audit: "false", + npm_config_fetch_retries: "0", + npm_config_fetch_timeout: "10000", + npm_config_fund: "false", + npm_config_update_notifier: "false", + }, + ...options, + }); + + if (result.status !== 0) { + process.stderr.write(result.stdout ?? ""); + process.stderr.write(result.stderr ?? ""); + throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}`); + } + + return result.stdout.trim(); +} + +function normalizeDeclaration(text) { + return text + .replace(/\r\n/g, "\n") + .replace(/[ \t]+$/gm, "") + .trim(); +} + +function isModuleSpecifier(node) { + const parent = node.parent; + return ( + (ts.isImportDeclaration(parent) && parent.moduleSpecifier === node) || + (ts.isExportDeclaration(parent) && parent.moduleSpecifier === node) || + (ts.isExternalModuleReference(parent) && parent.expression === node) || + (ts.isLiteralTypeNode(parent) && ts.isImportTypeNode(parent.parent)) || + (ts.isCallExpression(parent) && + parent.arguments.includes(node) && + parent.expression.kind === ts.SyntaxKind.ImportKeyword) + ); +} + +async function assertNoInternalBareSpecifiers(declarationRoot) { + const declarationFiles = []; + + async function collect(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + await collect(path); + } else if (entry.name.endsWith(".d.ts")) { + declarationFiles.push(path); + } + } + } + await collect(declarationRoot); + + const unresolved = []; + for (const declarationFile of declarationFiles) { + const declaration = await readFile(declarationFile, "utf8"); + const source = ts.createSourceFile(declarationFile, declaration, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + function visit(node) { + if (ts.isStringLiteralLike(node) && isModuleSpecifier(node)) { + const [root, ...rest] = node.text.split("/"); + if (internalRoots.has(root) && rest.length > 0) { + unresolved.push(`${declarationFile}: ${node.text}`); + } + } + ts.forEachChild(node, visit); + } + visit(source); + } + + assert.deepEqual( + unresolved, + [], + `Packed declarations contain internal bare module aliases:\n${unresolved.join("\n")}` + ); +} + +function assertPackageDeclarationDiagnostics(entryPoints) { + const program = ts.createProgram(entryPoints, { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ES2020, + skipLibCheck: false, + types: [], + }); + const packageDiagnostics = ts + .getPreEmitDiagnostics(program) + .filter( + (diagnostic) => + !diagnostic.file || !resolve(diagnostic.file.fileName).startsWith(resolve(repositoryRoot, "node_modules")) + ); + assert.equal( + packageDiagnostics.length, + 0, + packageDiagnostics.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")).join("\n") + ); +} + +function snapshotEntryPoint(declarationFile) { + const program = ts.createProgram([declarationFile], { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ES2020, + skipLibCheck: true, + types: [], + }); + const diagnostics = ts.getPreEmitDiagnostics(program); + assert.equal( + diagnostics.length, + 0, + diagnostics.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")).join("\n") + ); + + const source = program.getSourceFile(declarationFile); + assert(source, `Missing declaration source ${declarationFile}`); + const moduleSymbol = program.getTypeChecker().getSymbolAtLocation(source); + assert(moduleSymbol, `Missing module symbol for ${declarationFile}`); + + return program + .getTypeChecker() + .getExportsOfModule(moduleSymbol) + .map((symbol) => { + const target = symbol.flags & ts.SymbolFlags.Alias ? program.getTypeChecker().getAliasedSymbol(symbol) : symbol; + const declarations = target.getDeclarations() ?? symbol.getDeclarations() ?? []; + + return { + name: symbol.getName(), + declarations: declarations + .map((declaration) => ({ + kind: ts.SyntaxKind[declaration.kind], + text: normalizeDeclaration(declaration.getText()), + })) + .sort((left, right) => `${left.kind}\n${left.text}`.localeCompare(`${right.kind}\n${right.text}`)), + }; + }) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +async function installConsumer(name, tarball, dependencies, fixture) { + const consumerRoot = join(temporaryRoot, name); + await mkdir(consumerRoot, { recursive: true }); + await cp(join(fixtureRoot, "fixtures", fixture), join(consumerRoot, "index.ts")); + if (fixture.endsWith(".tsx")) { + await rm(join(consumerRoot, "index.ts")); + await cp(join(fixtureRoot, "fixtures", fixture), join(consumerRoot, "index.tsx")); + } + await cp(join(fixtureRoot, "fixtures", "tsconfig.json"), join(consumerRoot, "tsconfig.json")); + await writeFile( + join(consumerRoot, "package.json"), + `${JSON.stringify({ name: `package-contract-${name}`, private: true }, null, 2)}\n` + ); + + run("npm", ["install", "--ignore-scripts", "--no-package-lock", "--save-exact", tarball, ...dependencies], { + cwd: consumerRoot, + }); + + run(process.execPath, [join(repositoryRoot, "node_modules", "typescript", "bin", "tsc"), "-p", "tsconfig.json"], { + cwd: consumerRoot, + }); + + const bundleResult = await bundle({ + absWorkingDir: consumerRoot, + entryPoints: [join(consumerRoot, fixture.endsWith(".tsx") ? "index.tsx" : "index.ts")], + bundle: true, + format: "esm", + platform: "browser", + write: false, + logLevel: "silent", + }); + const javascript = bundleResult.outputFiles.find((output) => output.path.endsWith(".js")); + assert(javascript, `${name} did not produce a JavaScript runtime bundle`); + await import(`data:text/javascript;base64,${Buffer.from(javascript.contents).toString("base64")}`); +} + +try { + run("npm", ["run", "build:publish"]); + const packOutput = JSON.parse( + run("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", temporaryRoot]) + ); + assert.equal(packOutput.length, 1, "npm pack must produce exactly one tarball"); + const tarball = join(temporaryRoot, packOutput[0].filename); + const packedFiles = new Set(packOutput[0].files.map(({ path }) => path)); + const unpackedRoot = join(temporaryRoot, "unpacked"); + await mkdir(unpackedRoot); + run("tar", ["-xzf", tarball, "-C", unpackedRoot]); + await assertNoInternalBareSpecifiers(join(unpackedRoot, "package", "build")); + + const contract = JSON.parse(await readFile(join(fixtureRoot, "contract.json"), "utf8")); + const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")); + assert.deepEqual(packageJson.exports, contract.exports, "Published export map changed"); + assert.deepEqual(packageJson.typesVersions, contract.typesVersions, "typesVersions changed"); + + for (const [entryPoint, declaration] of Object.entries(contract.entryPoints)) { + assert(packedFiles.has(declaration), `${entryPoint} declaration is missing: ${declaration}`); + const runtime = packageJson.exports[entryPoint].default.replace(/^\.\//, ""); + assert(packedFiles.has(runtime), `${entryPoint} runtime is missing: ${runtime}`); + } + for (const style of contract.styles) { + assert(packedFiles.has(style), `Required published style is missing: ${style}`); + } + assertPackageDeclarationDiagnostics( + Object.values(contract.entryPoints).map((declaration) => join(repositoryRoot, declaration)) + ); + + const publicApi = Object.fromEntries( + Object.entries(contract.entryPoints).map(([entryPoint, declaration]) => [ + entryPoint, + snapshotEntryPoint(join(repositoryRoot, declaration)), + ]) + ); + const serializedApi = `${JSON.stringify(publicApi, null, 2)}\n`; + if (update) { + await writeFile(baselinePath, serializedApi); + } else { + assert.equal( + serializedApi, + await readFile(baselinePath, "utf8"), + "Public API changed. Review it and run with --update only for an approved v1 contract change." + ); + } + + if (artifactOnly) { + process.stdout.write("Packed artifact and public API baseline checks passed.\n"); + process.exitCode = 0; + } else { + await installConsumer("vanilla", tarball, ["react@18.3.1", "react-dom@18.3.1"], "vanilla/index.ts"); + await installConsumer( + "react-17", + tarball, + ["react@17.0.2", "react-dom@17.0.2", "@types/react@17.0.90", "@types/react-dom@17.0.26"], + "react/index.tsx" + ); + await installConsumer( + "react-18", + tarball, + ["react@18.3.1", "react-dom@18.3.1", "@types/react@18.3.28", "@types/react-dom@18.3.7"], + "react/index.tsx" + ); + + process.stdout.write( + `Package contract passed: ${packOutput[0].filename}, ${packedFiles.size} packed files, React 17/18 consumers.\n` + ); + } +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} From b83820870405a4573a2a6b14868f7da0c777b201 Mon Sep 17 00:00:00 2001 From: draedful Date: Sat, 25 Jul 2026 19:06:12 +0300 Subject: [PATCH 04/10] ci: separate v1 and v2 delivery routes --- .github/release-routing/routes.json | 52 +++++ .github/release-routing/validate.mjs | 286 +++++++++++++++++++++++++++ .github/workflows/ci.yaml | 56 +++--- .github/workflows/e2e.yml | 16 +- .github/workflows/release-v1.yml | 96 +++++++++ .github/workflows/release-v2.yml | 54 +++++ .github/workflows/release.yml | 26 --- .github/workflows/v2-preview.yml | 31 +++ 8 files changed, 558 insertions(+), 59 deletions(-) create mode 100644 .github/release-routing/routes.json create mode 100644 .github/release-routing/validate.mjs create mode 100644 .github/workflows/release-v1.yml create mode 100644 .github/workflows/release-v2.yml delete mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/v2-preview.yml diff --git a/.github/release-routing/routes.json b/.github/release-routing/routes.json new file mode 100644 index 00000000..5c5d1913 --- /dev/null +++ b/.github/release-routing/routes.json @@ -0,0 +1,52 @@ +{ + "activePhase": "pre-cutover", + "branchCreationPolicy": "skip-release-job", + "releaseRoutes": { + "pre-cutover-v1": { + "phase": "pre-cutover", + "branch": "main", + "major": 1, + "prerelease": false, + "distTag": "latest", + "workflow": "release-v1.yml", + "active": true + }, + "pre-cutover-v2": { + "phase": "pre-cutover", + "branch": "v2", + "major": 2, + "prerelease": true, + "distTag": "next", + "workflow": "release-v2.yml", + "active": true + }, + "post-cutover-v1": { + "phase": "post-cutover", + "branch": "release/v1", + "major": 1, + "prerelease": false, + "distTag": "v1", + "workflow": "release-v1.yml", + "active": false + }, + "post-cutover-v2": { + "phase": "post-cutover", + "branch": "main", + "major": 2, + "prerelease": false, + "distTag": "latest", + "workflow": "release-v2.yml", + "active": false + } + }, + "previewRoutes": { + "main": { + "workflow": "main-preview.yml", + "destination": "/graph/main/" + }, + "v2": { + "workflow": "v2-preview.yml", + "destination": "/graph/v2/" + } + } +} diff --git a/.github/release-routing/validate.mjs b/.github/release-routing/validate.mjs new file mode 100644 index 00000000..224439f6 --- /dev/null +++ b/.github/release-routing/validate.mjs @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(directory, "../.."); +const workflowsDirectory = path.join(root, ".github/workflows"); +const routes = JSON.parse(fs.readFileSync(path.join(directory, "routes.json"), "utf8")); + +function fail(message) { + throw new Error(message); +} + +function readWorkflow(name) { + const YAML = require("yaml"); + return YAML.parse(fs.readFileSync(path.join(workflowsDirectory, name), "utf8")); +} + +function branchesFor(workflow, event) { + const trigger = workflow.on?.[event]; + if (!trigger) { + return []; + } + return Array.isArray(trigger.branches) ? trigger.branches : []; +} + +function allSteps(workflow) { + return Object.values(workflow.jobs ?? {}).flatMap((job) => job.steps ?? []); +} + +function actionStep(workflow) { + const steps = allSteps(workflow).filter((step) => step.uses === "gravity-ui/release-action@v1"); + assert(steps.length > 0, "release workflow must invoke gravity-ui/release-action@v1"); + return steps; +} + +function hasRun(workflow, command) { + return allSteps(workflow).some((step) => String(step.run ?? "").includes(command)); +} + +function assertGuardBeforeCredentialsAndRelease(workflow, jobName) { + const steps = workflow.jobs[jobName].steps; + const guardIndex = steps.findIndex((step) => step.name === "Validate release route"); + const tokenIndex = steps.findIndex((step) => step.uses === "actions/create-github-app-token@v2"); + const releaseIndex = steps.findIndex((step) => step.uses === "gravity-ui/release-action@v1"); + assert(guardIndex >= 0, `${jobName} is missing its route guard`); + assert(tokenIndex > guardIndex, `${jobName} must validate before minting an App token`); + assert(releaseIndex > tokenIndex, `${jobName} must mint the token only after validation`); +} + +function validateRouteMatrix() { + const expectedRoutes = { + "pre-cutover-v1": ["pre-cutover", "main", 1, false, "latest", true], + "pre-cutover-v2": ["pre-cutover", "v2", 2, true, "next", true], + "post-cutover-v1": ["post-cutover", "release/v1", 1, false, "v1", false], + "post-cutover-v2": ["post-cutover", "main", 2, false, "latest", false], + }; + + assert.equal(routes.activePhase, "pre-cutover"); + assert.equal(routes.branchCreationPolicy, "skip-release-job"); + for (const [name, expected] of Object.entries(expectedRoutes)) { + const route = routes.releaseRoutes[name]; + assert(route, `missing release route ${name}`); + assert.deepEqual( + [route.phase, route.branch, route.major, route.prerelease, route.distTag, route.active], + expected, + `invalid release route ${name}` + ); + const sampleVersion = route.prerelease ? `${route.major}.0.0-next.0` : `${route.major}.0.0`; + assert(versionMatchesRoute(sampleVersion, route), `${name} must accept its declared version shape`); + const wrongPrerelease = route.prerelease ? `${route.major}.0.0` : `${route.major}.0.0-next.0`; + assert(!versionMatchesRoute(wrongPrerelease, route), `${name} must reject the wrong prerelease shape`); + assert(!versionMatchesRoute(`${route.major + 1}.0.0`, route), `${name} must reject another major`); + } + + assert.notEqual( + routes.previewRoutes.main.destination, + routes.previewRoutes.v2.destination, + "main and v2 preview destinations must differ" + ); +} + +function validateCi() { + for (const name of ["ci.yaml", "e2e.yml"]) { + const workflow = readWorkflow(name); + assert(Object.hasOwn(workflow.on ?? {}, "pull_request"), `${name} must run for pull requests`); + assert.deepEqual( + [...branchesFor(workflow, "push")].sort(), + ["main", "v2"], + `${name} must run on pushes to main and v2` + ); + } + + const ci = readWorkflow("ci.yaml"); + assert(hasRun(ci, "npm run test:package-contract"), "CI must run the full package contract"); + assert( + hasRun(ci, ".github/release-routing/validate.mjs check-workflows"), + "CI must validate release routing and workflow wiring" + ); +} + +function validatePreviews() { + for (const branch of ["main", "v2"]) { + const route = routes.previewRoutes[branch]; + const workflow = readWorkflow(route.workflow); + assert.deepEqual(branchesFor(workflow, "push"), [branch]); + const upload = allSteps(workflow).find((step) => step.uses === "gravity-ui/preview-upload-to-s3-action@v1"); + assert(upload, `${route.workflow} must upload its preview`); + assert.equal(upload.with?.["dest-path"], route.destination); + } +} + +function validateReleaseWorkflows() { + const zeroRevision = "0000000000000000000000000000000000000000"; + const v1 = readWorkflow("release-v1.yml"); + assert.deepEqual([...branchesFor(v1, "push")].sort(), ["main", "release/v1"]); + assert.equal(actionStep(v1).length, 2); + assert( + actionStep(v1).some((step) => step.with?.["default-branch"] === "main" && step.with?.["npm-dist-tag"] === "latest") + ); + const v1MainGuard = String(v1.jobs["release-main"].steps.find((step) => step.name === "Validate release route")?.run); + assert(v1MainGuard.includes("--route pre-cutover-v1")); + assert(v1MainGuard.includes("--dist-tag latest")); + assert(v1MainGuard.includes("--prerelease false")); + assert(String(v1.jobs["release-main"].if).includes(`github.event.before != '${zeroRevision}'`)); + assertGuardBeforeCredentialsAndRelease(v1, "release-main"); + const v1MaintenanceGuard = String( + v1.jobs["release-maintenance"].steps.find((step) => step.name === "Validate release route")?.run + ); + assert(v1MaintenanceGuard.includes("--route post-cutover-v1")); + assert(v1MaintenanceGuard.includes("--dist-tag v1")); + assert(v1MaintenanceGuard.includes("--prerelease false")); + assert(String(v1.jobs["release-maintenance"].if).includes(`github.event.before != '${zeroRevision}'`)); + assertGuardBeforeCredentialsAndRelease(v1, "release-maintenance"); + assert( + actionStep(v1).some( + (step) => step.with?.["default-branch"] === "release/v1" && step.with?.["npm-dist-tag"] === "v1" + ) + ); + + const v2 = readWorkflow("release-v2.yml"); + assert.deepEqual(branchesFor(v2, "push"), ["v2"]); + assert.equal(actionStep(v2).length, 1); + assert.equal(actionStep(v2)[0].with?.["default-branch"], "v2"); + assert.equal(actionStep(v2)[0].with?.["npm-dist-tag"], "next"); + assert.equal(actionStep(v2)[0].with?.prerelease, true); + const v2Guard = String( + v2.jobs["release-prerelease"].steps.find((step) => step.name === "Validate release route")?.run + ); + assert(v2Guard.includes("--route pre-cutover-v2")); + assert(v2Guard.includes("--dist-tag next")); + assert(v2Guard.includes("--prerelease true")); + assert(String(v2.jobs["release-prerelease"].if).includes(`github.event.before != '${zeroRevision}'`)); + assertGuardBeforeCredentialsAndRelease(v2, "release-prerelease"); + assert( + !fs.readFileSync(path.join(workflowsDirectory, "release-v2.yml"), "utf8").includes("npm-dist-tag: latest"), + "pre-cutover v2 workflow must never select latest" + ); + + for (const workflow of [v1, v2]) { + assert( + hasRun(workflow, ".github/release-routing/validate.mjs guard-push"), + "every release workflow must run the fail-before-publish guard" + ); + } + + assert(!fs.existsSync(path.join(workflowsDirectory, "release.yml")), "shared release.yml must be removed"); +} + +function parseArguments(values) { + const result = {}; + for (let index = 0; index < values.length; index += 2) { + const key = values[index]; + const value = values[index + 1]; + if (!key?.startsWith("--") || value === undefined) { + fail(`invalid argument sequence near ${key ?? ""}`); + } + result[key.slice(2)] = value; + } + return result; +} + +function packageVersionAt(revision) { + const contents = execFileSync("git", ["show", `${revision}:package.json`], { cwd: root, encoding: "utf8" }); + return JSON.parse(contents).version; +} + +function versionMatchesRoute(version, route) { + const parsed = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/.exec( + version + ); + return parsed !== null && Number(parsed[1]) === route.major && Boolean(parsed[4]) === route.prerelease; +} + +function validateExecutableRoute(route, args, version) { + assert(route.active, "release route is inactive"); + assert.equal(route.phase, routes.activePhase, "release route phase is not active"); + assert.equal(args.branch, route.branch, "release branch does not match route"); + assert.equal(args["dist-tag"], route.distTag, "npm dist-tag does not match route"); + assert.equal(args.prerelease, String(route.prerelease), "prerelease mode mismatch"); + assert(versionMatchesRoute(version, route), `version ${version} violates ${args.route}`); +} + +function validatePushVersions(route, args, beforeVersion, afterVersion) { + validateExecutableRoute(route, args, afterVersion); + return `${beforeVersion} -> ${afterVersion}`; +} + +function guardPush(values) { + const args = parseArguments(values); + assert(!/^0+$/.test(args.before), "branch creation must skip the entire release job"); + const route = routes.releaseRoutes[args.route]; + assert(route, `unknown route ${args.route}`); + + const afterVersion = packageVersionAt(args.after); + const beforeVersion = packageVersionAt(args.before); + const transition = validatePushVersions(route, args, beforeVersion, afterVersion); + console.log(`Route ${args.route} accepts current version ${afterVersion}`); + console.log(`Observed package version transition: ${transition}`); +} + +function validateGuardCases() { + const preV2 = routes.releaseRoutes["pre-cutover-v2"]; + const preV2Args = { + route: "pre-cutover-v2", + branch: "v2", + "dist-tag": "next", + prerelease: "true", + }; + + assert.throws(() => validateExecutableRoute(routes.releaseRoutes["post-cutover-v1"], {}, "1.0.0"), /inactive/); + assert.throws( + () => validateExecutableRoute({ ...preV2, phase: "post-cutover" }, preV2Args, "2.0.0-next.0"), + /phase is not active/ + ); + assert.throws( + () => + guardPush([ + "--route", + "pre-cutover-v2", + "--branch", + "v2", + "--dist-tag", + "next", + "--prerelease", + "true", + "--before", + "0000000000000000000000000000000000000000", + "--after", + "HEAD", + ]), + /branch creation must skip the entire release job/ + ); + assert.throws( + () => validatePushVersions(preV2, preV2Args, "1.11.3", "1.11.3"), + /version 1\.11\.3 violates pre-cutover-v2/ + ); + assert.doesNotThrow(() => validatePushVersions(preV2, preV2Args, "2.0.0-next.0", "2.0.0-next.0")); +} + +function checkWorkflows() { + validateRouteMatrix(); + validateCi(); + validatePreviews(); + validateReleaseWorkflows(); + validateGuardCases(); + console.log("Release routing and workflow wiring are valid"); + console.log( + "External gate: verify the release App has only the minimum ruleset bypass needed for release PRs and publication" + ); +} + +const [command, ...values] = process.argv.slice(2); +if (command === "check-workflows") { + checkWorkflows(); +} else if (command === "guard-push") { + guardPush(values); +} else { + fail("usage: validate.mjs "); +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ceb48d8a..b9d3af73 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, v2] pull_request: jobs: @@ -10,34 +10,38 @@ jobs: name: Verify Files runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version: 20 - cache: npm - - name: Install Packages - run: npm ci - - name: Lint Files - run: npm run lint - - name: Typecheck - run: npm run typecheck + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 20 + cache: npm + - name: Install Packages + run: npm ci + - name: Verify Release Routing + run: node .github/release-routing/validate.mjs check-workflows + - name: Lint Files + run: npm run lint + - name: Typecheck + run: npm run typecheck tests: name: Tests runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version: 20 - cache: npm - - name: Install Packages - run: npm ci - - name: Unit Tests - run: npm run test + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 20 + cache: npm + - name: Install Packages + run: npm ci + - name: Unit Tests + run: npm run test + - name: Package Contract + run: npm run test:package-contract # - name: ESBuild compatability - # run: npm run test:esbuild \ No newline at end of file + # run: npm run test:esbuild diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4caade39..d7f62cb7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -2,6 +2,8 @@ name: E2E Tests on: workflow_dispatch: + push: + branches: [main, v2] pull_request: jobs: @@ -9,28 +11,28 @@ jobs: name: E2E Tests runs-on: ubuntu-latest timeout-minutes: 15 - + steps: - name: Checkout uses: actions/checkout@v4 - + - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 cache: npm - + - name: Install dependencies run: npm ci - + - name: Install Playwright browsers run: npx playwright install --with-deps chromium - + - name: Run E2E tests run: npm run test:e2e:ci env: CI: true - + - name: Upload test results if: failure() uses: actions/upload-artifact@v4 @@ -38,7 +40,7 @@ jobs: name: playwright-report path: playwright-report/ retention-days: 7 - + - name: Upload test screenshots if: failure() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release-v1.yml b/.github/workflows/release-v1.yml new file mode 100644 index 00000000..e5a14851 --- /dev/null +++ b/.github/workflows/release-v1.yml @@ -0,0 +1,96 @@ +name: Release v1 + +on: + push: + branches: + - main + - release/v1 + +permissions: + contents: read + +jobs: + release-main: + name: Release v1 from main + # Creating a branch is not a release event. Skip the whole job before any + # App token is minted; ordinary pushes must pass the version/route guard. + if: >- + github.ref_name == 'main' && + github.event.before != '0000000000000000000000000000000000000000' + runs-on: ubuntu-latest + steps: + - name: Checkout for release preflight + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Node for release preflight + uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Validate release route + run: >- + node .github/release-routing/validate.mjs guard-push + --route pre-cutover-v1 + --branch "${{ github.ref_name }}" + --dist-tag latest + --prerelease false + --before "${{ github.event.before }}" + --after "${{ github.sha }}" + # The App creates/updates the reviewed release PR and publishes only after + # that PR is merged. Its ruleset bypass must be limited to those operations. + - name: Generate release App token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.GRAVITY_UI_APP_ID }} + private-key: ${{ secrets.GRAVITY_UI_APP_PRIVATE_KEY }} + - name: Release v1 from main + uses: gravity-ui/release-action@v1 + with: + github-token: ${{ steps.generate-token.outputs.token }} + npm-token: ${{ secrets.GRAVITY_UI_BOT_NPM_TOKEN }} + node-version: 24 + default-branch: main + npm-dist-tag: latest + + release-maintenance: + name: Release v1 from release/v1 + # This future route remains inactive until the explicit cutover config + # change. Branch creation also skips the whole job before token generation. + if: >- + github.ref_name == 'release/v1' && + github.event.before != '0000000000000000000000000000000000000000' + runs-on: ubuntu-latest + steps: + - name: Checkout for release preflight + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Node for release preflight + uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Validate release route + run: >- + node .github/release-routing/validate.mjs guard-push + --route post-cutover-v1 + --branch "${{ github.ref_name }}" + --dist-tag v1 + --prerelease false + --before "${{ github.event.before }}" + --after "${{ github.sha }}" + # Keep any App bypass scoped to release PR and publication operations. + - name: Generate release App token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.GRAVITY_UI_APP_ID }} + private-key: ${{ secrets.GRAVITY_UI_APP_PRIVATE_KEY }} + - name: Release v1 from release/v1 + uses: gravity-ui/release-action@v1 + with: + github-token: ${{ steps.generate-token.outputs.token }} + npm-token: ${{ secrets.GRAVITY_UI_BOT_NPM_TOKEN }} + node-version: 24 + default-branch: release/v1 + npm-dist-tag: v1 diff --git a/.github/workflows/release-v2.yml b/.github/workflows/release-v2.yml new file mode 100644 index 00000000..8ab588f3 --- /dev/null +++ b/.github/workflows/release-v2.yml @@ -0,0 +1,54 @@ +name: Release v2 + +on: + push: + branches: + - v2 + +permissions: + contents: read + +jobs: + release-prerelease: + name: Release v2 prerelease from v2 + # The initial v2 ref creation must not invoke release automation. Subsequent + # pushes remain blocked until package.json contains a reviewed 2.x + # prerelease version accepted by the preflight guard. + if: >- + github.event.before != '0000000000000000000000000000000000000000' + runs-on: ubuntu-latest + steps: + - name: Checkout for release preflight + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Node for release preflight + uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Validate release route + run: >- + node .github/release-routing/validate.mjs guard-push + --route pre-cutover-v2 + --branch "${{ github.ref_name }}" + --dist-tag next + --prerelease true + --before "${{ github.event.before }}" + --after "${{ github.sha }}" + # The App creates/updates the reviewed release PR and publishes only after + # that PR is merged. Its ruleset bypass must be limited to those operations. + - name: Generate release App token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.GRAVITY_UI_APP_ID }} + private-key: ${{ secrets.GRAVITY_UI_APP_PRIVATE_KEY }} + - name: Release v2 prerelease from v2 + uses: gravity-ui/release-action@v1 + with: + github-token: ${{ steps.generate-token.outputs.token }} + npm-token: ${{ secrets.GRAVITY_UI_BOT_NPM_TOKEN }} + node-version: 24 + default-branch: v2 + npm-dist-tag: next + prerelease: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index cea0f2e2..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Release - -on: - push: - branches: - - main - - release/v* - -jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Generate token - id: generate-token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.GRAVITY_UI_APP_ID }} - private-key: ${{ secrets.GRAVITY_UI_APP_PRIVATE_KEY }} - - name: Release from ${{ github.ref_name }} - uses: gravity-ui/release-action@v1 - with: - github-token: ${{ steps.generate-token.outputs.token }} - npm-token: ${{ secrets.GRAVITY_UI_BOT_NPM_TOKEN }} - node-version: 24 - default-branch: ${{ github.ref_name != 'main' && github.ref_name || null }} - npm-dist-tag: ${{ github.ref_name != 'main' && 'untagged' || 'latest' }} diff --git a/.github/workflows/v2-preview.yml b/.github/workflows/v2-preview.yml new file mode 100644 index 00000000..14dc82fb --- /dev/null +++ b/.github/workflows/v2-preview.yml @@ -0,0 +1,31 @@ +name: V2 Preview + +on: + push: + branches: [v2] + +jobs: + v2: + name: Build and Deploy + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 20 + cache: npm + - name: Install Packages + run: npm ci + shell: bash + - name: Build Storybook + run: npm run build-storybook + shell: bash + - name: Upload to S3 + uses: gravity-ui/preview-upload-to-s3-action@v1 + with: + src-path: storybook-static + dest-path: /graph/v2/ + s3-key-id: ${{ secrets.STORYBOOK_S3_KEY_ID }} + s3-secret-key: ${{ secrets.STORYBOOK_S3_SECRET_KEY }} From 9b086a9b95d1331702af5e1f67dffcc4e577e5e9 Mon Sep 17 00:00:00 2001 From: draedful Date: Sat, 25 Jul 2026 19:08:29 +0300 Subject: [PATCH 05/10] ci: expose package contract as a required check --- .github/release-routing/validate.mjs | 16 +++++++++++++++- .github/workflows/ci.yaml | 18 ++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.github/release-routing/validate.mjs b/.github/release-routing/validate.mjs index 224439f6..f92fdbfb 100644 --- a/.github/release-routing/validate.mjs +++ b/.github/release-routing/validate.mjs @@ -97,7 +97,21 @@ function validateCi() { } const ci = readWorkflow("ci.yaml"); - assert(hasRun(ci, "npm run test:package-contract"), "CI must run the full package contract"); + assert.equal(ci.jobs.tests?.name, "Tests", "unit tests must remain a separate Tests check"); + const packageContractJob = ci.jobs.package_contract; + assert(packageContractJob, "CI must define a separate package_contract job"); + assert.equal(packageContractJob.name, "Package Contract", "package contract check must keep its stable display name"); + const packageContractCommands = (packageContractJob.steps ?? []) + .filter((step) => step.run !== undefined) + .map((step) => String(step.run).trim()); + assert( + packageContractCommands.includes("npm run test:package-contract"), + "Package Contract job must run the exact package-contract command" + ); + assert( + !(ci.jobs.tests.steps ?? []).some((step) => String(step.run ?? "").trim() === "npm run test:package-contract"), + "Package Contract must not be folded into the Tests job" + ); assert( hasRun(ci, ".github/release-routing/validate.mjs check-workflows"), "CI must validate release routing and workflow wiring" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b9d3af73..4971d9b4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,7 +41,21 @@ jobs: run: npm ci - name: Unit Tests run: npm run test - - name: Package Contract - run: npm run test:package-contract # - name: ESBuild compatability # run: npm run test:esbuild + + package_contract: + name: Package Contract + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 20 + cache: npm + - name: Install Packages + run: npm ci + - name: Verify Package Contract + run: npm run test:package-contract From 6af91c928b5dfd59bf84f01a0decfababa2cffdb Mon Sep 17 00:00:00 2001 From: draedful Date: Sat, 25 Jul 2026 19:24:26 +0300 Subject: [PATCH 06/10] docs: prepare v1 v2 ruleset verification --- .github/rulesets/expected-policy.json | 71 ++++++ .github/rulesets/fixtures/compliant.json | 98 ++++++++ .github/rulesets/verify.mjs | 294 +++++++++++++++++++++++ docs/admin/v1-v2-rulesets.md | 153 ++++++++++++ 4 files changed, 616 insertions(+) create mode 100644 .github/rulesets/expected-policy.json create mode 100644 .github/rulesets/fixtures/compliant.json create mode 100644 .github/rulesets/verify.mjs create mode 100644 docs/admin/v1-v2-rulesets.md diff --git a/.github/rulesets/expected-policy.json b/.github/rulesets/expected-policy.json new file mode 100644 index 00000000..ced4c52b --- /dev/null +++ b/.github/rulesets/expected-policy.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 1, + "repository": "gravity-ui/graph", + "requiredChecks": ["Verify Files", "Tests", "Package Contract", "E2E Tests"], + "releaseAutomationBypass": { + "decisionStatus": "unresolved", + "allowedDecisionModes": ["none", "integration"], + "maximumActors": 1, + "allowedActorTypes": ["Integration"], + "requiredBypassMode": "pull_request" + }, + "rulesets": [ + { + "key": "main", + "name": "Gravity Graph main protection", + "phase": "pre-cutover-and-after", + "target": "branch", + "enforcement": "active", + "include": ["refs/heads/main"], + "exclude": [], + "requirePullRequest": true, + "minimumApprovals": 1, + "requireResolvedConversations": true, + "requiredMergeMethods": ["merge"], + "blockDeletion": true, + "blockForcePush": true, + "requireLinearHistory": false, + "strictRequiredStatusChecks": true, + "requiredChecks": ["Verify Files", "Tests", "Package Contract", "E2E Tests"], + "requireReleaseAutomationBypassDecision": true + }, + { + "key": "v2", + "name": "Gravity Graph v2 protection", + "phase": "must-be-active-before-v2-ref", + "target": "branch", + "enforcement": "active", + "include": ["refs/heads/v2"], + "exclude": [], + "requirePullRequest": true, + "minimumApprovals": 1, + "requireResolvedConversations": true, + "requiredMergeMethods": ["merge"], + "blockDeletion": true, + "blockForcePush": true, + "requireLinearHistory": false, + "strictRequiredStatusChecks": true, + "requiredChecks": ["Verify Files", "Tests", "Package Contract", "E2E Tests"], + "requireReleaseAutomationBypassDecision": true + }, + { + "key": "release-v1", + "name": "Gravity Graph release-v1 protection", + "phase": "must-be-active-before-release-v1-ref-at-cutover", + "target": "branch", + "enforcement": "active", + "include": ["refs/heads/release/v1"], + "exclude": [], + "requirePullRequest": true, + "minimumApprovals": 1, + "requireResolvedConversations": true, + "requiredMergeMethods": [], + "blockDeletion": true, + "blockForcePush": true, + "requireLinearHistory": null, + "strictRequiredStatusChecks": true, + "requiredChecks": ["Verify Files", "Tests", "Package Contract", "E2E Tests"], + "requireReleaseAutomationBypassDecision": true + } + ] +} diff --git a/.github/rulesets/fixtures/compliant.json b/.github/rulesets/fixtures/compliant.json new file mode 100644 index 00000000..858b7958 --- /dev/null +++ b/.github/rulesets/fixtures/compliant.json @@ -0,0 +1,98 @@ +[ + { + "id": 1001, + "name": "Gravity Graph main protection", + "target": "branch", + "enforcement": "active", + "conditions": { "ref_name": { "include": ["refs/heads/main"], "exclude": [] } }, + "bypass_actors": [{ "actor_id": 4242, "actor_type": "Integration", "bypass_mode": "pull_request" }], + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["merge", "squash", "rebase"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { "context": "Verify Files" }, + { "context": "Tests" }, + { "context": "Package Contract" }, + { "context": "E2E Tests" } + ] + } + } + ] + }, + { + "id": 1002, + "name": "Gravity Graph v2 protection", + "target": "branch", + "enforcement": "active", + "conditions": { "ref_name": { "include": ["refs/heads/v2"], "exclude": [] } }, + "bypass_actors": [{ "actor_id": 4242, "actor_type": "Integration", "bypass_mode": "pull_request" }], + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["merge", "squash", "rebase"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { "context": "Verify Files" }, + { "context": "Tests" }, + { "context": "Package Contract" }, + { "context": "E2E Tests" } + ] + } + } + ] + }, + { + "id": 1003, + "name": "Gravity Graph release-v1 protection", + "target": "branch", + "enforcement": "active", + "conditions": { "ref_name": { "include": ["refs/heads/release/v1"], "exclude": [] } }, + "bypass_actors": [{ "actor_id": 4242, "actor_type": "Integration", "bypass_mode": "pull_request" }], + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["merge", "squash", "rebase"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { "context": "Verify Files" }, + { "context": "Tests" }, + { "context": "Package Contract" }, + { "context": "E2E Tests" } + ] + } + } + ] + } +] diff --git a/.github/rulesets/verify.mjs b/.github/rulesets/verify.mjs new file mode 100644 index 00000000..dfc0a0cf --- /dev/null +++ b/.github/rulesets/verify.mjs @@ -0,0 +1,294 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = dirname(fileURLToPath(import.meta.url)); +const expected = JSON.parse(await readFile(resolve(root, "expected-policy.json"), "utf8")); +const supportedRules = new Set(["deletion", "non_fast_forward", "pull_request", "required_status_checks"]); + +function argument(name) { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +} + +function sorted(values) { + return [...values].sort((a, b) => String(a).localeCompare(String(b))); +} + +function clone(value) { + return structuredClone(value); +} + +function characterClass(pattern, start) { + let index = start + 1; + let negated = false; + if (pattern[index] === "!" || pattern[index] === "^") { + negated = true; + index += 1; + } + let body = ""; + if (pattern[index] === "]") { + body += "\\]"; + index += 1; + } + for (; index < pattern.length && pattern[index] !== "]"; index += 1) { + const character = pattern[index]; + if (character === "\\") { + if (index + 1 >= pattern.length) throw new Error(`malformed escape in pattern: ${pattern}`); + body += `\\${pattern[index + 1]}`; + index += 1; + } else { + if (character === "/") throw new Error(`unsupported slash in character class: ${pattern}`); + body += character; + } + } + if (index >= pattern.length || body === "") throw new Error(`malformed character class: ${pattern}`); + return { regex: `[${negated ? "^" : ""}${body}]`, end: index }; +} + +function fnmatchPathnameRegex(pattern) { + let regex = ""; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]; + if (character === "*") { + if (pattern[index + 1] === "*") { + while (pattern[index + 1] === "*") index += 1; + if (pattern[index + 1] === "/") { + regex += "(?:[^/]+/)*"; + index += 1; + } else { + regex += "[^/]*"; + } + } else { + regex += "[^/]*"; + } + } else if (character === "?") { + regex += "[^/]"; + } else if (character === "[") { + const parsed = characterClass(pattern, index); + regex += parsed.regex; + index = parsed.end; + } else if (character === "\\") { + if (index + 1 >= pattern.length) throw new Error(`malformed escape in pattern: ${pattern}`); + index += 1; + regex += pattern[index].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } else { + regex += character.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + } + return new RegExp(`^${regex}$`); +} + +function globMatches(pattern, ref) { + if (pattern === "~ALL") return true; + if (pattern === "~DEFAULT_BRANCH") return ref === "refs/heads/main"; + if (!pattern.startsWith("refs/heads/") || /[{}]/.test(pattern)) { + throw new Error(`unsupported ref target pattern: ${pattern}`); + } + return fnmatchPathnameRegex(pattern).test(ref); +} + +function appliesTo(ruleset, ref) { + if (ruleset.target !== "branch" || ruleset.enforcement !== "active") return false; + const condition = ruleset.conditions?.ref_name; + if (!condition) throw new Error(`${ruleset.name}: active branch ruleset has no supported ref_name condition`); + const included = (condition.include ?? []).some((pattern) => globMatches(pattern, ref)); + const excluded = (condition.exclude ?? []).some((pattern) => globMatches(pattern, ref)); + return included && !excluded; +} + +function rule(ruleset, type) { + return (ruleset.rules ?? []).find((candidate) => candidate.type === type); +} + +function checkBypass(ruleset, decision, failures, key) { + const actors = ruleset.bypass_actors ?? []; + if (decision.mode === "none") { + if (actors.length !== 0) failures.push(`${key}: bypass actors exist although admin decision is none`); + return; + } + if (decision.mode !== "integration" || !decision.actorId) { + failures.push("Bypass decision is unresolved; choose --bypass-mode none or integration with actor ID"); + return; + } + if (actors.length > expected.releaseAutomationBypass.maximumActors) { + failures.push(`${key}: extra bypass actor configured`); + } + const matching = actors.filter( + (actor) => + String(actor.actor_id) === String(decision.actorId) && + expected.releaseAutomationBypass.allowedActorTypes.includes(actor.actor_type) && + actor.bypass_mode === expected.releaseAutomationBypass.requiredBypassMode + ); + if (matching.length !== 1 || actors.length !== 1) { + failures.push(`${key}: exact approved integration bypass is missing or incorrect`); + } +} + +function checkRulesets(actualRulesets, decision) { + const failures = []; + if (!["none", "integration"].includes(decision.mode)) { + failures.push("Bypass decision is unresolved; pass --bypass-mode none or integration"); + } + + for (const policy of expected.rulesets) { + const ref = policy.include[0]; + let overlaps; + try { + overlaps = actualRulesets.filter((candidate) => appliesTo(candidate, ref)); + } catch (error) { + failures.push(`${policy.key}: ${error.message}`); + continue; + } + const actual = overlaps.find((candidate) => candidate.name === policy.name); + if (!actual) { + failures.push(`${policy.key}: expected active ruleset "${policy.name}" does not apply to ${ref}`); + continue; + } + const unexpected = overlaps.filter((candidate) => candidate !== actual); + for (const candidate of unexpected) { + failures.push( + `${policy.key}: unexpected active overlapping ruleset "${candidate.name}" (${candidate.source_type ?? "unknown source"})` + ); + } + + if ( + JSON.stringify(sorted(actual.conditions.ref_name.include ?? [])) !== JSON.stringify(sorted(policy.include)) || + JSON.stringify(sorted(actual.conditions.ref_name.exclude ?? [])) !== JSON.stringify(sorted(policy.exclude)) + ) { + failures.push(`${policy.key}: target scope differs from expected exact include/exclude`); + } + const unknown = (actual.rules ?? []).filter((candidate) => !supportedRules.has(candidate.type)); + if (unknown.length > 0) { + failures.push(`${policy.key}: unsupported active rule types: ${unknown.map(({ type }) => type).join(", ")}`); + } + + const pull = rule(actual, "pull_request"); + if (!pull) { + failures.push(`${policy.key}: pull_request rule is missing`); + } else { + const params = pull.parameters ?? {}; + if ((params.required_approving_review_count ?? 0) < policy.minimumApprovals) + failures.push(`${policy.key}: approval requirement is too low`); + if (params.required_review_thread_resolution !== policy.requireResolvedConversations) + failures.push(`${policy.key}: resolved-conversation requirement differs`); + const methods = params.allowed_merge_methods ?? []; + for (const method of policy.requiredMergeMethods) { + if (!methods.includes(method)) failures.push(`${policy.key}: allowed_merge_methods does not include ${method}`); + } + } + if (policy.blockDeletion && !rule(actual, "deletion")) failures.push(`${policy.key}: deletion rule is missing`); + if (policy.blockForcePush && !rule(actual, "non_fast_forward")) + failures.push(`${policy.key}: non_fast_forward rule is missing`); + if (policy.requireLinearHistory === false && rule(actual, "required_linear_history")) + failures.push(`${policy.key}: required_linear_history blocks canonical merge commits`); + + const checks = rule(actual, "required_status_checks"); + const params = checks?.parameters ?? {}; + const contexts = (params.required_status_checks ?? []).map(({ context }) => context); + if (JSON.stringify(sorted(contexts)) !== JSON.stringify(sorted(policy.requiredChecks))) + failures.push(`${policy.key}: required checks differ from the exact policy`); + if (params.strict_required_status_checks_policy !== policy.strictRequiredStatusChecks) + failures.push(`${policy.key}: strict_required_status_checks_policy differs`); + checkBypass(actual, decision, failures, policy.key); + } + return failures; +} + +function gh(args) { + const result = spawnSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + if (result.error?.code === "ENOENT") throw new Error("gh is not installed; use --fixture"); + if (result.status !== 0) + throw new Error( + `Read-only gh api failed; confirm authentication and ruleset read permission.\n${result.stderr.trim()}` + ); + return JSON.parse(result.stdout); +} + +function flattenSummaryPages(pages) { + if (!Array.isArray(pages) || pages.some((page) => !Array.isArray(page))) { + throw new Error("unexpected paginated ruleset-list response shape"); + } + return pages.flat(); +} + +async function loadActual() { + const fixture = argument("--fixture"); + if (fixture) return JSON.parse(await readFile(resolve(process.cwd(), fixture), "utf8")); + const pages = gh(["api", "--paginate", "--slurp", `repos/${expected.repository}/rulesets?includes_parents=true`]); + const summaries = flattenSummaryPages(pages); + if (summaries.length === 0) + throw new Error("No rulesets are visible; empty state and insufficient permission cannot be distinguished."); + return summaries.map(({ id }) => gh(["api", `repos/${expected.repository}/rulesets/${id}`])); +} + +async function selfTest() { + const integration = JSON.parse(await readFile(resolve(root, "fixtures/compliant.json"), "utf8")); + const zero = clone(integration); + zero.forEach((ruleset) => (ruleset.bypass_actors = [])); + assert.deepEqual(checkRulesets(zero, { mode: "none" }), []); + assert.deepEqual(checkRulesets(integration, { mode: "integration", actorId: "4242" }), []); + assert.deepEqual(flattenSummaryPages([[{ id: 1 }], [{ id: 2 }]]), [{ id: 1 }, { id: 2 }]); + assert.equal(globMatches("refs/heads/*", "refs/heads/main"), true); + assert.equal(globMatches("refs/heads/*", "refs/heads/release/v1"), false); + assert.equal(globMatches("refs/heads/**", "refs/heads/release/v1"), false); + assert.equal(globMatches("refs/heads/**/v1", "refs/heads/v1"), true); + assert.equal(globMatches("refs/heads/**/v1", "refs/heads/release/v1"), true); + assert.equal(globMatches("refs/heads/[mv]2", "refs/heads/v2"), true); + assert.equal(globMatches("refs/heads/[!m]*", "refs/heads/v2"), true); + assert.equal(globMatches("refs/heads/[a-u]*", "refs/heads/v2"), false); + assert.equal( + appliesTo( + { + name: "precedence", + target: "branch", + enforcement: "active", + conditions: { ref_name: { include: ["~ALL"], exclude: ["refs/heads/*"] } }, + }, + "refs/heads/release/v1" + ), + true + ); + + const mutations = [ + (x) => (x[0].enforcement = "evaluate"), + (x) => (x[0].conditions.ref_name.include = ["refs/heads/other"]), + (x) => x[0].rules.find(({ type }) => type === "required_status_checks").parameters.required_status_checks.pop(), + (x) => + x[0].rules + .find(({ type }) => type === "required_status_checks") + .parameters.required_status_checks.push({ context: "Unexpected" }), + (x) => x[0].rules.push({ type: "required_linear_history" }), + (x) => x[0].bypass_actors.push({ actor_id: 99, actor_type: "Integration", bypass_mode: "always" }), + (x) => (x[0].rules.find(({ type }) => type === "pull_request").parameters.allowed_merge_methods = ["squash"]), + (x) => + (x[0].rules.find( + ({ type }) => type === "required_status_checks" + ).parameters.strict_required_status_checks_policy = false), + (x) => x.push({ ...clone(x[0]), id: 9999, name: "Inherited overlap", source_type: "Organization" }), + (x) => (x[0].conditions.ref_name.include = ["refs/heads/[mv*"]), + ]; + for (const mutate of mutations) { + const value = clone(integration); + mutate(value); + assert(checkRulesets(value, { mode: "integration", actorId: "4242" }).length > 0); + } + assert(checkRulesets(integration, { mode: undefined }).some((failure) => failure.includes("unresolved"))); + process.stdout.write("Ruleset verifier self-test passed (zero/integration and negative matrix).\n"); +} + +if (process.argv.includes("--self-test")) { + await selfTest(); +} else { + const mode = argument("--bypass-mode"); + const actorId = argument("--bypass-actor-id") ?? process.env.GRAPH_RELEASE_BYPASS_ACTOR_ID; + const failures = checkRulesets(await loadActual(), { mode, actorId }); + if (failures.length) { + process.stderr.write(`Ruleset verification failed:\n- ${failures.join("\n- ")}\n`); + process.exitCode = 1; + } else { + process.stdout.write(`Ruleset verification passed for ${expected.repository}.\n`); + } +} diff --git a/docs/admin/v1-v2-rulesets.md b/docs/admin/v1-v2-rulesets.md new file mode 100644 index 00000000..8f816dbc --- /dev/null +++ b/docs/admin/v1-v2-rulesets.md @@ -0,0 +1,153 @@ +# V1/v2 ruleset administration + +This runbook turns the repository expectations in +[`expected-policy.json`](../../.github/rulesets/expected-policy.json) into an +administrator-owned GitHub configuration and evidence record. The verifier and +this document are preparation only: they do not create, update, or delete a +ruleset, branch, bypass, or project item. + +## Current audit boundary + +The Stage 0 read-only audit observed an empty response from the repository +rulesets endpoint and a `404` from the `main` branch-protection endpoint. Those +results do not distinguish absent protection from insufficient caller +permission. They are not evidence that the expected rulesets exist or are +effective. + +## Assign owners and the bypass decision + +Record these fields in issue #317 before configuration: + +- repository or organization administrator owner; +- verifier/evidence owner; +- explicit bypass decision: `none` or `integration`; +- bypass decision owner, approver, and date; +- rationale and controlled-test evidence for the selected decision; +- for `integration`, the approved release-automation GitHub App identity and + numeric actor ID, actor type, and `pull_request` bypass mode; +- for `integration`, why the bypass is necessary and why `pull_request` is the + minimum mode; +- rollback owner. + +Test `none` first. Approve `integration` only if a controlled release-automation +test proves that the App cannot create or update the reviewed release pull +request without it. Do not infer an actor from a workflow secret, app name, or +fixture. The `4242` actor in the offline fixture is test data only. Until an +administrator records either decision, actual verification must fail. + +## Prepare the rulesets + +Using the GitHub administrative UI or a separately reviewed administrative +procedure, prepare three active branch rulesets with the exact names and ref +targets in `expected-policy.json`: + +1. `main`, active for the current stable v1 branch; +2. future `v2`, active before `refs/heads/v2` is created; +3. future `release/v1`, active before that ref is created during cutover. + +Each ruleset must: + +- require pull requests, at least one approval, and resolved review + conversations; +- include `merge` in `allowed_merge_methods` on `main` and `v2`; +- prevent deletion and non-fast-forward updates; +- require exactly `Verify Files`, `Tests`, `Package Contract`, and `E2E Tests`; +- enable strict required-status-check handling; +- contain no bypass actors when the decision is `none`; +- contain exactly the approved release-automation integration in `pull_request` + mode when the decision is `integration`. + +The `v2` ruleset must omit required linear history so pre-cutover synchronization +merge commits are accepted. The `main` ruleset must also omit it before the +post-cutover `release/v1` to `main` synchronization route becomes active. The +repository policy does not otherwise prescribe a linear-history setting for the +future `release/v1` source branch. + +Creating a future-target ruleset does not create its branch. Do not create `v2` +until the complete Stage 0 baseline and the external pre-creation gate in ADR +0001 are approved. Do not create `release/v1` before cutover. + +## Verify without changing GitHub + +First verify the checker offline: + +```sh +node .github/rulesets/verify.mjs --self-test +``` + +For an approved zero-bypass decision, run: + +```sh +node .github/rulesets/verify.mjs --bypass-mode none +``` + +For an approved integration decision, run: + +```sh +node .github/rulesets/verify.mjs \ + --bypass-mode integration \ + --bypass-actor-id "" +``` + +The verifier calls only these read endpoints: + +- `GET /repos/gravity-ui/graph/rulesets` +- `GET /repos/gravity-ui/graph/rulesets/{ruleset_id}` + +An empty list or API error fails with an explicit permission/state ambiguity; +it is never treated as success. For review without GitHub access, use a captured +ruleset-detail array: + +```sh +node .github/rulesets/verify.mjs \ + --fixture path/to/rulesets.json \ + --bypass-mode none +``` + +Do not commit a capture containing sensitive administrative metadata. + +The list request includes inherited rulesets. For each protected ref, the +verifier evaluates every returned active branch ruleset whose `ref_name` +condition includes the ref and does not exclude it. Unexpected overlap, +unsupported target syntax, or an unsupported active rule fails closed because +its combined effect cannot be proven safe. Pattern matching follows GitHub's +documented `File::FNM_PATHNAME` behavior: `*` and `?` stay within one path +segment, `**` crosses `/`, and character sets, ranges, and negation are +supported. Malformed classes or escapes and unsupported brace syntax fail +closed. A future GitHub pattern form requires an explicit verifier update before +it can pass. + +## Evidence gates + +Before creating `v2`, record in #317: + +- ruleset URLs/IDs and names; +- administrator and bypass-decision owners; +- the recorded bypass decision, rationale, and controlled-test evidence; +- only for `integration`, the approved bypass actor ID, type, and mode; +- exported or captured rule details; +- successful verifier output; +- confirmation that `refs/heads/v2` does not yet exist. + +Immediately after creating `v2`, rerun the verifier and record: + +- effective ruleset details for the new ref; +- required-check visibility; +- a ruleset evaluation or controlled pull-request result showing that review, + checks, deletion/force-push protection, and merge commits behave as expected. + +Stage 1 remains blocked until that post-creation evidence is accepted. Repeat +the corresponding pre-creation and post-creation evidence gates for +`release/v1` during cutover. + +## Rollback and failure handling + +If preparation or verification fails, do not create the target branch and do +not weaken another ruleset to proceed. Record the mismatch, restore the last +reviewed administrative configuration using the administrator-owned change +procedure, and rerun the read-only verifier. + +If the post-creation `v2` check fails, stop Stage 1 and block merges until the +prepared policy is effective. Do not delete or force-update the branch as an +informal rollback. Any ruleset rollback or bypass change is a separate, +reviewed administrator action with before/after evidence. From f45d94490f6ac7b7676f0512d7c44f12ffc81dea Mon Sep 17 00:00:00 2001 From: draedful Date: Sat, 25 Jul 2026 19:27:16 +0300 Subject: [PATCH 07/10] test: fix vanilla package contract smoke --- tests/package-contract/fixtures/vanilla/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/package-contract/fixtures/vanilla/index.ts b/tests/package-contract/fixtures/vanilla/index.ts index ddbc5b20..14220b4d 100644 --- a/tests/package-contract/fixtures/vanilla/index.ts +++ b/tests/package-contract/fixtures/vanilla/index.ts @@ -16,5 +16,5 @@ export const vanillaContract = { point, anchorPortId: createAnchorPortId("block", "anchor"), portId: createPortId("block", "port"), - scaleLevel: defaultGetCameraBlockScaleLevel(1), + scaleStrategy: defaultGetCameraBlockScaleLevel, }; From 5d69437be50bc5933273b5e32a0022d0f5dc8b44 Mon Sep 17 00:00:00 2001 From: draedful Date: Sat, 25 Jul 2026 19:29:29 +0300 Subject: [PATCH 08/10] test: bundle package consumer styles --- tests/package-contract/run.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/package-contract/run.mjs b/tests/package-contract/run.mjs index 64451038..232c19d7 100644 --- a/tests/package-contract/run.mjs +++ b/tests/package-contract/run.mjs @@ -193,12 +193,15 @@ async function installConsumer(name, tarball, dependencies, fixture) { entryPoints: [join(consumerRoot, fixture.endsWith(".tsx") ? "index.tsx" : "index.ts")], bundle: true, format: "esm", + outdir: join(consumerRoot, "dist"), platform: "browser", write: false, logLevel: "silent", }); const javascript = bundleResult.outputFiles.find((output) => output.path.endsWith(".js")); + const stylesheet = bundleResult.outputFiles.find((output) => output.path.endsWith(".css")); assert(javascript, `${name} did not produce a JavaScript runtime bundle`); + assert(stylesheet, `${name} did not resolve the package stylesheet imports`); await import(`data:text/javascript;base64,${Buffer.from(javascript.contents).toString("base64")}`); } From a45e0b03744830d0b8267973b598605e3eb21dd6 Mon Sep 17 00:00:00 2001 From: draedful Date: Sat, 25 Jul 2026 19:31:38 +0300 Subject: [PATCH 09/10] test: fix React package contract setup --- tests/package-contract/fixtures/react/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/package-contract/fixtures/react/index.tsx b/tests/package-contract/fixtures/react/index.tsx index fa70a29d..e0e134e7 100644 --- a/tests/package-contract/fixtures/react/index.tsx +++ b/tests/package-contract/fixtures/react/index.tsx @@ -3,7 +3,7 @@ import { Graph, type TGraphColors } from "@gravity-ui/graph"; import { GraphCanvas, GraphContext, useGraph, useGraphEvent, type HookGraphParams } from "@gravity-ui/graph/react"; const colors: Partial = {}; -const params: HookGraphParams = { settings: { colors } }; +const params: HookGraphParams = { viewConfiguration: { colors } }; export function ContractGraph() { const { graph } = useGraph(params); From e5630a12e2fe46a06492ba6e7aaa6c24d0f57d4a Mon Sep 17 00:00:00 2001 From: draedful Date: Sun, 26 Jul 2026 02:18:06 +0300 Subject: [PATCH 10/10] docs: record v2 creation SHA externally --- .../0001-v1-v2-branch-release-policy.md | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/architecture-decisions/0001-v1-v2-branch-release-policy.md b/docs/architecture-decisions/0001-v1-v2-branch-release-policy.md index dfa58f81..66115e04 100644 --- a/docs/architecture-decisions/0001-v1-v2-branch-release-policy.md +++ b/docs/architecture-decisions/0001-v1-v2-branch-release-policy.md @@ -4,7 +4,8 @@ - Decision issue: [#315](https://github.com/gravity-ui/graph/issues/315) - Parent initiative: [#310](https://github.com/gravity-ui/graph/issues/310) - Audited Stage 0 base: `058990e019c1a79458a50584cb21666689ab9c3c` -- Approved `v2` creation commit: not selected yet; see [Selecting the `v2` creation commit](#selecting-the-v2-creation-commit) +- Approved `v2` creation commit record: recorded externally after the Stage 0 + baseline merge; see [Selecting the `v2` creation commit](#selecting-the-v2-creation-commit) ## Context @@ -134,17 +135,27 @@ and only the minimum release-automation bypass. Repository-verifiable artifacts do not substitute for this external configuration, and this ADR does not claim that the external gate has already passed. -The selected commit must descend from the audited Stage 0 base. The decision -owner records its full SHA in both this ADR's `Approved v2 creation commit` -field and issue #315 only after both the repository baseline gate and the -external ruleset-activation gate have passed. The branch-creation operator then +The selected commit must contain the complete Stage 0 delivery baseline and +descend from the audited Stage 0 base. The exact full SHA is approved only after +both the repository baseline gate and the external ruleset-activation gate have +passed. The decision owner records that approval in issue #315 with the +reviewed pull request and merge evidence. The branch-creation operator then verifies that `main` resolves to that exact SHA, creates `v2` from it, and -records the same SHA as the initial common commit in #312. Immediately after -creation, the assigned administrator must verify and record in #317 that the -ruleset is effective on `v2` and that its required checks apply. Stage 1 is not -authorized until that post-creation verification passes. Any baseline change -after approval invalidates the selection and requires a new full SHA approval -before `v2` is created. +records the same SHA as the initial common commit in #312, linking the #315 +decision and the relevant pull request/merge evidence. + +Issues #315 and #312 form the canonical immutable decision record for the +approved creation SHA. This repository ADR cannot canonically embed that SHA: +changing the ADR to add the value would create a new commit and make the +recorded SHA self-referential and immediately stale. The header therefore keeps +the audited base and the external-record status rather than duplicating the +future creation SHA inside the commit being selected. + +Immediately after creation, the assigned administrator must verify and record +in #317 that the ruleset is effective on `v2` and that its required checks +apply. Stage 1 is not authorized until that post-creation verification passes. +Any baseline change after approval invalidates the selection and requires a new +full SHA approval in #315 before `v2` is created. This record-and-selection rule deliberately prevents premature creation of the long-lived `v2` branch.