cytoscape.js v4 HEAD · 824a7351 · built 2026-08-05 21:16Z

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 in v3/ — its own package.json, build, tests and documentation site — because the comparison benchmarks and the v3-vs-v4 pixel-parity harness run against it. Nothing under src/ imports outside src/, and a spec enforces that (test/modules/import-graph.mjs). Everything below describes the v4 project unless it says otherwise.
  • Use Node via .nvmrc when possible: nvm use or mise en.
  • Use npm; the repo is configured around package-lock.json and the existing npm scripts.
  • Library source of truth is in src/. Built artifacts in build/ and dist/ should only be updated via the project scripts.
  • Bundles are produced with rolldown (rolldown -c, not Rollup) from the ./src/index.mjs entry into UMD, minified UMD, CJS, ESM, and minified ESM outputs. v3 has its own v3/rolldown.config.mjs producing the same five under v3/build/.
  • Source is TypeScript ESM (.mts) imported through .mjs specifiers. Under src/ every import — in the build config, in tests, between modules — spells the extension .mjs while the file on disk is .mts. So ./src/index.mjs above is a real specifier that resolves to src/index.mts; do not "fix" one into the other.
  • Linting is oxlint (npm run lint), not ESLint. Tests run on the node:test runner (node --test via the test:js / test:modules scripts), not Mocha: specs are written with chai's expect and a small describe/it/beforeEach shim over node:test in test/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.md is v4's maintained scope / deviations / design-decisions doc, and PLAN.md is 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/: grep v3/documentation/docmaker.json for the v3 API in JSON form (search e.g. "cy.on"), and v3/documentation/md/**/*.md for 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 install at the root and cd v3 && npm install. Both are needed even for a v4-only change: test/modules/benchmark-report.mjs imports benchmark/bench-run.mjs, which reaches benchmark/graph.mjs and so v3/src/test.mjs, which imports heap. Until round 46.5 the root package-lock.json was 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:js and npm 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-gc and so is its own script rather than part of test:js.
    • Renderer or interaction changes: npm run test:js, npm run test:modules, and sanity check in debug/ via npm run watch; run Playwright when browser behaviour is affected.
    • Bundle or packaging changes: npm run build, npm run build:types, and npm run test:modulestest/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.
  • 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 status then npm 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 in PLAN.md holding <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.md and src/README.md, then rewrite EXECUTIVE_SUMMARY.md from PLAN.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.md is 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.mts is 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.mts was not such a case — it holds the public option surface — and became public-types.mts in round 42.6.)
  • test/: node:test suites (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 by npm run test:soak under --expose-gc.
  • debug/: The manual dev harness (npm run watchhttp://localhost:3333/), rebuilt in round 43. Offers ten networks: six from real exports (four fixtures shared with v3's WebGL harness under v3/debug/webgl/, the 465k-edge ndex-x-large local to debug/, and a clustered variant derived from em-web in-page) and four built in-page, each with a hand-authored v4 stylesheet in debug/styles.js — including the real enrichmentmap.org style and, since round 46.6, a port of v3's own default debug graph (?network=v3-default), which is the one to open when you want arrows and edge routing rather than scale — 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.js holds the fixture conversion and the generators, split out so test/modules/debug-harness.mjs can 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 coverage debug/ 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 and cols together decide readability), the event log reads layout once per frame rather than once per event, and watch:sync binds livereload on every interface (its localhost default resolves to ::1 here while http-server -o opens 127.0.0.1, so the client never connected).
    • debug/slim-ndex.mjs records how the 34 MB ndex-x-large fixture 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 ten 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.js prefers it and falls back to the JSON when it is absent, which is what npm run watch does — local development is unchanged. The encoder runs debug/fixtures.js's own toGpuElements and calls the built CJS bundle, not src/, so the page decodes with exactly the code that encoded it.
  • playwright-tests/ and playwright.config.js: Browser-level regression coverage — the renderer project (and renderer-webkit), and visual (goldens + live v3-vs-v4 parity diffs).
  • benchmark/: v4's benchmark suites (round 33 took them to 22; style-bundle.mjs joined in round 36), each headed by a comment saying what it prices and how its rows avoid measuring nothing. Most measure against v3, imported from v3/src/. One measures through the built bundle rather than through src/, which is where a hot-path figure has to come from — see the tsx note below. npm run benchmark is the core/collection micro sweep, benchmark:report renders the HTML report (quick profile), benchmark:all adds every standalone sweep, and benchmark:renderer drives the browser scenes on a real adapter. node scripts/bench-coverage.mjs --verbose says which public members no benchmark calls. benchmark/published/ is tracked, unlike benchmark/results/: benchmarks are machine-dependent and slow, so no run happens on the status site's builder, and a run reaches the site only by npm run benchmark:publish on the machine that measured it. Runs are grouped by a machine fingerprint and never compared across machines; see benchmark/published/README.md for 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 by test/docs-generate.mjs against the shipped declaration), and round 46.5's status site. oxlint src scripts covers this directory since 46.5; it did not before.
    • scripts/status-build.mjs + scripts/status/: npm run status builds the gitignored status/ — a deployable preview of the branch (the debug harness, the benchmark archive, the API reference, the repo documents, the golden gallery). Split into a pure buildPlan() and a writing executePlan() so test/modules/status-site.mjs can check the intended output without copying 30 MiB of fixtures. Serve it with npm 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 tracked benchmark/published/.
    • scripts/theme.mjs: the design tokens and esc, shared by the benchmark report and the status site so the two read as one system.
  • .github/workflows/: CI and release workflows. tests.yml runs 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

  1. Preserve the existing style: two-space indentation, single quotes, ESM imports/exports, and concise readable functions.
  2. Do not hand-edit generated outputs when a source file exists instead. In particular, prefer editing src/ over generated files in build/, dist/, and compiled docs assets.
  3. Keep module boundaries aligned with the existing architecture. New source files should live near the corresponding subsystem in src/.
  4. When fixing a bug, add or update a regression test whenever practical. Put public-behaviour tests in test/; keep internal-only coverage in test/modules/ when applicable.
  5. 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 run npm run watch to run a dev server with auto-rebuild.
  6. 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 under v3/documentation/md/ is only for v3 changes.
  7. Avoid introducing new build tools, frameworks, or repo-wide conventions unless the task explicitly requires it.
  8. When adding new top-level workflows, major directories, or important source areas not already documented here, update AGENTS.md.

Testing notes

  • npm test matches CI closely: GitHub Actions installs dependencies, installs Playwright browsers, and runs npm test.

  • Nothing in the Node suite runs against the built bundle. v3 has a test:build script whose TEST_BUILD env var v3/src/test.mjs reads; 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 load build/cytoscape.umd.js) and benchmark/style-bundle.mjs. That matters more here than it sounds — see the __name note 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.js sets reuseExistingServer: !CI, so when an http-server is already listening on 3333 (a leftover npm run watch, or an earlier run's server), Playwright attaches to it and the test:playwright:build half of test:playwright:setup never 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. Run npm run test:playwright:build yourself before npx 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 visual Playwright project (playwright-tests/visual.spec.js): golden-image diffs against PNGs checked into playwright-tests/goldens/ plus live v3-vs-v4 parity diffs. After an intended visual change, regenerate goldens with UPDATE_GOLDENS=1 npx playwright test --project=visual and 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 on boundingBox() — which is the node box for every shape keyword, so all three passed with the shape under test swapped for ellipse. 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 devicelost actually 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 heapUsed by 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. A WeakRef per 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 separate npm 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 in deserializeElements measured 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.mjs runs test/!(types-*|node-test-setup).mjs — not test/modules/, not test/soak/. A new throw new exercised solely by a soak or fuzz spec reads as Node-reachable-and-never-run and fails the build, correctly. Pin each guard deterministically in test/ 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 the throw new sites in src the Node suite actually runs (round 30 took that from 34 never-executed to 0 Node-reachable). Since round 37.1 it gates, at zero tolerancenpm run test:throws, part of npm test: a Node-reachable site with no spec fails the build, and so does an UNREACHABLE/MISATTRIBUTED entry that no longer names a throw new line or carries no reason (the lists are keyed by file: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_COVERAGE offsets against the .mts files 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 even DA attributes 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: preset read 2388× because v4's preset does no work without a positions map 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 because cy.elementsInBox takes four numbers and silently answers the empty collection when handed a box object — a bug benchmark/curves.mjs had 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 under curve-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-bezier curves 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 switch over 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 in src/style.mts moving 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 measured background-color, which happened to be the 4th case, and so understated every other property. Second, the fix is a Map of 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-object style() — not on one property. And note that size alone is not the trigger: the write path's applyProp is 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 import src/ through tsx, which injects esbuild's __name wrapper — an Object.defineProperty on every closure creation. Round 33 published StyleEngine.readProp at 13–21× v3 from those suites; profiling it for round 34 found 23% of samples in __name alone, and through build/cytoscape.esm.mjs (where grep -c __name is 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: true and an explicit layout. Both halves bias, in opposite directions: a headless v3 defaults styleEnabled to false, so it does less work than v4 (which always applies its sheet), and v3's default layout is grid, so a plain cytoscape( { 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's cols: 3 — one of the two things that had made it unreadable — left the spec green, because grid derives 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 reads cy.width()/cy.height() must set headlessWidth/headlessHeight to 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 conservative fit() inflating compound graphs ~2×) was in src/ 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 loads debug/index.html, screenshots it, and compares against v3/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 back undefined. 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 existsSync guard and every spec stayed green — the catch beneath 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. .gitignore gained status for the round-46.5 build output, and it also matched scripts/status/ — the ten modules that are the build. git add -A staged nothing from them and the commit would have been unrunnable. Anchor a build-output pattern to the root (/status), and read what git status --short actually 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/href in all six harness pages resolved, and four of the debug page's seven networks were 404ing the whole time: their URLs live in debug/networks.js and are handed to fetch(). The page rendered nothing, said nothing (the Promise.all had no .catch), and no test noticed, because debug/ 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 under src/ against its src/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 in src/ 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's math.mts would 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 on about:blank, so a bare-page probe reads as "no GPU" on a box that has one; probe from a served page, which is what benchmark:renderer already does. Only the visual goldens pin SwiftShader, deliberately, and only for the WebGPU adapter — that pin says nothing about what hardware is present.

Documentation notes

  • EXECUTIVE_SUMMARY.md is derived from PLAN.md and is rewritten when a round closes (round 46.5). PLAN.md stays 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, and PLAN.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 then src/README.md is the maintained prose, MIGRATING.md is the v3->v4 porting guide and CHANGELOG.md the 4.0 summary — both of those ship in the package, so keep them true; test/modules/migration-guide.mjs checks 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 in core.mts/collection.mts are the section grouping a generator reads, so keep them accurate. Coverage is enforced: node scripts/jsdoc-coverage.mjs --verbose lists any public member of an exported class (or exported function) that lacks a comment, and test/jsdoc-coverage.mjs fails the build if one appears. Since round 31.2 the same script and gate also require an @throws tag on any public member whose body throws — these comments ship as hover text in dist/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 the PUBLIC_API files 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.ts hover 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.ts and test/modules/gpu-import-graph.mjs in src/README.md (neither matches test/gpu- or typescript/tests/gpu), three pointers at v3 sources that had moved under v3/, and one line-wrapped dist/\ncytoscape-gpu.d.ts. Extracting every rooted path from the markdown and testing it with existsSync found 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 found arrowBase's block stacked above lineOpacityConst's in dist/cytoscape.d.ts, so a consumer hovering one read the other. node scripts/jsdoc-coverage.mjs --verbose now 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 @param was 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.mjs is one) over a sentence asserting it. "Ported verbatim" is one of these claims, and the cheapest to check: round 43.4 said debug/'s compound fixture was v3's graph verbatim, and it had a sorted node list, four different edges and no cols: 3 — a diff of 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 @param at 221/221 and gated it, but auditParamTags descended class bodies only, while the script's own header defines a public member as a class member plus every top-level exported function — so wire.mts and columnar.mts, whose entire public surface is exported functions, sat outside a gate that read as complete, and two of them had no @param at all. Round 36 widened it to 229/229 — and round 37.3 found the same failure a third time: the widened pattern matched export function and export const f = but not export default function, so src/index.mts — listed in PUBLIC_API since round 26, and whose entire surface is the package entry point export 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.mjsscripts/build-dts.mjsdist/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:surface audits 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.