Status: IMPLEMENTED (PR #98, phased; each phase green through CI before the next). A per-tool adoption record for five tools, ported from the researched-then-adversarially-verified decision record in
libredb-database/docs/TOOLCHAIN.mdand adapted to Studio's reality: a Next.js 16 + React 19 + TSX application that ALSO ships as the dual-format npm package@libredb/studio(consumed bylibredb-platform). The database record is the rationale source of truth; this document records only what changes for Studio and why. Deviations surfaced during implementation are marked "as implemented".
Five tools, deliberately a subset of the database gate (no size-limit, commitlint, changesets, secretlint, license, etc.):
| Tool | Decision | Reason for Studio |
|---|---|---|
@biomejs/biome (format-only) |
ADOPT | No formatter today - the one unambiguous gap. Same as database. |
oxlint |
ADOPT | Fast Rust syntactic linter; a sub-second fail-fast layer in front of ESLint. |
typescript-eslint + eslint |
KEEP (Strategy A) | eslint-config-next stays as-is and keeps owning React/Next/hooks rules; oxlint is layered on top. |
knip |
KEEP | Already wired into the CI gate. Verify, do not rebuild. |
@arethetypeswrong/cli (attw) |
ADOPT | Higher value here than in database: 5 subpath exports x dual CJS+ESM x both .d.ts and .d.mts. |
| Dimension | libredb-database | libredb-studio |
|---|---|---|
| Type | Pure ESM TS library, synchronous core, ZERO runtime deps | Next.js 16 + React 19 + TSX (256 ts/tsx, 121 tsx), async-heavy (API routes, DB drivers) |
| Build | tsc + isolatedDeclarations, single entry |
tsup, dual ESM+CJS, 5 subpath exports (., /providers, /types, /components, /workspace) |
| Linting today | oxlint + type-aware-only ESLint | eslint-config-next (core-web-vitals + typescript + react-hooks) |
| Formatter today | Biome (present) | None (no prettier) |
| knip | present | present (in CI gate) |
| Tests | single bun test |
tests/run-tests.ts: one bun process per test file, several at a time, to avoid mock.module() cross-contamination |
Consequences:
- attw uses the DEFAULT profile, NOT
--profile esm-only- the package is intentionally dual CJS+ESM, so attw must verify CJS resolution too. - ESLint is NOT reduced to type-aware-only (the database move).
eslint-config-nextis the canonical Next linter and Studio ships as a Next app; reducing it would drop curated Next/React coverage. - CSS is excluded from the Biome formatter -
src/app/globals.cssis order-sensitive: it carries deliberately unlayered declarations that must outrank Tailwind's utility layer and shadcn's@layer baserules, and a formatter that reorders or re-nests them silently changes which rule wins (every gate stays green; only the rendered page differs). Keep CSS out of Biome's scope. - attw needs
build:lib(tsup), notnext build- do not mix the two in CI.
Not the Biome/Prettier default of 80. The 80 default is terminal/prose-era inertia; code is scanned, not read like prose. Reformatting the database repo from 80 to 120 was a net -245 lines because width-80 over-wrapped signatures and calls that fit cleanly on one line at 120. 120 is the JetBrains default and the modern wide-but-still-review-friendly choice (140 strains side-by-side review). Biome's JS formatter is configured: 2-space indent, double quotes, semicolons always.
Biome's type-aware lint rules use a re-implemented inference engine its own authors say "cannot guarantee
full coverage or alignment with TS." Linting stays with oxlint (syntactic) + ESLint (eslint-config-next,
including the type-aware Next rules). Biome's linter and assist are disabled.
- Add
.editorconfig(identical to database: 2-space, LF, UTF-8, final newline, trim trailing whitespace;*.mdexempted from trim since hard breaks use trailing spaces) so editors agree before Biome runs. - Branch
feat/toolchainoffmain(trunk-based).
Lowest-risk, path-clearing step. One-shot full-repo reformat.
biome.json:
Scripts:
"format": "biome format .",
"format:fix": "biome format --write ."As implemented, scope is not passed on the command line: biome.json's files.includes
(src/**, tests/**, e2e/**, scripts/**, bin/**, *.ts, *.mts, *.mjs) does the filtering, so the
scripts can stay a plain biome format . / biome format --write ..
Notes:
- Style is double-quote + semicolons: consistent with database and with the existing
eslint.config.mjs. The repo is inconsistent today (tsup.config.tsis single-quote / no-semi); the reformat unifies it. css.formatter.enabled: false(plusjson.formatter.enabled: false) keepsglobals.cssand JSON files untouched -globals.cssis order-sensitive (see the CSS note above) and formatting JSON would churn generated/config files for no gain.- Biome 2.6 added Markdown and YAML formatters, enabled by default.
markdown.formatter.enabled: falseandyaml.formatter.enabled: falsekeep the scope at JS/TS: the 2.6 Markdown formatter pads every table column to one width, which turns a one-row change into a whole-table diff, and the YAML formatter refuses the deliberately malformed seed fixtures undertests/fixtures/seed-connections/. - Deliverable: a single
chore(format): adopt Biome formatterPR (~256 files). Afterwards runbuild:liband verify BOTH modes (standalone + embedded), per the repo's UI-change rule. Coordinate timing to avoid clashing with open PRs.
Sub-second syntactic linter; a fail-fast layer in front of ESLint.
.oxlintrc.json (plugins + categories; the rule tuning below is the as-implemented set):
{
"plugins": ["typescript", "oxc", "react", "react-hooks", "jsx-a11y", "nextjs", "import"],
"categories": { "correctness": "error", "suspicious": "error", "perf": "warn", "pedantic": "off", "style": "off" }
}As implemented (the first run surfaced ~1300 findings; the breakdown drove these decisions):
- Disabled as false-positives / eslint-config-next-owned duplicates / idioms:
react/react-in-jsx-scope(936) - the project uses the automatic JSX runtime (jsx: react-jsx), so React need not be in scope. This is correct, not a workaround; eslint-config-next disables it too.import/no-unassigned-import(202) - intentional side-effect imports (setup/registration).no-underscore-dangle(64) - the_-prefix is the codebase's deliberate intentionally-unused marker.no-unused-vars(25) andreact-hooks/exhaustive-deps(2) - eslint-config-next already owns these as warnings; disabling in oxlint avoids duplicate/contradictory reporting.no-shadow(17) - same call as database; shadcn/ui vendored components shadow idiomatically.no-control-regex(4) - intentional control-char matching inlogger.tslog-injection sanitization.react/no-unstable-nested-components- the TanStack cell/header-renderer idiom (src/components/ResultsGrid.tsx).import/no-named-as-default- the monaco default+named export.
- Scoped to tests via
overrides(test idioms):typescript/no-extraneous-class,no-useless-constructor,no-new(constructor-throws assertions),no-constant-binary-expression(intentional falsy-class test data). jsx-a11yrules that fired (8) are downgraded towarn, not disabled: accessibility matters, but fixing ~60 a11y findings (many in vendored shadcn/ui, several needing markup/behaviour changes) belongs in a dedicated accessibility pass, not a tooling-adoption PR. They remain a visible, non-blocking backlog signal.- Three genuine bugs oxlint surfaced were FIXED, not silenced (see the Phase 2 commit): a dead
typeof ... || "unknown"fallback inprofile/route.ts, a dropped error cause inseed/config-loader.ts, and a useless regex escape inmerge-lcov.mjs. - The
unicornplugin is NOT added (taste noise, same call as database). NOTE: an unknown rule key is a HARD error in oxlint (it exits 1), not a warning -react/jsx-uses-reactdoes not exist and had to be removed; onlyreact/react-in-jsx-scopeis needed for the automatic runtime. - Scripts:
"lint:oxc": "oxlint", andlintruns oxlint first:"lint": "oxlint && eslint .".
eslint-config-next stays exactly as it is in eslint.config.mjs (it owns core-web-vitals, the typescript
config, and the react-hooks rules). Oxlint is layered on top for fast syntactic feedback; ESLint remains the
curated Next/React safety net.
As implemented, the narrow type-aware layer WAS added (it earned its place): a typescript-eslint flat-config
block scoped to the async-heavy code (src/app/api/**, src/lib/db/**) via parserOptions.projectService,
enabling @typescript-eslint/no-floating-promises, no-misused-promises, await-thenable as errors. It
immediately caught five genuine fire-and-forget bugs (async functions invoked in setInterval/setTimeout/process
signal handlers without handling the promise, in factory.ts, mysql.ts, postgres.ts), fixed with the
void operator. Scoping keeps lint fast; eslint-config-next still owns everything else.
Rejected for Studio: the database-style reduction of ESLint to type-aware-only with React/Next rules moved
to oxlint. For a shipping Next app the risk of losing eslint-config-next's curated coverage outweighs the
single-linter simplicity.
High value here: the package has 5 subpath exports, dual CJS+ESM, and emits both .d.ts and .d.mts - the
exact surface where types-resolution and CJS/ESM-masquerading bugs hide.
// scripts
"attw": "rm -rf .attw && bun pm pack --quiet --destination .attw && attw .attw/*.tgz --profile node16 --exclude-entrypoints styles.css",
"prepublishOnly": "bun run build:lib && bun run attw"Notes:
--profile node16(as implemented, a deviation from the planned default profile). The first run was green for the main.entry under all modes, but the four subpath exports (/providers,/types,/components,/workspace) failed ONLY on the legacynode10resolution algorithm (node16 CJS+ESM and bundler were all green). node10 cannot resolve subpath exports without redirect stubs, and the package requires Node >=24 and is consumed by modern bundlers (Next.js/platform), so supporting node10 is moot.--profile node16scopes the check to node16 CJS+ESM (the real consumer scenarios) and is more honest and precise than the broad--ignore-rules no-resolution, which could mask a real node16 failure.rm -rf .attwruns FIRST (not trailing): a trailing&& rmwould mask attw's exit code, and pre-cleaning drops a stale tarball from a previous version bump.- attw needs
dist/frombuild:lib, soprepublishOnlyrunsbuild:libbeforeattw. In CI usebuild:lib, nevernext build, before attw.build:lib, not baretsup— see the stylesheet note below; a baretsuphere published a dist with the stylesheet missing. --exclude-entrypoints styles.cssis not a waiver, it is a statement of scope. attw resolves entry points as modules;./styles.cssis a plain file, so attw reports it as unresolvable no matter how correct the export map is. Excluding it keeps the check green on a non-finding — and means attw is NOT what guards that entry point.tests/unit/packaging-theme-stylesheet.test.tsis, and it exists precisely because nothing else in the publish chain would have failed loudly.
src/app/globals.css is a Next.js concern and is not packaged. Everything under src/exports/ colours
itself through var(--studio-*), so an embedding host that imports the components and not the tokens gets
invalid computed values — grounds fall to transparent, hairlines to currentColor. The tokens ship as their
own file for that reason:
import "@libredb/studio/styles.css"; // required once, before any studio component rendersbuild:lib is tsup && node scripts/copy-theme.mjs, and the order is load-bearing: tsup runs with
clean: true, so anything staged into dist/ before it is wiped. The copy has to come after.
- Git-ignore
.attw/and*.tgz(packaging scratch). - CI: the
lint-and-buildjob runsbuild:libthenattw(plus a Biome format check) so the package surface is gated on every PR.
Each new tool (biome, oxlint, attw, typescript-eslint) gets a real package.json script or a config
import, so knip resolves them and counts them as used. As implemented, bun run knip was green with NO
knip.json change needed (database's finding held: scripts suffice, even for attw whose binary name differs
from @arethetypeswrong/cli, and typescript-eslint is seen via the eslint.config.mjs import).
As implemented in .github/workflows/ci.yml, the "Lint, Typecheck and Build" job runs, in order: Biome format
check (bun run format), linters (bun run lint, i.e. oxlint then ESLint), typecheck, knip, next build,
build:lib, then attw. oxlint is folded into bun run lint rather than a separate step. The pre-commit git
hook (.claude/settings.json) runs lint && typecheck && test && build and now transitively enforces oxlint
and the type-aware layer via bun run lint.
In every workflow, the type check, next build and the Playwright runs whose first server builds run Node with NODE_OPTIONS=--max-old-space-size=6144, as the release payload build has since #1535, because tsconfig.json takes every TypeScript file, tests included, and that program peaks within tens of MB of Node's default heap of about 4 GB on a GitHub runner (tests/unit/ci-node-heap.test.ts).
bun run lint ends with one more step, added after the phases above: node scripts/only-check.mjs, the
focused-test guard from #979, also reachable on its own as bun run only:check. A committed .only makes bun
run that one test in the file, report it honestly and exit 0, so every other test the file registers is absent
from the report, the totals and the verdict alike - no report can show that, so it is refused from the source
before the run. It sits beside the other scripts/*-check.mjs guards rather than in the lint pipeline for the
reason its own docblock gives: a finding is printed as a file and a line for an editor to jump to, not as a
rule id. Its scope is tests/ and e2e/, enumerated with git ls-files rather than a glob, so an untracked
draft cannot red a check that CI, which checks out tracked files only, cannot reproduce (#980).
mock.module() is process-wide with no undo, so a mock one layer installs reaches every file that
shares its process. bun run test is bun tests/run-tests.ts, which discovers every test file under
tests/ except tests/live/ and runs EACH ONE in its own bun process, several at a time, so no
file is ever reached by another file's mock. That is the whole reason the runner spawns a process
per file instead of handing a directory to bun test; the decision is argued in
tests/runner/execute.ts.
It used to be the other way round, and the cost was paid by contributors rather than by a gate.
bun run test ran bun test tests/unit tests/api tests/integration in ONE process while CI ran
the now-deleted tests/run-core.sh, one process per file, so the command CLAUDE.md documents for
pre-commit was red on a clean checkout and no gate could see it: 12849 pass, 42 fail, exit 1 on the
tree this change was written against, all of it one layer's mocks reaching a sibling that wanted the
real module. Those 42 are gone because the documented command and the gate now run the same way, not
because any mock was repaired. What the mocks are still wrong ABOUT is docs/BACKLOG.md D85.
Two bun options were measured on this suite and are NOT used. bun test --isolate (1.4.2) resets
the module registry per file inside one process, which would let the runner start far fewer
processes, and it does contain mock.module - but it is the subject of
oven-sh/bun#41655, a NAPI finalizer SIGSEGV that
reproduces serially on 1.4.2, and this suite loads three NAPI addons (better-sqlite3, oracledb,
@duckdb/node-api). bun test --parallel gave 0 fail in seven runs, then one run that hung for 17
minutes inside a synchronous helm spawn and one that failed 7 tests of
tests/unit/docker-bind-address.test.ts on 5000 ms timeouts; and
oven-sh/bun PR 41467, which stops a crashed worker
leaking its subprocesses, is merged but in no release, so 1.4.2 leaks them. A process boundary needs
no upstream fix. Re-probing --isolate when 41655 closes is docs/BACKLOG.md D86.
The process boundary is now structural, but the two cases it was first built by hand for are still the clearest statement of what it buys, so both measurements stay here.
The first is a file that must load a module before anything else does:
tests/isolated/factory.test.tsmocks six native driver packages and@/lib/ssh/tunnel, then imports@/lib/db/factoryunder a temporaryNODE_ENV=productionso the SIGTERM and SIGINT handlers the module registers on load can be captured by diffingprocess.listeners. Both steps happen once per process.- So it passes only while it is the FIRST file in its process to evaluate
@/lib/db/factory. Any earlier evaluation leaves it with an already-built module: no handler to capture, and unmocked drivers behindgetOrCreateProvider, whose cached entry throws inside theclearProviderCache()inbeforeEachand fails every remaining test in the file. - Measured 2026-09-13: the file alone is 99 pass 0 fail; adding a three-line probe under
tests/unit/whose only content is an import of@/lib/db/factorymakes it 44 pass 56 fail. A probe importing@/lib/ssh/tunnelor@/lib/db/compatibilityinstead reproduces nothing, and an empty probe reproduces nothing. Both CLI orders give the same 56, because bun does not run test files in the order they are listed. - It used to hold by accident: nothing else under
tests/unitimported the factory.tests/isolated/exports-shim.test.tshad already been moved out for the same reason. #789 added twotests/unitfiles that construct every provider through the real factory, and a fleet census cannot do its job without importing it, so the accident ran out.
The second is the mirror image: a file that must read a module the rest of a layer replaces.
tests/isolated/object-source-declarations.test.tscensuses what every provider declares, andtests/isolated/monaco-language-ids.test.tschecks every declared source language against the ids the installed Monaco registers. Both build providers through the REALcreateDatabaseProvider, which is the point: a census that read a double would certify the double.- Every file under
tests/api/mocks@/lib/dbwith acreateDatabaseProvider: mock()that answers undefined, which is that layer's standard pattern, and the mock reaches@/lib/db/factorythrough the index re-export. Measured 2026-09-13: the census besidetests/api/db-objects.test.tsis 3 fail, the language guard beside it is 1 fail, and each of them alone is 0 fail.
Moving those files into tests/isolated/ was the only way to get each a process of its own while a
runner named its groups by hand. It is no longer what protects them, and no new file needs
that treatment: the directory keeps its name and its files because docs/SECURITY.md,
sonar-project.properties and several source comments cite the paths, not because the runner treats
it specially.
The rule the two cases leave: a test file whose assumption is unshareable - it has to be the first to evaluate some module, or it has to read a module a whole layer replaces - says so in its own docblock, naming the module and what the failure looks like. It needs no directory and no registration, because the runner already gives it a process. What is still not allowed is the reverse repair: pushing one file's constraint outward onto every file that might legitimately import the module.
A test file that needs a tool a contributor may not have says so on its first line, and today there is one such tool.
The twelve chart tests that drive the real helm binary open with // @requires helm, and tests/runner/requirements.ts reads the marker before it starts a file.
Where helm is missing, or the chart's PostgreSQL subchart is not built, those files are not started, and the summary names them once under the reason and the command that fixes it; a selection made only of such files is an error, not an empty green run.
Every CI job that runs the suite sets LIBREDB_REQUIRE_HELM=1, which makes the same condition stop the run before anything starts, and tests/unit/helm-pin-matrix.test.ts fails if one of those jobs loses the variable.
The decision is per file rather than per test because each of those files needs Helm for everything it does, so skipping inside them would print about 180 test titles where twelve file names say the same.
Leaving them out costs no line coverage, because what they exercise is the chart's templates, which no lcov measures: measured on 2026-09-15 with helm hidden from PATH, 531 of the 543 files the tree held then ran, and the merged report was still 100% of its lines.
tests/unit/test-runner-requirements.test.ts holds the marker true of the tree in both directions: a file that spawns helm carries it, and a file that carries it spawns helm.
A file's counts come from the junit report bun writes for it, never from its console output.
Every child is spawned with --reporter=junit --reporter-outfile=<scratch>/file-N.xml, and those two options are appended AFTER any argument forwarded past --, because bun takes the last of a repeated option: a forwarded --reporter-outfile would otherwise redirect the report and leave every file looking as though it wrote none.
Reading the console is what the runner did before, and free-form text mixed with whatever the tests printed can be made to say anything: measured on bun 1.4.2, a file that registered no test and printed the line 1 pass was reported PASS with exit 0, and so was a test that printed a whole summary block on stderr and then called process.exit(0) so the tests after it never ran.
Both shapes defeat exactly the two guards that keep a red tree from turning green, "printed no summary" and "registered nothing".
--bail is the same defect from the other side: it prints no count line at all, while the report still carries the failure.
A report that is absent and one the parser cannot read are told apart, and neither ever becomes zero counts: the file fails, its line reads "no test report" or "unreadable test report", and the summary says how many files left no readable report and that their tests are not in the totals above it.
There is one shape no report can show, and the runner says so rather than pretending otherwise: bun honours a committed .only, so such a file writes an honest report naming that one test and exits 0, and the tests it never ran are absent from the report, the totals and the verdict alike (measured on 1.4.2).
That has to be refused before the run rather than read out of what the run wrote, and scripts/only-check.mjs
refuses it: see "CI and pre-commit integration" above.
Forwarding a flag to bun test works only when the runner is invoked directly and a selector comes first.
Measured on 1.4.2: bun run test -- --bail reaches the script as ["--bail"], because bun run removes the first --, and bun removes one that sits straight after the script path too, so bun tests/run-tests.ts -- --bail loses it as well.
bun tests/run-tests.ts tests/unit -- --bail is the form that arrives whole, and it is what the runner's unknown-option error names when it refuses a flag it does not own.
One flag the runner supplies itself: on Windows, and only there, every child is started with --timeout=60000 in place of bun's own 5000ms per-test default.
Measured across three CI runs on windows-latest, six tests in four unrelated files died between 5003ms and 5522ms: the flat-zip packer, the agent run store, the SQLite provider and the agent investigation.
Every one of them was doing filesystem work under the user's temp directory or spawning a process, and the failures came with transient Windows sharing violations (EPERM, EBUSY, ENOENT on a rename) that the libraries doing the work retry internally.
The retries are correct and they are not free, so 5000ms is not a budget chosen for that machine, it is a default that happens to sit just under what it costs; the same files pass in 2.5s on a run where the machine is not loaded.
Raising it hides no hang, because a file that genuinely stops is still killed and reported by the runner's own per-file budget, which is 300 seconds.
The same thing happened one order of magnitude up, which is why the flag says 60000 and not the 30000 it said first: the runner's own coverage test, which spawns a second runner that spawns bun with coverage and then merges the report, died at 30671ms on windows-latest against 16.3s for that whole file on linux-x64.
Linux and macOS are left on bun's default on purpose: Linux is the leg the coverage gate and every contributor run on, and it is where a test that really did get slow has to stay visible.
The flag is placed before the forwarded arguments, so -- --timeout=... still wins, bun taking the last of a repeated option; tests/unit/test-runner-cli.test.ts measures that precedence against the real bun binary rather than assuming it.
The titles of the tests a file skipped come from the same report, and their describe path from its nested <testsuite> elements rather than from the classname attribute.
classname lists those titles too, but bun joins them with " > " and writes a literal ">" inside a title as ">" as well, so no split rule can tell the separator from the character: measured on 1.4.2, splitting classname turned a describe titled "rows where count > 100" into "100 > rows where count".
The titles are worth printing at all because a skip in this repository states its reason in its title, and bun prints that title nowhere: piped, with FORCE_COLOR set, and under a real pty, the output carries the count and nothing else.
A todo is in neither the count nor the list, although bun writes one as <skipped message="TODO" /> as well: a todo has no reason to state and is already its own column in the totals line, and listing one under an "(N skipped)" header that does not count it would print two different numbers for one block.
A file whose report could not be read still prints the titles the parser reached before it stopped, under "unreadable report; it named N skipped tests" instead of a count it does not have.
Each child's stdout and stderr are captured rather than inherited, because several files run at once and interleaved output belongs to nobody, and each stream is bounded at BOTH ends by tests/runner/capture.ts: the first megabyte, the last megabyte, and one line naming how many bytes fell between them.
Both ends are kept because both are read, the head for bun's file header and the first failure diff, the tail for the rest of the diffs and bun's own per-file summary.
The megabyte is measured against this tree rather than guessed: across the 543 files the tree held when the measurement was taken on 2026-09-15, the largest prints 154,526 bytes on stdout, the largest stderr is 48,369 bytes and the median is 104 bytes, so nothing that runs here today is ever cut, and the worst case per running child is 4 MB.
Nothing is decided from that text, so a cut can never change a verdict.
A passing file's output is dropped once its line has been printed, which is what stops a whole run's output adding up: held to the end, four passing files printing 100 MB each peaked at 406 MB of RSS against 249 MB for one.
Every exit path writes its last line and waits for the bytes to leave the process before it calls process.exit.
bun writes to a pipe asynchronously and process.exit throws away whatever is still queued: measured on 1.4.2 through a piped stdout, a run whose failing file printed a megabyte lost about a third of that output and the whole summary with it, "Failed files:" and the re-run hint included, while the exit code stayed 1.
The wait has to be a real write whose callback resolves, because an empty write's callback does not wait for the queue and a drain event never arrives: write() returned false while writableLength was 0 and writableNeedDrain was false.
A non-empty write's callback does wait for everything queued before it, measured at 1 MB and at 10 MB and against a reader that started 1.5 seconds late, so one such write also drains the lines printed as the files landed.
What that covers is the lines this process writes: the per-file lines, the summary, the failed-file block and the re-run hint.
It does not cover a child's own console output, and nothing here can: measured under CPU load on 1.4.2, a failing file's bun test process drops part of its queued stdout as it exits, between 20 and 90 per cent of a megabyte, and it does so with no runner in the picture at all, so a failure diff read from a busy CI machine can still be truncated under an accurate verdict (docs/BACKLOG.md D96).
A write that fails because the reader has gone rather than because this process could not write is not the runner's problem and does not become its exit code: an EPIPE from | head -1 or a closed terminal leaves the run's own 0, 1 or 128 plus signal in place, and exit 2 stays for a write the runner really could not make, a full disk for instance (measured against /dev/full, which reports ENOSPC and does exit 2).
SIGINT, SIGTERM, SIGHUP and SIGBREAK all end a run the same way: no further file is started, the files still running are killed, the scratch directory is removed, Interrupted (SIGNAL). is written, and the runner exits 128 plus the signal's number, so 130, 143 and 129, measured end to end for those three, and 149 for SIGBREAK, which only Windows can deliver and which is therefore pinned over the mapping rather than over a run.
The number is taken from the platform's own os.constants.signals where the platform names the signal, because that is the number its shell will report, and from the table written out in tests/runner/signals.ts where it does not: measured on 1.4.2, that table has no SIGBREAK on Linux or macOS, and 128 + undefined is NaN, which process.exit refuses with a RangeError thrown from inside the listener.
SIGTERM is what timeout(1), docker stop, Kubernetes and systemd send, and what bun run test forwards; SIGBREAK is Ctrl+Break, which GitHub Actions on Windows sends 7.5 seconds after Ctrl+C, and naming it is valid on every platform.
Handling only SIGINT, as this did, meant a run stopped any other way printed nothing at all and left its junit scratch directory behind in the temporary directory.
A scratch directory that cannot be removed is named on stderr and exits 2, neither swallowed nor thrown: measured on 1.4.2, a throw from inside a signal listener left the process RUNNING and the queue started the next file.
The default disposition is put back before the handler awaits anything, so a second signal kills the process outright instead of starting a second cleanup over the first, and the last write is raced against a three-second grace.
That grace is there because a reader that has stopped reading blocks the write behind a full pipe: measured on 1.4.2, the process then sat out the whole stall and survived a second SIGINT, a SIGTERM and a SIGHUP.
When the grace runs out the run still ends with its own exit code and its scratch directory gone, and the only thing lost is the Interrupted line, which the reader that was not reading would not have seen anyway.
Discovery refuses a symbolic link or junction under tests/ by name instead of walking through it.
It used to skip one in silence, because a readdirSync Dirent for a link reports neither isDirectory() nor isFile(), so a linked directory and a file link named *.test.ts both fell out of the selection with nothing printed; entries are classified with lstat now, which also reports a Windows junction as a link.
Refusing rather than following is the other half of that decision: following a link can run files from outside the repository, loop on a link to a parent, or list one file twice under two names, while skipping it drops its tests without a word.
The default concurrency is one job per available CPU, and it is not memory-aware.
availableParallelism() in bun 1.4.2 follows CPU affinity and a cgroup v2 CPU quota, measured on a 20-core host: 2 under taskset -c 0-1, and 2 under CPUQuota=200%.
So a container with a CPU limit already gets as many jobs as it has CPU, and a laptop, a Codespace and a GitHub-hosted runner are all sized by that.
What the default does not read is a memory limit.
A test file's peak RSS over a 32-file sample runs from 58 MiB to 341 MiB with a median of 100 MiB, and under real concurrency the heaviest layer adds about 80 to 90 MiB per extra job: tests/components measured 599 MB at 4 jobs, 998 MB at 8 and 1599 MB at 16.
In a container with a memory limit and no CPU limit on a many-core host, pass --jobs=N.
A run that did not is readable rather than mysterious in the one case where the kernel kills a single child: that file's line names the OOM killer and --jobs=N in its reason, because the runner sends SIGKILL itself only to a child that outran its budget, and that child is reported as timed out instead.
Where the cgroup kills the whole group the runner dies with its children, so that reason is never printed and no file is named at all.
Under Kubernetes's default memory.oom.group the kernel SIGKILLs every process in the cgroup, and SIGKILL cannot be handled, so the output simply stops after the files that had already landed and the exit code is the only signal: reasoned from the kernel's semantics, not measured here.
Under systemd's default OOMPolicy the unit is stopped with SIGTERM instead, which this runner now takes: measured before that handling existed, such a run ended at exit 143 with no summary and its scratch directory left behind, so it now ends at the same 143 with Interrupted (SIGTERM). and nothing left in the temporary directory.
Neither shape names the file that exhausted the memory, which is the second reason a memory-aware default is worth having.
That default is docs/BACKLOG.md D95.
Every workflow job installs dependencies through the local composite action
.github/actions/bun-install, never with a bare
bun install:
- name: Install dependencies
uses: ./.github/actions/bun-installIt restores bun's global package cache (~/.bun/install/cache, keyed on bun.lock with a
restore-keys prefix so a lockfile bump warms from the previous entry) and then runs
scripts/ci-install.sh, which retries bun install --frozen-lockfile
with a linear backoff and logs a ::warning:: per failed attempt.
Why: bun install has no retry of its own, so one failed package download fails the step with
error: Fail extracting tarball for <package>. On 2026-07-29 that broke three runs in a single day
— two CI runs and the npm publish of release 0.9.61, which left the release published on GitHub
while npm still served the previous version. Nothing was reproducible or code-related; a re-run of
the same commit passed. The cache is the other half of the fix: warm, the packages are not fetched
at all, which removes the exposure rather than retrying through it.
The action deliberately does not set up Bun — each job's existing Setup Bun step keeps owning
the pinned version, so the action makes no assumption about being adjacent to it. It requires bun on
PATH and the repository checked out. The retry policy lives in one place because there are 21
install sites across 9 workflows; duplicated, it would drift immediately.
tests/unit/ci-install.test.ts covers the policy against a stub bun (first-try success, retry then
success, exhaustion, custom attempt count, no dead sleep after the final attempt).
- Biome formatter +
.editorconfig(one-shot reformat PR). - Oxlint (tune rules to green).
- ESLint Strategy A wiring + optional type-aware layer.
- attw +
.gitignore+prepublishOnly+ CI packaging step. - knip verification.
Each phase must end green on the repo's checks - bun run lint, bun run typecheck, bun run test,
bun run build - PLUS bun run build:lib and a both-modes (standalone + embedded) verification for any
phase that can affect output.
- Big-bang reformat diff churn - coordinate with open PRs / platform; one PR; verify both modes.
- Order-sensitive CSS - keep
globals.cssout of Biome; verify the embedded (StudioWorkspace) render path as well as the standalone app after the reformat. - Oxlint React noise on the first run - expect minor rule tuning.
- attw must use
build:lib, notnext build. mock.module()test isolation is unaffected by these static tools.
Proposed in #422 for lucide-react, recharts, @xyflow/react, framer-motion and date-fns.
Measured before merging, and it changed nothing: first-load JS for / was 6030 KiB across 37 files
with the option, and 6030 KiB across 37 files without it — identical, measured in Chrome against
bun run build + bun run start, summing every JS response from /login through the studio being
interactive.
Two reasons it cannot help here. lucide-react, recharts and date-fns are already in Next's own
default list (next/dist/esm/server/config.js), which is concatenated with whatever the config names,
so three of the five entries were never doing anything. And the remaining two are not barrel-of-icons
packages, which is the shape the transform exists for.
Do not re-add it without a number. The measurement takes one build and one page load.
@biomejs/biome@^2.5, oxlint@^1.71, @arethetypeswrong/cli@^0.18.4. eslint / eslint-config-next /
typescript-eslint / knip stay at their current Studio versions.
The merged coverage/lcov.info sits at 100% lines (#192/#195/#196) and CI enforces it:
scripts/check-coverage.mjs fails the Unit & Integration Tests job on any zero-hit DA record,
printing the uncovered file:line ranges. Local check: bun run test:coverage && bun run coverage:check.
bun run test:coverage is the same runner as bun run test with --coverage --merge-into=coverage/lcov.info: one lcov per TEST FILE under coverage/raw/, merged by
scripts/merge-lcov.mjs at the end. Measured 2026-09-15 on Linux, 8 files at a time: about 60
seconds, and the merged report is 100% of 57157 lines. Two mechanics of that merge exist for
Windows: the report list is handed over as a manifest (--inputs-from=<file>) because 500-odd paths
do not fit in a Windows command line, and merge-lcov.mjs normalises a backslash SF: path, so
coverage produced on Windows merges as the same file as coverage produced on Linux instead of as a
second file the src/ filter then drops.
Holding 100% honestly requires knowing how bun measures:
- Per-function granularity (V8 semantics). Functions that executed in a process get a precise executable-line map; functions that never ran are reported as coarse whole-span blocks whose DA sets include type annotations and JSX text lines. A process that merely loads a module (a mocked-away child, a transitive import) therefore claims lines as "coverable" that no exercising process does.
- Authority-universe merge (
scripts/merge-lcov.mjs, tested intests/unit/merge-lcov.test.ts): per file, the record with the most executed lines decides which lines are coverable; per-line hit counts still take the max across all records, so a secondary record (e.g. the mobile-drawer test of a desktop-rendered component) keeps contributing. Without this rule, load-only records surface phantom uncovered lines that no test can ever close. Per-file processes made this rule carry MORE than it used to: a run merges one report per test file rather than one per hand-written group, so a source file is now described by every test file that so much as imports it, and the load-only records among them outnumber what a grouped run produced. The max-hit record is what keeps those extra descriptions from adding uncoverable lines. COVERAGE_EXEMPT_FILES(tests/runner/discover.ts): the two files that pull in a module chain without exercising it - the exports CJS shim loads every component, and the Monaco loader file imports the editor to observe a call it makes at module scope - run without coverage collection entirely. The docblock beside the list is where the reason lives, with what the shim alone would otherwise contribute (31 phantom uncovered lines insrc/lib/llm/factory.ts).- Non-executable-line strip (
merge-lcov.mjs): bun emits DA records for blanks, comments, and bare punctuation; these are removed against the actual source before SonarCloud reads the report. - Diagnosis recipe: when a file shows stubborn uncovered lines, compare its records across
coverage/raw/file-*/lcov.info- one directory per test file, numbered by that file's position in the sorted selection, so line N ofbun tests/run-tests.ts --listis what wrotefile-N. If the zero lines are absent from the record with the most hits, they are measurement phantoms (fix the merge inputs), not test gaps. - Mock fidelity over stubs: hover/portal-dependent branches are closed by making test mocks
honor the real library contract (recharts
Tooltiprenders itscontentelement with an active payload; the select mock drivesonValueChangethrough clickable items; dropdown items honor RadixonSelect) instead of reshaping production code.
src/exports/index.js (one-line CJS shim) is the single sonar.coverage.exclusions entry — it is
smoke-tested functionally in tests/isolated/exports-shim.test.ts.
vscode-extension/ is a second package beside the Bun root, with its own toolchain and its own CI job.
It bundles LibreDB's provider layer from the root's src/, but it is not a workspace of the root's package manager, so the root's bun install does not install its packages.
The extension's package manager is npm, because vsce reads a package's dependencies only through npm or Yarn 1.
vscode-extension/package-lock.json is its lockfile; a dependency is added with npm install --save-exact (or --save-dev) inside the directory, never by editing the lockfile.
CI installs it with npm ci --ignore-scripts.
The root's packages (React, the drivers) resolve from the root's node_modules, so bun install at the root runs first.
@vscode/test-cli runs Mocha only, so one framework serves the unit, integration and e2e legs.
tsx loads the TypeScript and honours tsconfig.test.json's paths, which send @/ to the root's src/ and vscode to test/fakes/vscode.ts; test/setup/register.cjs points tsx at that file.
The editor modules' unit tests drive FakeTextDocument and FakeTextEditor from test/fakes/documents.ts, which compute offsets and positions over a text's lines as VS Code does for \n line ends.
Every Mocha configuration sets forbid-only (.mocharc.json, test/integration/.mocharc.json and .vscode-test.mjs), and SCAN_ROOTS in scripts/only-check.mjs reads vscode-extension/test, so bun run lint refuses a focused test there too.
.vscode-test.mjs also sets failZero: @vscode/test-cli hands Mocha an empty file list without an error, so an e2e glob that matches no file, a moved test/e2e/smoke/ for one, would otherwise pass with nothing run.
The webview's unit files are .tsx and register happy-dom per file, through useDom() in test/setup/dom.ts at the top of a describe block, so no host or backend test ever sees a DOM global.
tsconfig.test.json sends sonner and @/components/copy-button to the webview's shims, so the unit leg renders ResultsGrid against what the bundle carries.
React and @testing-library/react resolve from the root's install.
c8 measures the unit leg only (.c8rc.json: all: true over src/**), and the root's scripts/check-coverage.mjs holds coverage/lcov.info to 100% lines: npm run test:unit && npm run coverage:check.
The root gate, Sonar and Codecov never see these lines, because scripts/merge-lcov.mjs keeps the root's src/ records only.
The gate is line-granular: a callback written on the line that registers it counts as covered once the registration runs, so its test alone holds it.
A callback whose behaviour the gate must hold keeps its body on its own line, as startBackend in src/backend/start.ts does for its disconnect listener; without the case that closes the channel, the gate names that line.
- Unit:
npm run test:unit, on Node 24, against the fakevscode. - Integration:
npm run test:integrationforks the built child fromLIBREDB_BACKEND_RUNTIME=node(the job's Node) orLIBREDB_BACKEND_RUNTIME=vscode(the pinned stable binary oftest/vscode-versions.json, downloaded into.vscode-test/and run in Node mode withELECTRON_RUN_AS_NODE=1), so a child that works on Node but not on VS Code's own runtime fails here (P5).test/integration/backend-child.test.tsruns against PostgreSQL, SQL Server, MySQL and MongoDB containers thattest/integration/containers.tsstarts from the images and environment of the same services indatabase-compose.yml, so the leg and the dev stack cannot drift. Each container is capped for a shared machine (ENGINE_LIMITS: 1 CPU and512mfor PostgreSQL, 1 CPU and1gfor MySQL and MongoDB, 2 CPUs and2gfor SQL Server, whoseMSSQL_MEMORY_LIMIT_MB=1536keeps it inside that cap), publishes its port on127.0.0.1only, and carries the labelorg.libredb.vscode-extension.integration=<pid>-<epoch ms>. The leg waits up to 180 s for each port, then for aconnection/testthrough the child that succeeds, retried every second, because SQL Server opens its port before it accepts a login. It removes each container by its id inafter, never by label or name, since another checkout's run may be live on the same machine; a killed run's leftovers are listed withdocker ps --filter label=org.libredb.vscode-extension.integrationand removed by hand. A local run therefore needs Docker on the machine. - E2e:
npm run test:e2eruns@vscode/test-clion the floor and stable builds oftest/vscode-versions.json, underxvfb-run -aon Linux. The build machine has no Xvfb, so the leg runs locally in anode:24-bookwormcontainer that installsxvfband VS Code's libraries and runsxvfb-run -a npm run test:e2eas the image'snodeuser. Mount the checkout at a short path such as/w: VS Code listens on a socket under.vscode-test/user-data/, and a socket path over the 107-byte Unix limit fails withlisten EINVAL.test/e2e/backend-lifecycle.test.tsstarts PostgreSQL through Docker, so the local e2e container also runs with--network hostand-v /var/run/docker.sock:/var/run/docker.sockand installsdocker.iobesidexvfb; the smoke subset needs no Docker access. Thenodeuser reaches the socket through a group with the socket's group id, created in the container and passed torunuser -G, as the command below does. The leg runs a second configuration per build,vscode-<version>-workspace, which openstest/e2e-workspace/fixtureso the connections setting can be checked against a workspace value. That configuration also runstest/e2e-workspace/connections.test.ts, which drives the connection commands through scripted quick inputs against PostgreSQL in Docker: it andbackend-lifecycle.test.tseach need a host whose extension has started no child yet, and Add Connection leaves the extension's own child running. The smoke subset runs neither the workspace configuration nor any file that needs Docker.@vscode/test-electronstarts every host with--disable-workspace-trust, so the leg sees a trusted workspace only.activateexports nothing, so a test reaches what the extension created throughtest/e2e/tree-capture.ts, which wrapsvscode.window.createTreeViewbefore the extension activates and keeps the Connections view it captured onglobalThis. The editor cases read the same way throughtest/e2e/host-capture.ts, which wrapsvscode.window.createStatusBarItem,vscode.window.createOutputChannelandvscode.commands.executeCommandbefore the extension activates and keeps everysetContextcall, the LibreDB log's lines and the connection status bar item onglobalThis. The same file also wrapsvscode.window.registerWebviewViewProviderfor the Results view, sotest/e2e/results-view.test.tsandtest/e2e/smoke/results-view.test.tsread each resolve, every message the host posts, every message the real webview sends and the view's description, and hand the host a message as the webview would.test/e2e/first-run.test.tsdrives the first-run flow once per engine against PostgreSQL, SQL Server, MySQL and MongoDB in Docker: the empty view, Add Connection through the real quick inputs, New Query and Run, the tree down to a table's columns, Select Rows to the Results view, Load More and Cancel. It does not repeat the cases that already pass intest/e2e/editors.test.tsandtest/e2e/results-view.test.ts. The coexistence subset,LIBREDB_E2E_SUBSET=coexistence, runstest/e2e-coexistence/coexistence.test.tsalone, withms-mssql.mssqlinstalled beside LibreDB.configurationsForin.vscode-test.mjsgives it onevscode-<version>-coexistenceconfiguration per build, whose--extensions-dirisCOEXISTENCE_EXTENSIONS_DIR(.vscode-test/extensions-coexistence):@vscode/test-cliinstalls everyinstallExtensionsentry into the shared.vscode-test/extensions, so mssql installed there would load into every other e2e host.scripts/coexistence-extensions.mjsinstalls the pinnedCOEXISTENCE_EXTENSIONSinto that directory from the Marketplace beforevscode-teststarts, and installs nothing outside the subset. mssql declares an extension pack of four more extensions, soCOEXISTENCE_EXTENSIONSpins each of them beside mssql in the same install call, andlistingViolationsfails the run when the directory then lists anything but that pinned set. Because it downloads from the Marketplace, the subset runs in the daily workflow and by hand, never in the pull request job. - Smoke: the e2e leg with
LIBREDB_E2E_SUBSET=smoke, on macOS and Windows, with no Xvfb and no database. The smoke subset runstest/e2e/smoke/tree-root.test.ts, which needs no Docker; the full leg also runstest/e2e/connections-tree.test.ts, which does. - Browser:
npm run test:browserruns the results webview in Chromium with Playwright, afternpm run build.@playwright/testis pinned to the root's version, becausenpm runfinds only the package's own binaries, and the same version shares the root's Chromium. Itsplaywright.config.mjsstarts no server:test/browser/harness.tsservesdist/webview/throughrenderResultsHtmlwithpage.routeand a fakeacquireVsCodeApi, so the bundle runs under the view's real CSP. It setsforbidOnly, and it does not count toward coverage.
Beside the legs, npm run test:vsix runs scripts/vsix-install-check.mjs after npm run package: it installs the packed VSIX with the stable build's CLI into a scratch extensions and user data directory and reads --list-extensions --show-versions, because the e2e leg runs dist/ from the development path, which proves the files and not the package.
The local e2e run, from the checkout's root after npm run build in vscode-extension/:
docker run --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -v "$PWD:/w" -w /w/vscode-extension node:24-bookworm bash -euc '
apt-get update -qq
apt-get install -y -qq --no-install-recommends xvfb xauth libasound2 libgbm1 libgtk-3-0 libnss3 libxkbfile1 libsecret-1-0 docker.io >/dev/null
gid=$(stat -c %g /var/run/docker.sock)
getent group "$gid" >/dev/null || groupadd -g "$gid" hostdocker
runuser -u node -G "$(getent group "$gid" | cut -d: -f1)" -- env HOME=/tmp/e2e-home xvfb-run -a npm run test:e2e'LIBREDB_VSCODE_VERSION replaces the pinned builds of the integration and e2e legs with one version, which the Insiders run sets to insiders.
vscode-jsonrpc 10.0.0 is the bundles' one runtime package: the host bundle's metafile check refuses any other node_modules input, and test/unit/manifest.test.ts holds it as the package's only dependency.
CHILD_EXTERNALS in scripts/bundle-checks.mjs names what the child bundle leaves to a runtime require: the drivers of the engines outside v0 (bun:sqlite, @duckdb/*, db2-node, oracledb), cpu-features, proper-lockfile and any *.node path.
OPTIONAL_REQUIRES names the nine packages the bundled drivers require inside their own try and do not install (pg-native from pg, cardinal from mysql2, kerberos from mongodb and cassandra-driver, and six more from mongodb), each with the packages allowed to require it.
The child's metafile check accepts such an import only as a require() from inside a package its entry names, so the same name required from anywhere else fails the build.
renderNotices in scripts/notices.mjs writes THIRD_PARTY_NOTICES.txt from every package that contributed an input to a bundle, and refuses a package whose licence it cannot place.
A package passes with an id on LICENSE_ALLOWLIST (Unlicense among them), with an OR expression one of whose plain ids is on the list, or with npm's legacy licenses array when every type in it is on the list; an expression with AND or WITH stays refused.
A package that ships no licence file takes its text from third-party-licenses/<name>@<version>.txt, whose first line, Source: <URL>, names where the text was copied from; a version bump finds no file and fails the build until the text is checked again.
LICENSE_OVERRIDES records, by exact name@version, the reviewed licence of a package whose package.json names none, and where that licence is stated.
A dependency bump that trips one of these rules is a reviewed change to that rule, never a widened pass.
The Results webview is built from LibreDB's source, not from the published package: src/webview/main.tsx is one IIFE for the browser at es2022, dist/webview/results.js.
Three module shims replace what cannot run in a webview, listed in WEBVIEW_SHIMS in scripts/webview-shims.mjs: sonner inserts an un-nonced <style> the CSP blocks, so each toast posts notify to the host instead; @/components/copy-button would write the clipboard itself, so its writeToClipboard asks the host to; and @/lib/storage throws, since the view keeps its state through getState and setState.
React, react-dom and get-nonce come in one copy each from the root's install, and the extension never declares them: a second copy would put two Reacts in the bundle.
webviewViolations in scripts/bundle-checks.mjs holds the metafile to that boundary inside esbuild.mjs, as the host's and the child's checks are held, and its unit test reads fixture metafiles, so the unit leg never reads dist/.
The stylesheet, dist/webview/results.css, is built by @tailwindcss/cli, pinned to the root's tailwindcss, from src/webview/results.css.
Its @source list is explicit, and sourceCoverageViolations in scripts/webview-css.mjs fails the build when a bundled file under src/components/ lies under none of them.
src/webview/vscode-bridge.css maps LibreDB's tokens onto the --vscode-* variables every webview receives, so one mapping serves every theme.
tailwindcss is in the notices, because its preflight is compiled into the stylesheet.
The build first bundles scripts/engine-icons.tsx into out/scripts/ and runs it, before it bundles the extension.
The script writes media/engines/**, a light and a dark logo per engine and index.json with each engine's label, from DB_UI_CONFIG with the root's React and react-dom/server, so npm run build needs the root's bun install first.
It fails the build unless DB_UI_CONFIG and SHIPPED_DATABASE_TYPES hold the same set of types.
media/engines/ is a build artifact that vscode-extension/.gitignore ignores and every build writes again, while the activity bar icon media/libredb.svg is committed.
The build also writes the 128 by 128 Marketplace icon media/icon.png from the brand mark public/logo.svg through scripts/marketplace-icon.mjs, and vscode-extension/.gitignore ignores it too.
- The root
tscexcludesvscode-extension(tsconfig.jsonexclude);npm run typecheckchecks it in its own job, where Mocha's globals and thevscodetypes exist. - Biome formats
vscode-extension/**minusdist,out,dist-vsix,.vscode-testandcoverage(biome.jsonfiles.includes). - oxlint (
.oxlintrc.jsonignorePatterns) and ESLint (globalIgnoresineslint.config.mjs) lint it minus the same five build paths.vscode-extension/test/setup/register.cjsis the repository's first.cjsfile, so the ESLint block that tunes eslint-config-next's rules names eslint-config-next's own files pattern. - knip has a
vscode-extensionworkspace inknip.jsonbeside the root'sworkspaces["."]. Its entries list neither bundle entry noresbuild.mjs, which knip finds without them (it reports them as redundant entry patterns,src/webview/main.tsxamong them); an export of the fakevscodemodule that only the code under test reaches, throughtsconfig.test.json's alias, which knip does not read, carries@public(todaywindow). Itsprojectand test entries take.tsxas well as.ts. The workspace turns knip's Playwright plugin off and namestest/browser/**/*.spec.tsas an entry instead: the plugin loadsplaywright.config.mjs, which requires the extension's@playwright/testafter knip has loaded the root's, and Playwright refuses a second copy. It ignores exactlyget-nonce,@radix-ui/react-use-layout-effectandsharp. The webview and its tests import the first two from the root's install on purpose; declaring them in the extension would install a second copy beside React's.scripts/marketplace-icon.mjsrenders the Marketplace icon through the root'ssharp, the copy Next.js installs as its optional dependency, so the build reads one brand source with one renderer. The requiredLint, Typecheck and Buildjob installs the extension's packages withnpm ci --ignore-scriptsbefore knip: measured on a clone with only the root installed, knip otherwise reportsc8,mochaandvscode-testas unlisted binaries andc8and@vscode/test-clias unused dependencies. Itsactions/setup-nodestep gives that install npm's cache, keyed onvscode-extension/package-lock.json, and runs the job's Node tools on Node 24. Biome, oxlint, ESLint and the roottscpass there without them.
.github/workflows/vscode-extension.yml is a reusable workflow that ci.yml calls as the vscode-extension job.
Its Linux job runs the typecheck, the build with its metafile checks, the unit leg and its gate, the browser leg, both integration runtimes, the e2e leg, npm run package and npm run test:vsix, and uploads the VSIX, failing when there is none; its smoke job runs on macos-latest and windows-latest.
.github/workflows/vscode-extension-release.yml calls the same file as its gates job on a pushed vscode-extension-* tag, so a release passes the gates a pull request does; its release runbook is in docs/DISTRIBUTION.md, "VS Code extension (Marketplace)".
The browser leg's Chromium comes from bunx playwright install --with-deps chromium at the repository root, because the job does not run in Playwright's image; the root's Playwright is the extension's version, so the extension's own Playwright finds that browser.
The smoke and Insiders runs have no browser leg: the webview is the same bytes on every OS and VS Code build.
It caches Bun's packages through .github/actions/bun-install, npm's through actions/setup-node keyed on vscode-extension/package-lock.json, and the VS Code builds through actions/cache keyed on vscode-extension/test/vscode-versions.json.
The VS Code cache holds .vscode-test/vscode-* only: the e2e leg's profile in .vscode-test/user-data and .vscode-test/extensions stays out of it, so no run starts from a profile an earlier run left, and a fork's pull request restores none.
.github/workflows/vscode-extension-insiders.yml runs the build, the vscode integration runtime and the e2e leg against VS Code Insiders daily and on demand, so an Electron or Node bump shows up before stable ships it; test/vscode-versions.json is bumped by hand.
Its coexistence job runs the coexistence subset on the pinned builds on the same schedule.
Dependabot watches vscode-extension/ through its own npm entry, with @types/vscode held at the engines.vscode floor.
The job is not a required check until the owner adds it to main's branch protection.
{ "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "includes": ["src/**", "tests/**", "e2e/**", "scripts/**", "bin/**", "*.ts", "*.mts", "*.mjs"] }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 120 }, "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always" } }, "css": { "formatter": { "enabled": false } }, "json": { "formatter": { "enabled": false } }, "markdown": { "formatter": { "enabled": false } }, "yaml": { "formatter": { "enabled": false } }, "linter": { "enabled": false }, "assist": { "enabled": false } }