Cytoscape.js AGENTS.md
Guidelines for agents contributing to the Cytoscape.js graph theory and visualisation library.
Environment & tooling
- Round 42 split the repo in two. v4 is the package and lives at the
repo root (
src/,test/,benchmark/,debug/,playwright-tests/,scripts/). v3 is kept whole, self-contained and buildable inv3/— its ownpackage.json, build, tests and documentation site — because the comparison benchmarks and the v3-vs-v4 pixel-parity harness run against it. Nothing undersrc/imports outsidesrc/, and a spec enforces that (test/modules/import-graph.mjs). Everything below describes the v4 project unless it says otherwise. - Use Node via
.nvmrcwhen possible:nvm useormise en. - Use
npm; the repo is configured aroundpackage-lock.jsonand the existing npm scripts. - Library source of truth is in
src/. Built artifacts inbuild/anddist/should only be updated via the project scripts. - Bundles are produced with rolldown (
rolldown -c, not Rollup) from the./src/index.mjsentry into UMD, minified UMD, CJS, ESM, and minified ESM outputs. v3 has its ownv3/rolldown.config.mjsproducing the same five underv3/build/. - Source is TypeScript ESM (
.mts) imported through.mjsspecifiers. Undersrc/every import — in the build config, in tests, between modules — spells the extension.mjswhile the file on disk is.mts. So./src/index.mjsabove is a real specifier that resolves tosrc/index.mts; do not "fix" one into the other. - Linting is oxlint (
npm run lint), not ESLint. Tests run on thenode:testrunner (node --testvia thetest:js/test:modulesscripts), not Mocha: specs are written with chai'sexpectand a smalldescribe/it/beforeEachshim overnode:testintest/node-test-setup.mjs, which is why they look like Mocha suites. Browser coverage is Playwright. - Before starting significant work, read any repo docs directly related to the area you are changing.
src/README.mdis v4's maintained scope / deviations / design-decisions doc, andPLAN.mdis the development record — start there, not with v3's site. - v4 has no documentation site yet (round 46 builds it); v4 documents itself in JSDoc on the source — see "Documentation notes" below. v3's site is still readable at
v3/documentation/: grepv3/documentation/docmaker.jsonfor the v3 API in JSON form (search e.g. "cy.on"), andv3/documentation/md/**/*.mdfor its prose. Useful for parity questions — what did v3 do here? — but remember it describes v3, and v4 deviates deliberately in many places.
Development flow
- Make sure dependencies are installed when you first start:
npm installat the root andcd v3 && npm install. Both are needed even for a v4-only change:test/modules/benchmark-report.mjsimportsbenchmark/bench-run.mjs, which reachesbenchmark/graph.mjsand sov3/src/test.mjs, which importsheap. Until round 46.5 the rootpackage-lock.jsonwas still the pre-round-42 v3 one and hoisted v3's runtime deps to the root, so a root-only install happened to work; refreshing that lock made the real requirement visible. CI has always installed both (tests.yml). - Install Playwright browsers before running browser coverage or the full test suite on a fresh environment:
npx playwright install --with-deps. - Make your changes.
- Lint source files:
npm run lint. - Run the narrowest useful test loop while iterating, but run the relevant verification before handing work back:
- Source or algorithm changes:
npm run test:jsandnpm run test:modules. - Store, wire-format, lifecycle or multi-instance changes: also
npm run test:soak— the leak/churn/fuzz/isolation tier (round 48), which needs--expose-gcand so is its own script rather than part oftest:js. - Renderer or interaction changes:
npm run test:js,npm run test:modules, and sanity check indebug/vianpm run watch; run Playwright when browser behaviour is affected. - Bundle or packaging changes:
npm run build,npm run build:types, andnpm run test:modules—test/modules/packaging.mjs(round 44) gates the chain rolldown outputs ->dist:copy-> the manifest -> the tarball, and it is the only thing that notices when one of those four stops agreeing with the others. - If the change is broad or you are unsure, run
npm test.
- Source or algorithm changes:
- Build all bundles but only if you're modifying the build system:
npm run build. - Changing the harness, the documents, the benchmark report or the docs model? Rebuild the status site and open it:
npm run statusthennpm run status:serve. It is the one place those four artifacts are seen together, and round 46.5 found three defects by driving it that no spec had — including a code span inPLAN.mdholding<script>, which opened a real script element and silently broke every page after it. - The v3-vs-v4 parity diffs need v3's UMD bundle, which is no longer a by-product of building v4:
cd v3 && npm install && npm run build:umd. The specs fail with that instruction rather than skipping, deliberately — a parity suite that quietly stops running is worth less than one that is absent. - Touching v3 (parity fixtures, a comparison baseline)? It builds and tests as its own project:
cd v3 && npm run build,npm test. - Commit your changes in isolated commits. Use detailed commit messages.
- Closing a round? Sweep
PLAN.mdandsrc/README.md, then rewriteEXECUTIVE_SUMMARY.mdfromPLAN.md— see "Documentation notes". The status site publishes it, so a stale summary is the most public thing this repo can get wrong.
Repository structure
src/: v4's source — the columnar core and WebGPU renderer (issue #3486).src/README.mdis the maintained scope and design-decisions doc;PLAN.md(repo root) records each development round and the standing process rules (docs travel with every commit; a closing docs sweep ends every round).src/core.mts,src/collection.mts: the core facade and the collection API.src/store/: the columnar model — tables, indexes, sidecars, dirty tracking.src/render/: the WebGPU frame graph, pipelines, shaders, culling, picking.src/interact/: pointer, wheel and touch gestures.src/layout/,src/algorithms/: built-in layouts (incl. the GPU force) and the graph algorithms.src/style.mts,src/style-scales.mts,src/style-schemes.mts: the sheet compiler and the mapper DSL.src/contract.mts: the co-signed model↔renderer column/flag layout — change it first when the layout changes.src/math.mts,src/types.mts,src/util/: v4's own copies of the generic helpers it used to import from v3 (round 42).src/math.mtsis deliberately lean — the functions v4 calls, not v3's 1500-line geometry module.- The
gpu-prefix survives only where it names the device half against a CPU counterpart:gpu-context.mts,render/gpu-force.mts,render/gpu-tween.mts,render/gpu-timer.mts. (gpu-types.mtswas not such a case — it holds the public option surface — and becamepublic-types.mtsin round 42.6.)
test/:node:testsuites (Mocha-shaped, see above). Add regression coverage here for API and logic changes;test/modules/holds internal-only and tooling coverage;test/soak/holds the round-48 robustness tier (leaks, churn, wire fuzzing, multi-instance isolation), run bynpm run test:soakunder--expose-gc.debug/: The manual dev harness (npm run watch→ http://localhost:3333/), rebuilt in round 43. Offers nine networks: six from real exports (four fixtures shared with v3's WebGL harness underv3/debug/webgl/, the 465k-edgendex-x-largelocal todebug/, and a clustered variant derived from em-web in-page) and three built in-page, each with a hand-authored v4 stylesheet indebug/styles.js— including the real enrichmentmap.org style — plus view/layout/core-toggle/selection/event/add-remove sections and a stats overlay. Use it for renderer, interaction and gesture changes that are hard to verify in unit tests alone.debug/fixtures.jsholds the fixture conversion and the generators, split out sotest/modules/debug-harness.mjscan exercise the same code: that spec asserts every fixture exists at the path the page will fetch, and that every sheet compiles against that fixture's real data. It is the only automated coveragedebug/has, and both halves exist because both failed silently before round 43. The 2026-08-05 review pass added three more, each pinning a defect a person had to open the page to find: the compound fixture lays out into disjoint parent boxes (grid places leaves in declaration order and parents derive from where their children land, so the node order andcolstogether decide readability), the event log reads layout once per frame rather than once per event, andwatch:syncbinds livereload on every interface (itslocalhostdefault resolves to::1here whilehttp-server -oopens 127.0.0.1, so the client never connected).debug/slim-ndex.mjsrecords how the 34 MBndex-x-largefixture was derived from its 250 MB original, so the slim is re-runnable rather than a mystery blob.- The status build ships the fixtures as v4's own binary wire format (round 46.5). Measured over the five fetched fixtures: 102.5 MiB of JSON becomes 37.5 MiB, and the largest goes 34.1 -> 9.5 MiB — which is what puts every one of them under Cloudflare Pages' 25 MiB per-file cap, so the deploy carries all nine networks itself with no off-site bucket and no CORS rule. Note what it is not: gzipped, binary and minified JSON are within 1% of each other, so this is a file-at-rest win (which is what the cap measures) and a parse win, not a transfer one. The build writes a manifest (
status-config.js, generated into the output tree) naming each encoded fixture;debug/init.jsprefers it and falls back to the JSON when it is absent, which is whatnpm run watchdoes — local development is unchanged. The encoder runsdebug/fixtures.js's owntoGpuElementsand calls the built CJS bundle, notsrc/, so the page decodes with exactly the code that encoded it.
playwright-tests/andplaywright.config.js: Browser-level regression coverage — therendererproject (andrenderer-webkit), andvisual(goldens + live v3-vs-v4 parity diffs).benchmark/: v4's benchmark suites (round 33 took them to 22;style-bundle.mjsjoined in round 36), each headed by a comment saying what it prices and how its rows avoid measuring nothing. Most measure against v3, imported fromv3/src/. One measures through the built bundle rather than throughsrc/, which is where a hot-path figure has to come from — see the tsx note below.npm run benchmarkis the core/collection micro sweep,benchmark:reportrenders the HTML report (quick profile),benchmark:alladds every standalone sweep, andbenchmark:rendererdrives the browser scenes on a real adapter.node scripts/bench-coverage.mjs --verbosesays which public members no benchmark calls.benchmark/published/is tracked, unlikebenchmark/results/: benchmarks are machine-dependent and slow, so no run happens on the status site's builder, and a run reaches the site only bynpm run benchmark:publishon the machine that measured it. Runs are grouped by a machine fingerprint and never compared across machines; seebenchmark/published/README.mdfor the retention rule.typescript/: TypeScript-related tests and fixtures (the compile-only consumer test).scripts/: Repo tooling run by hand or by a spec — the v4 audits (jsdoc-coverage.mjs,throw-coverage.mjs,bench-coverage.mjs), the docs generator (docs-generate.mjs, round 45:npm run docs:api, gated bytest/docs-generate.mjsagainst the shipped declaration), and round 46.5's status site.oxlint src scriptscovers this directory since 46.5; it did not before.scripts/status-build.mjs+scripts/status/:npm run statusbuilds the gitignoredstatus/— a deployable preview of the branch (the debug harness, the benchmark archive, the API reference, the repo documents, the golden gallery). Split into a purebuildPlan()and a writingexecutePlan()sotest/modules/status-site.mjscan check the intended output without copying 30 MiB of fixtures. Serve it withnpm run status:serve(port 3335 — 3333 is v4's harness, 3334 is v3's).scripts/machine-info.mjs:npm run machine— CPU/cores/clock, RAM, OS, and a GPU inventory with VRAM, for benchmark provenance. Parsers are pure and exported; probes are separate and never throw.scripts/benchmark-publish.mjs:npm run benchmark:publish— promotes a local run into the trackedbenchmark/published/.scripts/theme.mjs: the design tokens andesc, shared by the benchmark report and the status site so the two read as one system.
.github/workflows/: CI and release workflows.tests.ymlruns both projects; the three release workflows are still v3's and are marked as not yet adapted (round 50 owns them) — they stay at the root only because GitHub reads workflows nowhere else.v3/: Cytoscape.js v3, whole and self-contained —v3/src/,v3/test/,v3/benchmark/,v3/debug/,v3/documentation/,v3/playwright-tests/(port 3334, so a stray server cannot be mistaken for v4's on 3333), and its own build/tsconfig/package.json.
Code standards
- Preserve the existing style: two-space indentation, single quotes, ESM imports/exports, and concise readable functions.
- Do not hand-edit generated outputs when a source file exists instead. In particular, prefer editing
src/over generated files inbuild/,dist/, and compiled docs assets. - Keep module boundaries aligned with the existing architecture. New source files should live near the corresponding subsystem in
src/. - When fixing a bug, add or update a regression test whenever practical. Put public-behaviour tests in
test/; keep internal-only coverage intest/modules/when applicable. - For renderer, gesture, or grab-state changes, verify behaviour in
debug/because visual regressions are not always caught by the Node suites alone. You need to control a browser instance to use this and you need to runnpm run watchto run a dev server with auto-rebuild. - Keep docs in sync with API or behaviour changes. For v4 that means the JSDoc on the source and
src/README.md; v3's markdown underv3/documentation/md/is only for v3 changes. - Avoid introducing new build tools, frameworks, or repo-wide conventions unless the task explicitly requires it.
- When adding new top-level workflows, major directories, or important source areas not already documented here, update
AGENTS.md.
Testing notes
npm testmatches CI closely: GitHub Actions installs dependencies, installs Playwright browsers, and runsnpm test.Nothing in the Node suite runs against the built bundle. v3 has a
test:buildscript whoseTEST_BUILDenv varv3/src/test.mjsreads; v4 has no such switch, and the root copy of that script was dead from the round-42 split until round 43 removed it. What does exercise the bundle: the Playwright projects (which loadbuild/cytoscape.umd.js) andbenchmark/style-bundle.mjs. That matters more here than it sounds — see the__namenote below, where a hot-path figure measured through tsx was 7x off what the bundle does.Playwright setup depends on a built UMD bundle and a local HTTP server. Use the existing scripts rather than inventing a parallel harness.
Rebuild the bundle before trusting a Playwright run.
playwright.config.jssetsreuseExistingServer: !CI, so when anhttp-serveris already listening on 3333 (a leftovernpm run watch, or an earlier run's server), Playwright attaches to it and thetest:playwright:buildhalf oftest:playwright:setupnever runs. (Round 42 gave v3's project port 3334 for exactly this reason — before that both served 3333, so a stray v3 server could feed v4's specs v3's pages.) The suite then silently exercises a stale bundle, and a green run proves nothing about the source you just changed. Runnpm run test:playwright:buildyourself beforenpx playwright test, or kill the listener on 3333 first. This is invisible when it happens — the only symptom is a pass you did not earn.Visual regression coverage for the GPU prototype lives in the
visualPlaywright project (playwright-tests/visual.spec.js): golden-image diffs against PNGs checked intoplaywright-tests/goldens/plus live v3-vs-v4 parity diffs. After an intended visual change, regenerate goldens withUPDATE_GOLDENS=1 npx playwright test --project=visualand commit the updated PNGs; never edit goldens by hand.If a change claims v3 parity, verify it with a live parity diff, not a golden. Goldens compare against v4's own previous output at a 0.5% default tolerance, so they answer "did this change?" and not "is this right" — in round 27 the arrow goldens passed both before and after v4's arrow sizing was corrected to v3's formula, because the difference sat under that tolerance. The parity tests render the same scene through both renderers in one run and diff them, which is what actually caught it.
Check that a new parity test can fail. Run it once with the feature deliberately disabled (or the shape swapped for its unstyled counterpart) and confirm the mismatch jumps. Round 27 shipped one test whose first version passed at 0.514% with the feature on and 0.672% with it off — it was measuring nothing. Make the scene dominated by whatever the change affects.
The same control applies to plain unit specs, and a spec's name is not evidence that it tests what it says. Round 27 shipped three specs named
'picks by its slanted outline, not its bounding box','picks inside the body and outside the cut corners'and'picks inside the body ...'whose only assertions were onboundingBox()— which is the node box for every shape keyword, so all three passed with the shape under test swapped forellipse. Each comment described the pick it meant to check and then never called a pick path. When a spec is named for behaviour X, assert X, and run the file once with X's implementation swapped out to confirm the failure lands.A spec that accepts either outcome discriminates on neither. Round 48.5 lost the GPU device mid-export and asserted only that the promise settled — resolved or rejected are both correct, and which you get depends on how far the readback had progressed. With the loss hook neutered the export simply resolved and the spec passed. When the outcome is legitimately either/or, assert the precondition as well (here: that
devicelostactually fired), or the spec is testing that promises settle. Its two siblings, which assert a specific end state, failed the control as they should.A fixture must be written in the shape the tool actually parses. Round 36's audit fixtures declared members as one-liners with the comment inline (
/** a */ a(): void {}), a shape the scanner does not match and the real sources never use — so two specs passed with the behaviour they tested deliberately broken. The control caught it, which is the argument for running controls on tool specs too: a fixture that the tool silently skips is a spec that can never fail.A leak gate measures reachability, not bytes. Round 48.1 measured 1000 create/destroy cycles growing
heapUsedby a steady ~2.2 KB per cycle — linear across five 200-cycle blocks, which is what a leak looks like and is not what this was. AWeakRefper destroyed instance showed every one collected; the growth is V8's own bookkeeping, and a byte bound would have pinned the engine's behaviour as the library's contract. Assert that the thing is collected (--expose-gc, hence the separatenpm run test:soak), and keep a byte bound only as a wide backstop. The first spec in such a file must be the probe's own control — held instances stay reachable, unheld ones do not — or every spec after it passes by doing nothing.Validate a payload-derived count where something already walks it. Round 48.3 fuzzed the wire format and found three defects of one shape: a count or index read out of the buffer, unvalidated, driving allocation (a dictionary index of 2,566,914,049 made a load never return; a corrupt offset total made it take 25.9 s). The fix's placement is the lesson. Fusing the dictionary check into
DataStore.ingestColumn, whose two branches already walk every index, costs nothing; the same check indeserializeElementsmeasured 4× on that function (0.106 → 0.46 ms per 200k indices) against a reader whose headline property is being O(1) per column. Prefer an O(1) invariant where one exists (a declared total cannot exceed the blob carrying it), then a fused walk, and only then a new pass.A guard reachable only from a fuzzer is invisible to the throw gate.
scripts/throw-coverage.mjsrunstest/!(types-*|node-test-setup).mjs— nottest/modules/, nottest/soak/. A newthrow newexercised solely by a soak or fuzz spec reads as Node-reachable-and-never-run and fails the build, correctly. Pin each guard deterministically intest/as well; round 48.3 hand-built a 28-byte buffer for one of them rather than relying on the mutation that found it.A guard nothing has ever triggered is not tested. v4 fails loudly by design, so its throws are public contract;
node scripts/throw-coverage.mjs [--verbose]reports which of thethrow newsites insrcthe Node suite actually runs (round 30 took that from 34 never-executed to 0 Node-reachable). Since round 37.1 it gates, at zero tolerance —npm run test:throws, part ofnpm test: a Node-reachable site with no spec fails the build, and so does anUNREACHABLE/MISATTRIBUTEDentry that no longer names athrow newline or carries no reason (the lists are keyed byfile:line, so an insertion above an exempted site silently hands its exemption to a different throw). When adding a guard, add the spec that fires it — and prefer a message assertion when several guards share a method, since "it threw" does not say which one.Coverage of transpiled sources needs source maps, or it lies. Reading raw
NODE_V8_COVERAGEoffsets against the.mtsfiles is fiction: tsx transpiles before V8 sees them, and round 30's first measurement "found" 47 dead throw sites including two with specs since round 13. Collect through the test runner instead (--enable-source-maps --experimental-test-coverage --test-reporter=lcov), and use line-level (DA:) data only — the function-level records misattribute one-line arrow functions, and evenDAattributes the body of a module-level arrow const to the module-evaluation count.A benchmark row is guilty until it is shown to discriminate. Round 33 wrote six rows that measured nothing and caught them only because it checked:
presetread 2388× because v4's preset does no work without apositionsmap while v3 walks every node; a compound style row read 3.55× faster than flat because one side was built without edges; a custom-polygon pick row read 1500× faster than its siblings because the pick point was inside the shape, so the walk stopped at the first node; and two box-selection rows measured an empty result becausecy.elementsInBoxtakes four numbers and silently answers the empty collection when handed a box object — a bugbenchmark/curves.mjshad carried since round 29.4, in a premium the README published. Before believing a row, ask what would make it move, and run it that way once. Round 39.1 adds a variant worth naming — a fixture can be styled into a mode it never enters: a benchmark row priced curved-edge box selection undercurve-style: bezier, which bundles multi-edges only, so on a fixture with no parallel pairs every edge rendered straight and the row read identical to the straight row above it.unbundled-beziercurves per edge; the row now prints how many of its edges are actually curved, which is the general fix — have the row assert the property it is named for.A large
switchover strings is a linear scan, so position costs. V8 does not hash one: a generated 145-case string switch measures 48.7 ns at its first case and 552.9 ns at its last, and insrc/style.mtsmoving one case (body untouched) from sixth to last cost it 56 → 90 ns. Two consequences. First, benchmark a dispatch at more than one position — rounds 33 and 34 both measuredbackground-color, which happened to be the 4th case, and so understated every other property. Second, the fix is aMapof small readers (round 35), which flattens rather than uniformly lowers: the worst case improved 2.6×, the earliest few got ~15 ns slower. Judge such a change on the aggregate — a whole-objectstyle()— not on one property. And note that size alone is not the trigger: the write path'sapplyPropis the same shape and stays a switch, because it runs per sheet compile rather than per read.For a hot path, check a finding against the built bundle before acting on it. The
benchmark/suites importsrc/through tsx, which injects esbuild's__namewrapper — anObject.definePropertyon every closure creation. Round 33 publishedStyleEngine.readPropat 13–21× v3 from those suites; profiling it for round 34 found 23% of samples in__namealone, and throughbuild/cytoscape.esm.mjs(wheregrep -c __nameis 0) the same getter measures 292 ns against v3's 50 ns — 5.8×. Allocation-light rows are unaffected, so this is not a reason to distrust the suites generally; it is a reason to re-measure any hot path that builds closures per call before you rewrite it. This is round 30's transpiled-coverage lesson in a second guise.A v3 side needs
styleEnabled: trueand an explicitlayout. Both halves bias, in opposite directions: a headless v3 defaultsstyleEnabledto false, so it does less work than v4 (which always applies its sheet), and v3's default layout is grid, so a plaincytoscape( { elements } )runs a whole layout inside the measured region. Round 33.4's init comparison moved from 1.89× to 5.47× when both were fixed.benchmark/graph.mjs's helpers take the options; use them.A headless spec for browser-page behaviour inherits the headless defaults, not the page's. The 2026-08-05 review pass pinned that
debug/'s compound fixture lays out into disjoint parent boxes, and the control came back BAD: deleting the fixture'scols: 3— one of the two things that had made it unreadable — left the spec green, becausegridderives its column count from the container's aspect ratio and a headless instance is 800 × 600 where the page is 930 × 900, so headless picked 3 anyway. Any spec asserting a layout, a fit or anything else that readscy.width()/cy.height()must setheadlessWidth/headlessHeightto the dimensions it is standing in for, or it is testing a different graph than the one that broke.Something has to open the page.
debug/now has specs, and they are worth having, but round 43 shipped with its own risk note saying they prove sheets compile and not that anything still looks right — and one day later a maintainer opening the page found three defects, one of which (a conservativefit()inflating compound graphs ~2×) was insrc/and visible in every app with a compound graph. When a change touches the harness, the renderer or bounds, drive the page:npm run watch, or a scripted browser that loadsdebug/index.html, screenshots it, and compares againstv3/debug/'s equivalent.A columnar payload can lose a whole column silently, and the page still looks plausible. Round 46.5 re-encoded the harness fixtures into the binary wire format, and the first reader treated a dictionary column (
{ dict, indices }, 1-based, 0 = absent) as a plain array — so every string column in every fixture came backundefined. The graph still rendered: right node count, right edges, right positions, no labels and no categorical colours. Nothing throws on that. When a format has more than one column encoding, the spec has to assert each column still carries values after the round trip, not that the payload parsed; the control (read the dict as an array) must fail on every fixture.A control that fails to fail is a finding, not a wasted control. Round 46.5 ran eleven and two of them landed nowhere. One deleted an
existsSyncguard and every spec stayed green — thecatchbeneath already handled a missing file, so the guard was dead code and was removed. The other removed a fixture-minifying branch to break a "nothing exceeds the 25 MiB cap" loop, and that loop stayed green too, because both oversized fixtures load from a remote so nothing planned was near the cap: the loop was not discriminating, and a spec that measures the minified size on disk was written in its place. When a control does not fail, do not shrug and move on — either the code is redundant or the spec is.An ignore pattern without a leading slash matches at every depth.
.gitignoregainedstatusfor the round-46.5 build output, and it also matchedscripts/status/— the ten modules that are the build.git add -Astaged nothing from them and the commit would have been unrunnable. Anchor a build-output pattern to the root (/status), and read whatgit status --shortactually lists before committing a new directory.An asset check that reads HTML attributes does not see URLs fetched from JS. Round 42 verified every
src/hrefin all six harness pages resolved, and four of the debug page's seven networks were 404ing the whole time: their URLs live indebug/networks.jsand are handed tofetch(). The page rendered nothing, said nothing (thePromise.allhad no.catch), and no test noticed, becausedebug/had no tests. Round 43 fixed all three halves — the paths, the missing.catch, and the absence of coverage — but the general lesson is the one to keep: enumerate what the runtime asks for, not what the markup declares.A restructure is behaviour-neutral only if you check every file, not every test. Round 42 moved ~1100 files and rewrote imports across all of them; a green suite says the paths resolve, not that nothing else changed. What actually proved it was comparing blobs: every file now under
v3/against its pre-move blob (6 differed, all intended), and every file undersrc/against itssrc/gpu/original, with the diff filtered to the two changes the round was allowed to make — an import-depth fix or the factory rename. Anything printed by that filter was a bug. Do this before trusting the suite, not instead of it.A vendored copy joins the audits. Round 42 copied five utility modules out of v3 so
src/imports nothing outside itself, and the JSDoc gate failed immediately: 19 undocumented exports, the internal tier at 96.8%. That is the gate working — a file insrc/is v4's regardless of where it came from. The fix is to document the copy, not to exempt it; and it is a reason to copy lean, since v3'smath.mtswould have dropped 1500 mostly-dead lines into scope.Do not record a GPU measurement as "blocked, no adapter here" without checking. That conclusion has been reached and corrected twice (round 18.5 and round 27.9 — see PLAN.md).
requestAdapter()returns null onabout:blank, so a bare-page probe reads as "no GPU" on a box that has one; probe from a served page, which is whatbenchmark:rendereralready does. Only thevisualgoldens pin SwiftShader, deliberately, and only for the WebGPU adapter — that pin says nothing about what hardware is present.
Documentation notes
EXECUTIVE_SUMMARY.mdis derived fromPLAN.mdand is rewritten when a round closes (round 46.5).PLAN.mdstays the source of truth; the summary is the five-minute version for a reader who will never open it, organised by calendar week and written in outcomes and decisions rather than rounds and file names. Three rules keep it honest: restate rather than append (later rounds routinely change what an earlier decision meant, so earlier weeks need correcting too — appending a week is not a rewrite); re-measure every figure rather than copying it forward, since test tallies, member counts and benchmark numbers go stale first; and an item leaves its open-questions table when the decision is made, not when the work is scheduled. Its own "Maintaining this file" section carries the full rule, andPLAN.md's process list carries the trigger.- v4 has no documentation site yet — round 46 builds it, from the model round 45's generator already emits (
npm run docs:api; 362 documented members over 48 sections, gated against the shipped declaration). Until thensrc/README.mdis the maintained prose,MIGRATING.mdis the v3->v4 porting guide andCHANGELOG.mdthe 4.0 summary — both of those ship in the package, so keep them true;test/modules/migration-guide.mjschecks the guide's property table against the running library. - v3's documentation HTML under
v3/documentation/is generated. Do not edit it directly when the corresponding markdown or template source (v3/documentation/docmaker.json,template.html) should be changed instead. v3/documentation/is v3's, and stays that way until v4 ships — the v3 code and docs are kept intact so they remain available for comparison benchmarks and parity work. Do not add v4 pages to it.- v4 documents itself in JSDoc (round 26). For anything under
src/, prose about what a member does belongs in a doc comment next to the member; the release documentation will be generated from those comments. Use standard tags only (@param name — description,@returns,@throws,@see); one block per overload signature; state the contract and any deliberate deviation from v3, not the implementation. The// -- <group> --banner comments incore.mts/collection.mtsare the section grouping a generator reads, so keep them accurate. Coverage is enforced:node scripts/jsdoc-coverage.mjs --verboselists any public member of an exported class (or exported function) that lacks a comment, andtest/jsdoc-coverage.mjsfails the build if one appears. Since round 31.2 the same script and gate also require an@throwstag on any public member whose body throws — these comments ship as hover text indist/cytoscape.d.ts, so a doc comment that is wrong (or silent) about failure is a defect a consumer sees. Round 31.1 is the cautionary case: the per-element bypass error told callers to use the style function form, which round 8 removed and 29.3 made throw, and the doc comment repeated it — while this repo's markdown described the replacement correctly the whole time. A docs sweep that only reads markdown never sees this class of drift. Round 32 added the third rule: a public member of thePUBLIC_APIfiles that takes arguments must document them with@param, because docmaker's per-function shape carries a description per argument.@returns(278/278) was written in round 36 and left reporting on that same reasoning — there is no return field — until round 37.1 gated it too, since these comments ship as.d.tshover text whether or not the generator reads them. Three tags gated, then:@throws,@param,@returns. The two remaining audits (stranded doc blocks, benchmark coverage) stay report-only deliberately, each being heuristic in a way the gated three are not. - After a rename, check that the docs' paths resolve — do not grep for the spellings you expect. Round 42's sweep worked from a hand-written substitution list and so fixed every spelling it thought of, leaving
typescript/tests/gpu.test-d.tsandtest/modules/gpu-import-graph.mjsinsrc/README.md(neither matchestest/gpu-ortypescript/tests/gpu), three pointers at v3 sources that had moved underv3/, and one line-wrappeddist/\ncytoscape-gpu.d.ts. Extracting every rooted path from the markdown and testing it withexistsSyncfound all of them in seconds — and it is the only method that catches a spelling nobody anticipated. Allow for the repo's.mjs-specifier convention when you do it. - A doc block can strand onto the wrong member, and coverage will not notice. The most repeated documentation defect in this repo — seventeen instances by the close of round 36 (eight across rounds 26.1-26.4, the ninth and tenth in 34.4/34.5, the eleventh in 36.2, and six more the moment a check existed to look) — is a later insertion landing between a
/** */block and the member it documents, after which the comment re-attaches to whatever now follows. The coverage gate sees it only when the displacement leaves some member with no comment; when the block lands on another documented member, coverage stays at 100% and two members carry each other's prose. That variant ships: round 36 foundarrowBase's block stacked abovelineOpacityConst's indist/cytoscape.d.ts, so a consumer hovering one read the other.node scripts/jsdoc-coverage.mjs --verbosenow lists blocks that document nothing (reporting only — a free-standing module note is legitimate and looks identical), and a block displaced onto another documented member is not statically detectable at all, so reading is still the only defence there. - A plan's statements about the code are claims to re-measure, not facts to build on. This repo's records are unusually good, which is exactly why their stale parts are dangerous: a sentence written when it was true reads identically to one that still is. Three rounds in a row tripped on this. Round 37.3 was told
@paramwas at 229/229 and found the package entry point outside every audit; round 37.4 was told namespaced listeners "never fire" and found v4 running v3's namespace semantics in full; round 41 was told v4's "one remaining shared-module dependency" was the emitter and found five more after severing it. The cost of checking is a grep; the cost of not checking is a round built on a premise that was never true. Prefer a spec that measures the claim (test/modules/import-graph.mjsis one) over a sentence asserting it. "Ported verbatim" is one of these claims, and the cheapest to check: round 43.4 saiddebug/'s compound fixture was v3's graph verbatim, and it had a sorted node list, four different edges and nocols: 3— adiffof the two files answered it in seconds, and the difference was the whole reason the fixture was unreadable. - An audit's scope is part of its claim; check what it enumerates before quoting its 100%. Round 32 reported
@paramat 221/221 and gated it, butauditParamTagsdescended class bodies only, while the script's own header defines a public member as a class member plus every top-level exported function — sowire.mtsandcolumnar.mts, whose entire public surface is exported functions, sat outside a gate that read as complete, and two of them had no@paramat all. Round 36 widened it to 229/229 — and round 37.3 found the same failure a third time: the widened pattern matchedexport functionandexport const f =but notexport default function, sosrc/index.mts— listed inPUBLIC_APIsince round 26, and whose entire surface is the package entry pointexport default function cytoscape— contributed zero members to every audit while reading as audited and complete, with all three of its tags in fact missing (231/231 now). The same question applies to the remaining audits: a green gate answers "nothing regressed among the things I look at", so read the enumerator, not the percentage. - The package ships declarations built by
npm run build:types(rolldown.dts.config.mjs→scripts/build-dts.mjs→dist/cytoscape.d.ts), which carry those JSDoc comments to consumers. Regenerate and commit that file when the v4 public surface changes;npm run test:types:surfaceaudits its shape.
Contribution notes
- Keep changes narrowly scoped. Cytoscape.js has a large public API and small internal regressions can surface broadly.
- Prefer extending existing tests, demos, and docs over adding parallel mechanisms.
- If a change affects public API semantics, selectors, style behaviour, layouts, rendering, or documentation structure, call that out explicitly in your summary to the user.