diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 0f220f86..185a0d11 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -26,7 +26,7 @@ jobs: - name: Format (Oxfmt) run: bun run format:check - - name: Lint and assist (Biome) + - name: Lint (Oxlint) run: bun run lint - name: Anti-slop (Oxlint, baseline-aware) diff --git a/biome.jsonc b/biome.jsonc deleted file mode 100644 index 66dd2417..00000000 --- a/biome.jsonc +++ /dev/null @@ -1,65 +0,0 @@ -{ - // @norvalbv/devkit — ROOT biome config (lints devkit's OWN authored code). - // - // This is the package linting ITSELF, so it extends the base preset by its - // RELATIVE FILE PATH ("./biome/base.jsonc") — NOT the bare `@norvalbv/devkit/biome/base` - // exports subpath. A package cannot reliably self-resolve its own exports map while - // being linted in-place (the name maps into node_modules, which is not the working - // tree here), so the relative path is the correct seam for self-linting. Consumers - // still extend the bare subpath — see biome/base.jsonc's header. - "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json", - "extends": ["./biome/base.jsonc"], - "files": { - "includes": [ - "cli/**", - "gate-engine/**", - "tsconfig/**", - "biome/**", - "*.mjs", - "*.json", - "*.jsonc", - "!node_modules", - // skills/ SKILL.md + references are vendored markdown (out of scope). But the helper - // SCRIPTS under skills/**/*.mjs ARE authored here and ship to consumers whose hooks run - // biome on them, so we hold them to the same standard (a re-include can't rescue a - // dir-negated path under gitignore semantics — so this is a positive include, not "!skills" - // + an exception). Only *.mjs is pulled in; *.md/*.sh stay out. - "skills/**/*.mjs", - // templates/ holds literal emitted configs (verbatim fixtures) that may not - // match devkit's own formatting — exclude. - "!templates", - "!bun.lock", - // dist/ is build output — already unlinted (not in the includes allowlist), but a - // single "!" still lets the LSP scanner index it for the module graph, and it is - // rewritten wholesale on every build. Force-ignore ("!!") keeps the scanner out - // entirely, per Biome's own guidance for output folders. - "!!dist" - ] - }, - "overrides": [ - { - // The gate-engine bins are CLIs — console output is their legitimate UX, not a - // suspicious leftover. Turn noConsole off for the whole authored surface. - "includes": ["**"], - "linter": { - "rules": { - "suspicious": { - "noConsole": "off" - } - } - } - }, - { - // Tests legitimately use inline regex literals in expect(...).toMatch(/…/) — - // the top-level-regex perf rule is noise there (not a hot path). - "includes": ["**/*.test.mts"], - "linter": { - "rules": { - "performance": { - "useTopLevelRegex": "off" - } - } - } - } - ] -} diff --git a/bun.lock b/bun.lock index a0b30a39..6ae55b01 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,6 @@ "ts-morph": "^28.0.0", }, "devDependencies": { - "@biomejs/biome": "^2.5.6", "@types/node": "^25.9.3", "@vitest/coverage-v8": "^4.1.10", "husky": "^9.1.7", @@ -38,24 +37,6 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - "@biomejs/biome": ["@biomejs/biome@2.5.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.6", "@biomejs/cli-darwin-x64": "2.5.6", "@biomejs/cli-linux-arm64": "2.5.6", "@biomejs/cli-linux-arm64-musl": "2.5.6", "@biomejs/cli-linux-x64": "2.5.6", "@biomejs/cli-linux-x64-musl": "2.5.6", "@biomejs/cli-win32-arm64": "2.5.6", "@biomejs/cli-win32-x64": "2.5.6" }, "bin": { "biome": "bin/biome" } }, "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA=="], - - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw=="], - - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA=="], - - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg=="], - - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw=="], - - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA=="], - - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A=="], - - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA=="], - - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ=="], - "@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="], "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], diff --git a/cli/__tests__/lint-policy.test.mts b/cli/__tests__/lint-policy.test.mts new file mode 100644 index 00000000..0a4c03aa --- /dev/null +++ b/cli/__tests__/lint-policy.test.mts @@ -0,0 +1,45 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { testSpawnSync as spawnSync } from './_helpers.mts'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const OXLINT = join(ROOT, 'node_modules', '.bin', 'oxlint'); +const OXLINT_CONFIG = join(ROOT, 'oxc', 'oxlint.devkit-lint.json'); +const roots: string[] = []; + +function fixture(name: string, source: string) { + const root = mkdtempSync(join(tmpdir(), 'devkit-lint-policy-')); + roots.push(root); + const path = join(root, name); + writeFileSync(path, source); + return path; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('Devkit lint ownership', () => { + it('makes the native Oxlint profile fail on a JS/TS correctness violation', () => { + const path = fixture('unused.mts', 'const unused = 1;\n'); + + const result = spawnSync(OXLINT, ['--config', OXLINT_CONFIG, '--disable-nested-config', path], { + encoding: 'utf8', + }); + + expect(result.status).toBe(1); + }); + + it('runs the policy without a Biome fallback command', () => { + const packageJson: { scripts?: Record } = JSON.parse( + readFileSync(join(ROOT, 'package.json'), 'utf8'), + ); + + expect(packageJson.scripts?.lint).toBe('bun run lint:oxlint'); + expect(packageJson.scripts?.['lint:biome']).toBeUndefined(); + expect(packageJson.scripts?.['lint:regex']).toBeUndefined(); + }); +}); diff --git a/cli/__tests__/self-host.test.mts b/cli/__tests__/self-host.test.mts index 9f681a8e..25308bc3 100644 --- a/cli/__tests__/self-host.test.mts +++ b/cli/__tests__/self-host.test.mts @@ -218,18 +218,15 @@ describe('buildSelfHostHook', () => { expect(() => execFileSync('sh', ['-c', fragment ?? 'exit 1'], { cwd: root })).toThrow(); }); - // The `--extra` above is only as hard as the script it names, and biome exits 0 when every - // diagnostic is warn-severity. A bare `biome check .` therefore PRINTS its findings into the gate - // log — indistinguishable from a real failure to the reader — and passes the commit anyway (a - // v0.50.0 ship shipped with six of them). `--error-on-warnings` is what makes the exit code match - // what the log shows. devkit's own root config turns `noConsole` (the one deliberately-advisory - // rule in biome/base.jsonc) off for the whole authored surface, so nothing advisory is caught here. - it('backs the lint extra with a biome invocation that exits non-zero on WARNINGS too', () => { + it('backs the lint extra with the native Oxlint policy only', () => { const pkg: { scripts?: Record } = JSON.parse( readFileSync(join(ROOT, 'package.json'), 'utf8'), ); expect(SELF_HOST_EXTRAS).toContainEqual({ label: 'lint', cmd: 'bun run lint' }); - expect(pkg.scripts?.lint).toBe('biome check --formatter-enabled=false --error-on-warnings .'); + expect(pkg.scripts?.lint).toBe('bun run lint:oxlint'); + expect(pkg.scripts?.['lint:oxlint']).toContain('--deny-warnings'); + expect(pkg.scripts?.['lint:biome']).toBeUndefined(); + expect(pkg.scripts?.['lint:regex']).toBeUndefined(); }); it('preserves the advisory fallow-audit gate INSIDE the block (never blocks, survives re-run)', () => { diff --git a/dist/oxc/oxlint.devkit-lint.json b/dist/oxc/oxlint.devkit-lint.json new file mode 100644 index 00000000..dead088a --- /dev/null +++ b/dist/oxc/oxlint.devkit-lint.json @@ -0,0 +1,37 @@ +{ + "$schema": "../node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "oxc", "react"], + "categories": { + "correctness": "deny", + "perf": "deny", + "suspicious": "deny", + "pedantic": "allow", + "restriction": "allow", + "style": "allow", + "nursery": "allow" + }, + "rules": { + "eslint/no-await-in-loop": "allow", + "eslint/no-console": "allow", + "eslint/no-extend-native": "allow", + "eslint/no-shadow": "allow", + "eslint/no-underscore-dangle": "allow", + "eslint/preserve-caught-error": "allow", + "oxc/no-map-spread": "allow", + "react/rules-of-hooks": "deny" + }, + "overrides": [ + { + "files": ["**/upstream-sync/scripts/sync.mjs"], + "rules": { + "eslint/no-unused-vars": "allow" + } + }, + { + "files": ["**/comment-firewall/__tests__/detect.test.mts"], + "rules": { + "eslint/no-useless-concat": "allow" + } + } + ] +} diff --git a/dist/package.json b/dist/package.json index 6bc592bb..02bb1cbf 100644 --- a/dist/package.json +++ b/dist/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "license": "MIT", - "description": "Single source-of-truth devkit: shared Biome/TS configs + portable governance gate-engine + setup-wizard CLI. Consumed via `bun add git+ssh://git@github.com/norvalbv/devkit.git#`.", + "description": "Single source-of-truth devkit: shared lint/TS configs + portable governance gate-engine + setup-wizard CLI. Consumed via `bun add git+ssh://git@github.com/norvalbv/devkit.git#`.", "exports": { "./biome/base": "./dist/biome/base.jsonc", "./biome/react": "./dist/biome/react.jsonc", @@ -59,7 +59,8 @@ "test:run": "vitest run", "test:e2e": "vitest run -c vitest.e2e.config.mjs", "playground": "bun scripts/playground.mts", - "lint": "biome check --formatter-enabled=false --error-on-warnings .", + "lint": "bun run lint:oxlint", + "lint:oxlint": "oxlint --config oxc/oxlint.devkit-lint.json --disable-nested-config --deny-warnings cli gate-engine *.mjs skills", "lint:anti-slop": "node cli/index.mts anti-slop check", "lint:structure": "eslint cli gate-engine", "benchmarks:check": "bun gate-engine/eval/cli.mts check", @@ -67,15 +68,14 @@ "benchmarks:typecheck": "tsc -p gate-engine/eval/tsconfig.json", "comments:eval": "node gate-engine/comment-firewall/eval/run.mts", "search-eval:check": "node gate-engine/search-tool/eval/eval.mts --fail", - "format": "oxfmt --write 'cli/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'gate-engine/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'tsconfig/**/*.{json,jsonc}' 'biome/**/*.{json,jsonc}' 'skills/**/*.mjs' .co-occurrence-allowlist.json .fallowrc.jsonc .oxfmtrc.json biome.jsonc eslint.config.mjs guard.config.example.json guard.config.json package.json search-code.config.json tsconfig.build.json tsconfig.json vitest.config.mjs vitest.e2e.config.mjs vitest.setup.mjs", - "format:check": "oxfmt --check 'cli/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'gate-engine/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'tsconfig/**/*.{json,jsonc}' 'biome/**/*.{json,jsonc}' 'skills/**/*.mjs' .co-occurrence-allowlist.json .fallowrc.jsonc .oxfmtrc.json biome.jsonc eslint.config.mjs guard.config.example.json guard.config.json package.json search-code.config.json tsconfig.build.json tsconfig.json vitest.config.mjs vitest.e2e.config.mjs vitest.setup.mjs", + "format": "oxfmt --write 'cli/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'gate-engine/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'tsconfig/**/*.{json,jsonc}' 'biome/**/*.{json,jsonc}' 'skills/**/*.mjs' .co-occurrence-allowlist.json .fallowrc.jsonc .oxfmtrc.json eslint.config.mjs guard.config.example.json guard.config.json package.json search-code.config.json tsconfig.build.json tsconfig.json vitest.config.mjs vitest.e2e.config.mjs vitest.setup.mjs", + "format:check": "oxfmt --check 'cli/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'gate-engine/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'tsconfig/**/*.{json,jsonc}' 'biome/**/*.{json,jsonc}' 'skills/**/*.mjs' .co-occurrence-allowlist.json .fallowrc.jsonc .oxfmtrc.json eslint.config.mjs guard.config.example.json guard.config.json package.json search-code.config.json tsconfig.build.json tsconfig.json vitest.config.mjs vitest.e2e.config.mjs vitest.setup.mjs", "typecheck": "tsc -p tsconfig.json", "prepare": "husky", "guard:freeze": "node gate-engine/ratchets/folder-fanout.mjs freeze && node gate-engine/ratchets/size-disable.mjs freeze", "build": "tsc -p tsconfig.build.json && node scripts/copy-dist-assets.mjs" }, "devDependencies": { - "@biomejs/biome": "^2.5.6", "@types/node": "^25.9.3", "@vitest/coverage-v8": "^4.1.10", "husky": "^9.1.7", diff --git a/docs/benchmarks/experiments/2026-08-18-oxlint-native-devkit-adoption/README.md b/docs/benchmarks/experiments/2026-08-18-oxlint-native-devkit-adoption/README.md new file mode 100644 index 00000000..b46af942 --- /dev/null +++ b/docs/benchmarks/experiments/2026-08-18-oxlint-native-devkit-adoption/README.md @@ -0,0 +1,109 @@ +# Devkit's lint hard cutover to Oxlint — 2026-08-18 + +**Shortcut:** sc-1787 + +Devkit now runs **Oxlint only** for its own ordinary source-code linting. Its local lint command, +pre-commit hook, and CI do not invoke Biome. This is a deliberate hard cutover, made because CPU +time is the limiting resource for local coding and agent loops. + +## Result in plain English + +The same Devkit lint command was run ten times before and after the change. The middle result was: + +| What you feel | Before | After | Improvement | +| --- | ---: | ---: | ---: | +| CPU occupied by the check | 2.41 seconds | 0.18 seconds | **92.5% less CPU** | +| Time waiting for it to finish | 0.41 seconds | 0.10 seconds | **74.5% faster** | +| Peak memory used by the command and children | 208 MiB | 91 MiB | 56.4% lower | + +CPU is the reason for the decision. Memory is only a safety measure here; an increase would have +been acceptable if the CPU saving still made the agent loop materially faster. + +## What runs now + +| Responsibility | Tool | Reason | +| --- | --- | --- | +| Ordinary JavaScript and TypeScript lint | Native Oxlint | Fast explicit policy: correctness, suspicious-code, performance, and React hook checks. | +| Formatting | Oxfmt | Already selected for Devkit formatting; it is separate from linting. | +| Folder/file topology and import walls | ESLint + `eslint-plugin-project-structure` | These are cross-file filesystem rules, not normal source lint. | +| Anti-slop debt | Oxlint's vendored JS plugin | It uses its own explicit baseline lifecycle. | +| Type errors and build compatibility | `tsc --noEmit` | Oxlint is not a type checker and does not replace Devkit's TypeScript compiler lane. | + +`bun run lint` is now exactly `bun run lint:oxlint`. It uses +`oxc/oxlint.devkit-lint.json` and `--disable-nested-config`, so it cannot accidentally inherit the +separate anti-slop policy. + +## Policy change, made explicit + +This is not a claim that every rule in Biome's broad `recommended` preset has the same rule name or +message in Oxlint. There is no trustworthy automatic Biome-to-Oxlint translation. Instead, this +change replaces the implicit ambient preset with an explicit Devkit policy: + +- enable Oxlint's native `correctness`, `suspicious`, and `perf` categories plus React hooks; +- deliberately leave `style`, `pedantic`, `restriction`, and `nursery` off, because enabling a + broad style category created 40,755 existing findings rather than a practical gate; +- keep seven native checks off where Devkit's established source conventions legitimately use + them: `no-await-in-loop`, `no-console`, `no-extend-native`, `no-shadow`, + `no-underscore-dangle`, `preserve-caught-error`, and `no-map-spread`; +- keep one file-specific `no-unused-vars` exception for the pre-existing unused helper in + `skills/upstream-sync/scripts/sync.mjs`, rather than pretending this migration has repaired + unrelated code. + +That makes future additions and removals reviewable in `oxc/oxlint.devkit-lint.json` rather than +being hidden inside a changing recommended preset. + +## Checks consciously retired from Devkit's lint command + +This cutover removes every Devkit self-hosted Biome pass. Nothing was silently left running as a +fallback. + +| Retired check | Why it is not reimplemented | +| --- | --- | +| Biome's `useTopLevelRegex` performance rule | Oxlint 1.78 has no equivalent. A one-rule custom plugin would recreate the maintenance burden this cutover is intended to remove. | +| JSON duplicate-key and CSS-property diagnostics | Oxlint does not lint JSON or CSS. These 29 Devkit config/style files still go through Oxfmt, but their extra Biome lint diagnostics are intentionally no longer a CI gate. | +| Static import organisation assist | The old Biome assist was not a sound part of the lint gate once formatting moved to Oxfmt. Oxfmt import sorting remains deliberately off because its ordering would make an unrelated 412-file rewrite. | + +This is a policy decision, not a claim that Oxlint produces the same message for every previous +Biome rule. The explicit native policy and its seven established Devkit exceptions are recorded in +`oxc/oxlint.devkit-lint.json`. + +## Important compatibility boundary + +The published `biome/base` and `biome/react` files remain in the npm package for existing consumer +projects. They are **not part of Devkit's own runtime toolchain** and no Devkit self-host command +uses them. Removing published configuration paths before Frink is ported would break installed +projects without making Devkit itself faster. Their replacement is therefore consumer migration +work, not an invisible second lint lane. + +## Measurement protocol + +The control is a clean archive of `origin/main` at `76383ff117bcd64f10cceb0d8cbea12e4d8df3a2`, +running its original `bun run lint`. The candidate is this worktree running the new command of the +same name. Both include normal command startup; each side installs its locked dependencies before +timing, and that setup time is excluded. + +The machine was macOS arm64 with Node 24.19.0, Bun 1.3.1, and ten logical CPUs. There were three +warm-up runs, then ten measured pairs in alternating order. CPU is user plus system time collected +by `/usr/bin/time -lp`; the memory figure samples the command and its children every 10 ms. Raw +samples and summaries are in [results.json](results.json); the reproducer is +[benchmark.mjs](benchmark.mjs). + +The slowest measured candidate run still used only 0.18 seconds CPU and took 0.11 seconds wall +time, compared with 2.48 seconds CPU and 0.44 seconds wall time for the slowest old run. + +## Acceptance checks + +- `bun run lint` runs Oxlint only and exits clean. +- A fixture proves the configured native policy rejects an unused TypeScript value. +- A regression test proves the root lint script cannot regain either of the retired Biome fallback + commands unnoticed. +- The native runner uses a separate config with `--disable-nested-config`, so anti-slop's baseline + config cannot accidentally join the ordinary lint pass. +- Formatting, topology, anti-slop, and TypeScript retain their separately documented owners. + +## Sources + +- [Oxlint configuration](https://oxc.rs/docs/guide/usage/linter/config.html) +- [Oxlint rules and native plugins](https://oxc.rs/docs/guide/usage/linter/rules.html) +- [Oxfmt import sorting](https://oxc.rs/docs/guide/usage/formatter/sorting.html) +- [Earlier retain experiment](../2026-08-16-oxlint-native-devkit/README.md) diff --git a/docs/benchmarks/experiments/2026-08-18-oxlint-native-devkit-adoption/benchmark.mjs b/docs/benchmarks/experiments/2026-08-18-oxlint-native-devkit-adoption/benchmark.mjs new file mode 100644 index 00000000..bba8f594 --- /dev/null +++ b/docs/benchmarks/experiments/2026-08-18-oxlint-native-devkit-adoption/benchmark.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node + +import { execFileSync, spawn, spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { performance } from 'node:perf_hooks'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const CONTROL_REF = 'origin/main'; +const WARMUPS = 3; +const SAMPLES = 10; +const TIME_BIN = '/usr/bin/time'; +const PS_BIN = '/bin/ps'; + +const COMMANDS = { + control: 'bun run lint', + candidate: 'bun run lint', +}; + +function git(cwd, args, options = {}) { + return execFileSync('git', args, { cwd, encoding: 'utf8', ...options }).trim(); +} + +function processTreeRss(rootPid) { + try { + const rows = execFileSync(PS_BIN, ['-axo', 'pid=,ppid=,rss='], { encoding: 'utf8' }) + .trim() + .split('\n') + .map((line) => line.trim().split(/\s+/).map(Number)) + .filter(([pid, parent, rss]) => pid > 0 && parent >= 0 && rss >= 0); + const children = new Map(); + const rssByPid = new Map(); + for (const [pid, parent, rss] of rows) { + rssByPid.set(pid, rss); + const siblings = children.get(parent) ?? []; + siblings.push(pid); + children.set(parent, siblings); + } + const pending = [rootPid]; + const seen = new Set(); + let kib = 0; + while (pending.length > 0) { + const pid = pending.pop(); + if (seen.has(pid)) continue; + seen.add(pid); + kib += rssByPid.get(pid) ?? 0; + pending.push(...(children.get(pid) ?? [])); + } + return kib * 1024; + } catch { + return 0; + } +} + +function parseTime(stderr) { + const value = (pattern, label) => { + const match = stderr.match(pattern); + if (!match) throw new Error(`/usr/bin/time output omitted ${label}`); + return Number(match[1]); + }; + return { + userMs: value(/([0-9.]+)\s+user/, 'user CPU') * 1000, + systemMs: value(/([0-9.]+)\s+sys/, 'system CPU') * 1000, + directPeakRssBytes: value( + /([0-9]+)\s+maximum resident set size/, + 'maximum resident set size', + ), + }; +} + +async function measure(command, cwd) { + const started = performance.now(); + const child = spawn(TIME_BIN, ['-lp', 'sh', '-c', command], { + cwd, + env: { ...process.env, NO_COLOR: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let treePeakRssBytes = 0; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + const sampler = setInterval(() => { + treePeakRssBytes = Math.max(treePeakRssBytes, processTreeRss(child.pid)); + }, 10); + const status = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + clearInterval(sampler); + if (status !== 0) throw new Error(`timed command failed (${status}): ${stdout}\n${stderr}`); + const timing = parseTime(stderr); + return { + wallMs: performance.now() - started, + cpuMs: timing.userMs + timing.systemMs, + userMs: timing.userMs, + systemMs: timing.systemMs, + treePeakRssBytes: Math.max(treePeakRssBytes, timing.directPeakRssBytes), + directPeakRssBytes: timing.directPeakRssBytes, + }; +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]; +} + +function p95(values) { + return [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.95) - 1]; +} + +function summarize(samples) { + return Object.fromEntries( + ['wallMs', 'cpuMs', 'userMs', 'systemMs', 'treePeakRssBytes', 'directPeakRssBytes'].map( + (field) => [ + field, + { + median: median(samples.map((sample) => sample[field])), + p95: p95(samples.map((sample) => sample[field])), + }, + ], + ), + ); +} + +function prepareControl(tempRoot) { + const control = join(tempRoot, 'control'); + mkdirSync(control, { recursive: true }); + const archive = execFileSync('git', ['archive', CONTROL_REF], { + cwd: REPO_ROOT, + maxBuffer: 128 * 1024 * 1024, + }); + const extracted = spawnSync('tar', ['-x', '-C', control], { input: archive }); + if (extracted.status !== 0) throw new Error(`control archive extraction failed: ${extracted.stderr}`); + const installed = spawnSync('bun', ['install', '--frozen-lockfile', '--ignore-scripts'], { + cwd: control, + encoding: 'utf8', + }); + if (installed.status !== 0) { + throw new Error(`control dependency install failed: ${installed.stdout}\n${installed.stderr}`); + } + return control; +} + +async function main() { + const tempRoot = mkdtempSync(join(tmpdir(), 'devkit-oxlint-adoption-benchmark-')); + try { + const control = prepareControl(tempRoot); + const roots = { control, candidate: REPO_ROOT }; + for (let index = 0; index < WARMUPS; index += 1) { + await measure(COMMANDS.control, roots.control); + await measure(COMMANDS.candidate, roots.candidate); + } + const samples = { control: [], candidate: [] }; + for (let index = 0; index < SAMPLES; index += 1) { + const order = index % 2 === 0 ? ['control', 'candidate'] : ['candidate', 'control']; + for (const side of order) samples[side].push(await measure(COMMANDS[side], roots[side])); + } + console.log( + JSON.stringify( + { + schemaVersion: 1, + recordedAt: new Date().toISOString(), + source: { + controlRef: CONTROL_REF, + controlCommit: git(REPO_ROOT, ['rev-parse', CONTROL_REF]), + candidateCommit: git(REPO_ROOT, ['rev-parse', 'HEAD']), + }, + host: { + uname: execFileSync('uname', ['-a'], { encoding: 'utf8' }).trim(), + node: process.version, + bun: execFileSync('bun', ['--version'], { encoding: 'utf8' }).trim(), + logicalCpuCount: Number( + execFileSync('sysctl', ['-n', 'hw.logicalcpu'], { encoding: 'utf8' }).trim(), + ), + }, + protocol: { + warmups: WARMUPS, + samples: SAMPLES, + order: 'alternating control-first/candidate-first', + control: 'clean origin/main archive with its original bun run lint script', + candidate: 'this candidate worktree with its proposed bun run lint script', + dependencies: + 'each side has its locked dependencies installed before timing; install time excluded', + cpu: '/usr/bin/time -lp aggregate user + sys; primary decision metric', + rss: '10ms sampling; sum RSS of /usr/bin/time wrapper and all descendants', + }, + commands: COMMANDS, + samples, + summary: { control: summarize(samples.control), candidate: summarize(samples.candidate) }, + }, + null, + 2, + ), + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +await main(); diff --git a/docs/benchmarks/experiments/2026-08-18-oxlint-native-devkit-adoption/results.json b/docs/benchmarks/experiments/2026-08-18-oxlint-native-devkit-adoption/results.json new file mode 100644 index 00000000..1cbcfa65 --- /dev/null +++ b/docs/benchmarks/experiments/2026-08-18-oxlint-native-devkit-adoption/results.json @@ -0,0 +1,249 @@ +{ + "schemaVersion": 1, + "recordedAt": "2026-08-18T16:29:01.692Z", + "source": { + "controlRef": "origin/main", + "controlCommit": "76383ff117bcd64f10cceb0d8cbea12e4d8df3a2", + "candidateCommit": "c158bafee68421f67cb5b0cd1620a24a014d2026" + }, + "host": { + "uname": "Darwin Benji-mac 25.5.0 Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:26 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T8132 arm64", + "node": "v22.20.0", + "bun": "1.3.1", + "logicalCpuCount": 10 + }, + "protocol": { + "warmups": 3, + "samples": 10, + "order": "alternating control-first/candidate-first", + "control": "clean origin/main archive with its original bun run lint script", + "candidate": "this candidate worktree with its proposed bun run lint script", + "dependencies": "each side has its locked dependencies installed before timing; install time excluded", + "cpu": "/usr/bin/time -lp aggregate user + sys; primary decision metric", + "rss": "10ms sampling; sum RSS of /usr/bin/time wrapper and all descendants" + }, + "commands": { + "control": "bun run lint", + "candidate": "bun run lint" + }, + "samples": { + "control": [ + { + "wallMs": 426.33308299999953, + "cpuMs": 2420, + "userMs": 390, + "systemMs": 2029.9999999999998, + "treePeakRssBytes": 218218496, + "directPeakRssBytes": 166821888 + }, + { + "wallMs": 412.34375, + "cpuMs": 2430, + "userMs": 370, + "systemMs": 2060, + "treePeakRssBytes": 218710016, + "directPeakRssBytes": 167100416 + }, + { + "wallMs": 398.941417, + "cpuMs": 2370, + "userMs": 360, + "systemMs": 2009.9999999999998, + "treePeakRssBytes": 218316800, + "directPeakRssBytes": 166805504 + }, + { + "wallMs": 404.99554100000023, + "cpuMs": 2390, + "userMs": 350, + "systemMs": 2040, + "treePeakRssBytes": 218038272, + "directPeakRssBytes": 166494208 + }, + { + "wallMs": 424.7429169999996, + "cpuMs": 2480, + "userMs": 380, + "systemMs": 2100, + "treePeakRssBytes": 218251264, + "directPeakRssBytes": 166690816 + }, + { + "wallMs": 432.8482090000007, + "cpuMs": 2450, + "userMs": 380, + "systemMs": 2070, + "treePeakRssBytes": 218431488, + "directPeakRssBytes": 166887424 + }, + { + "wallMs": 405.25104100000044, + "cpuMs": 2390, + "userMs": 360, + "systemMs": 2029.9999999999998, + "treePeakRssBytes": 218775552, + "directPeakRssBytes": 167264256 + }, + { + "wallMs": 401.6501249999992, + "cpuMs": 2380, + "userMs": 350, + "systemMs": 2029.9999999999998, + "treePeakRssBytes": 217956352, + "directPeakRssBytes": 166477824 + }, + { + "wallMs": 395.0868330000012, + "cpuMs": 2400, + "userMs": 370, + "systemMs": 2029.9999999999998, + "treePeakRssBytes": 219086848, + "directPeakRssBytes": 167624704 + }, + { + "wallMs": 441.24483300000065, + "cpuMs": 2440, + "userMs": 400, + "systemMs": 2040, + "treePeakRssBytes": 218398720, + "directPeakRssBytes": 166952960 + } + ], + "candidate": [ + { + "wallMs": 106.0815419999999, + "cpuMs": 180, + "userMs": 60, + "systemMs": 120, + "treePeakRssBytes": 97320960, + "directPeakRssBytes": 97320960 + }, + { + "wallMs": 103.86833399999978, + "cpuMs": 180, + "userMs": 60, + "systemMs": 120, + "treePeakRssBytes": 95256576, + "directPeakRssBytes": 95256576 + }, + { + "wallMs": 88.01283299999977, + "cpuMs": 170, + "userMs": 60, + "systemMs": 110, + "treePeakRssBytes": 95338496, + "directPeakRssBytes": 95338496 + }, + { + "wallMs": 105.12704099999974, + "cpuMs": 180, + "userMs": 60, + "systemMs": 120, + "treePeakRssBytes": 95977472, + "directPeakRssBytes": 95977472 + }, + { + "wallMs": 102.34370899999976, + "cpuMs": 180, + "userMs": 60, + "systemMs": 120, + "treePeakRssBytes": 95223808, + "directPeakRssBytes": 95223808 + }, + { + "wallMs": 104.24091600000065, + "cpuMs": 180, + "userMs": 60, + "systemMs": 120, + "treePeakRssBytes": 93405184, + "directPeakRssBytes": 93405184 + }, + { + "wallMs": 102.78479099999913, + "cpuMs": 180, + "userMs": 60, + "systemMs": 120, + "treePeakRssBytes": 101777408, + "directPeakRssBytes": 95092736 + }, + { + "wallMs": 110.55375000000004, + "cpuMs": 180, + "userMs": 60, + "systemMs": 120, + "treePeakRssBytes": 95272960, + "directPeakRssBytes": 95272960 + }, + { + "wallMs": 105.84124999999949, + "cpuMs": 180, + "userMs": 60, + "systemMs": 120, + "treePeakRssBytes": 96092160, + "directPeakRssBytes": 96092160 + }, + { + "wallMs": 103.44437500000095, + "cpuMs": 180, + "userMs": 60, + "systemMs": 120, + "treePeakRssBytes": 93814784, + "directPeakRssBytes": 93814784 + } + ] + }, + "summary": { + "control": { + "wallMs": { + "median": 408.7973955000002, + "p95": 441.24483300000065 + }, + "cpuMs": { + "median": 2410, + "p95": 2480 + }, + "userMs": { + "median": 370, + "p95": 400 + }, + "systemMs": { + "median": 2035, + "p95": 2100 + }, + "treePeakRssBytes": { + "median": 218357760, + "p95": 219086848 + }, + "directPeakRssBytes": { + "median": 166854656, + "p95": 167624704 + } + }, + "candidate": { + "wallMs": { + "median": 104.05462500000021, + "p95": 110.55375000000004 + }, + "cpuMs": { + "median": 180, + "p95": 180 + }, + "userMs": { + "median": 60, + "p95": 60 + }, + "systemMs": { + "median": 120, + "p95": 120 + }, + "treePeakRssBytes": { + "median": 95305728, + "p95": 101777408 + }, + "directPeakRssBytes": { + "median": 95264768, + "p95": 97320960 + } + } + } +} diff --git a/docs/decisions/oxc-toolchain-migration.md b/docs/decisions/oxc-toolchain-migration.md index 93d01724..6fb91258 100644 --- a/docs/decisions/oxc-toolchain-migration.md +++ b/docs/decisions/oxc-toolchain-migration.md @@ -26,3 +26,4 @@ created: 2026-08-15 - 2026-08-16 — sc-1679 proves Oxfmt 0.63.0 over Devkit's exact 558-file Biome formatting scope. The corrected migration changes seven TypeScript files with formatter-only hunks, is byte-idempotent on pass two, and keeps JSON/JSONC/package ordering stable through explicit overrides. Ten paired samples show the direct pinned binary cuts full-scope median CPU 72.6% (0.8709s to 0.2382s), wall 37.8% (0.1997s to 0.1242s), and process-tree RSS 24.1% (143.7 to 109.1 MiB); a one-file single-thread check-mode proxy cuts CPU 8.1% but increases wall/RSS. Devkit adopts direct Oxfmt for its own formatting, CI, and staged self-host path, retains Biome for lint and consumer configs/hooks, and keeps devkit oxc fmt out of the hot staged path because Node-wrapper startup measured 0.1113s CPU and 137.0 MiB there. - 2026-08-16 — sc-1676 vendors anti-slop commit 446268e5d15baa968eaec669ff65358d36ae6259 with @oxlint/plugins@1.78.0 into the managed Oxc configuration and adds an explicit, deterministic create/check/inspect/prune baseline lifecycle. Normal checks stay read-only and reject only unbaselined error-severity findings; prune can only delete absent debt or reduce duplicate counts. Rule severity and scoped overrides remain native Oxlint config so anti-slop composes with other Oxc rules. - 2026-08-16 — sc-1681 closes Devkit dogfooding with the mixed ownership boundary recorded in the [assembled benchmark](../benchmarks/experiments/2026-08-16-oxc-devkit-dogfood/README.md). Oxfmt and anti-slop/Oxlint are adopted; Biome lint, ESLint topology, and TypeScript remain because sc-1677, sc-1678, and sc-1680 found concrete diagnostic or semantic gaps despite faster candidates. Devkit now commits its managed Oxc/plugin bytes and 1,677-finding baseline, gates the exact Git index locally, rejects base-to-candidate baseline growth in CI, and checks all managed state through self-host doctor. The measured local agent segment is slightly faster while adding the policy (-3.0% median CPU, -2.7% median process-tree RSS), while full CI is +39.4% median CPU because it deliberately adds anti-slop and typechecks its vendored source. Frink adoption therefore requires its own assembled benchmark; individual microbenchmark speed is not enough to remove an incumbent owner. +- 2026-08-18 — sc-1787 adopts native Oxlint as Devkit's own ordinary JS/TS lint owner after replacing the ambient Biome recommended preset with an explicit 155-rule correctness/suspicious/perf policy plus React hooks. The pinned Node 24.19 paired experiment compares the actual old and new `bun run lint` commands: median CPU falls 92.5% (2.410s to 0.180s), wall 74.5% (0.409s to 0.104s), and process-tree RSS 56.4% (208.2 to 90.9 MiB). Following the maintainer's hard-cutover direction, Devkit's local lint, hook, and CI now invoke Oxlint only: Biome's JSON/CSS diagnostics, production `useTopLevelRegex`, and static import assist are explicitly retired rather than carried as small fallback passes. Formatting remains Oxfmt, topology ESLint, anti-slop its baseline-aware Oxlint lane, and tsc the type checker. Distributed Biome presets remain published only as temporary consumer compatibility contracts; no Devkit self-host process executes them, and their removal belongs to the separately measured consumer/Frink port. Evidence: docs/benchmarks/experiments/2026-08-18-oxlint-native-devkit-adoption/. diff --git a/oxc/oxlint.devkit-lint.json b/oxc/oxlint.devkit-lint.json new file mode 100644 index 00000000..dead088a --- /dev/null +++ b/oxc/oxlint.devkit-lint.json @@ -0,0 +1,37 @@ +{ + "$schema": "../node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "oxc", "react"], + "categories": { + "correctness": "deny", + "perf": "deny", + "suspicious": "deny", + "pedantic": "allow", + "restriction": "allow", + "style": "allow", + "nursery": "allow" + }, + "rules": { + "eslint/no-await-in-loop": "allow", + "eslint/no-console": "allow", + "eslint/no-extend-native": "allow", + "eslint/no-shadow": "allow", + "eslint/no-underscore-dangle": "allow", + "eslint/preserve-caught-error": "allow", + "oxc/no-map-spread": "allow", + "react/rules-of-hooks": "deny" + }, + "overrides": [ + { + "files": ["**/upstream-sync/scripts/sync.mjs"], + "rules": { + "eslint/no-unused-vars": "allow" + } + }, + { + "files": ["**/comment-firewall/__tests__/detect.test.mts"], + "rules": { + "eslint/no-useless-concat": "allow" + } + } + ] +} diff --git a/package.json b/package.json index 6bc592bb..02bb1cbf 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "license": "MIT", - "description": "Single source-of-truth devkit: shared Biome/TS configs + portable governance gate-engine + setup-wizard CLI. Consumed via `bun add git+ssh://git@github.com/norvalbv/devkit.git#`.", + "description": "Single source-of-truth devkit: shared lint/TS configs + portable governance gate-engine + setup-wizard CLI. Consumed via `bun add git+ssh://git@github.com/norvalbv/devkit.git#`.", "exports": { "./biome/base": "./dist/biome/base.jsonc", "./biome/react": "./dist/biome/react.jsonc", @@ -59,7 +59,8 @@ "test:run": "vitest run", "test:e2e": "vitest run -c vitest.e2e.config.mjs", "playground": "bun scripts/playground.mts", - "lint": "biome check --formatter-enabled=false --error-on-warnings .", + "lint": "bun run lint:oxlint", + "lint:oxlint": "oxlint --config oxc/oxlint.devkit-lint.json --disable-nested-config --deny-warnings cli gate-engine *.mjs skills", "lint:anti-slop": "node cli/index.mts anti-slop check", "lint:structure": "eslint cli gate-engine", "benchmarks:check": "bun gate-engine/eval/cli.mts check", @@ -67,15 +68,14 @@ "benchmarks:typecheck": "tsc -p gate-engine/eval/tsconfig.json", "comments:eval": "node gate-engine/comment-firewall/eval/run.mts", "search-eval:check": "node gate-engine/search-tool/eval/eval.mts --fail", - "format": "oxfmt --write 'cli/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'gate-engine/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'tsconfig/**/*.{json,jsonc}' 'biome/**/*.{json,jsonc}' 'skills/**/*.mjs' .co-occurrence-allowlist.json .fallowrc.jsonc .oxfmtrc.json biome.jsonc eslint.config.mjs guard.config.example.json guard.config.json package.json search-code.config.json tsconfig.build.json tsconfig.json vitest.config.mjs vitest.e2e.config.mjs vitest.setup.mjs", - "format:check": "oxfmt --check 'cli/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'gate-engine/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'tsconfig/**/*.{json,jsonc}' 'biome/**/*.{json,jsonc}' 'skills/**/*.mjs' .co-occurrence-allowlist.json .fallowrc.jsonc .oxfmtrc.json biome.jsonc eslint.config.mjs guard.config.example.json guard.config.json package.json search-code.config.json tsconfig.build.json tsconfig.json vitest.config.mjs vitest.e2e.config.mjs vitest.setup.mjs", + "format": "oxfmt --write 'cli/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'gate-engine/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'tsconfig/**/*.{json,jsonc}' 'biome/**/*.{json,jsonc}' 'skills/**/*.mjs' .co-occurrence-allowlist.json .fallowrc.jsonc .oxfmtrc.json eslint.config.mjs guard.config.example.json guard.config.json package.json search-code.config.json tsconfig.build.json tsconfig.json vitest.config.mjs vitest.e2e.config.mjs vitest.setup.mjs", + "format:check": "oxfmt --check 'cli/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'gate-engine/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'tsconfig/**/*.{json,jsonc}' 'biome/**/*.{json,jsonc}' 'skills/**/*.mjs' .co-occurrence-allowlist.json .fallowrc.jsonc .oxfmtrc.json eslint.config.mjs guard.config.example.json guard.config.json package.json search-code.config.json tsconfig.build.json tsconfig.json vitest.config.mjs vitest.e2e.config.mjs vitest.setup.mjs", "typecheck": "tsc -p tsconfig.json", "prepare": "husky", "guard:freeze": "node gate-engine/ratchets/folder-fanout.mjs freeze && node gate-engine/ratchets/size-disable.mjs freeze", "build": "tsc -p tsconfig.build.json && node scripts/copy-dist-assets.mjs" }, "devDependencies": { - "@biomejs/biome": "^2.5.6", "@types/node": "^25.9.3", "@vitest/coverage-v8": "^4.1.10", "husky": "^9.1.7",