diff --git a/bin/verify-workspace-config.mjs b/bin/verify-workspace-config.mjs index a611c2f..7df4854 100755 --- a/bin/verify-workspace-config.mjs +++ b/bin/verify-workspace-config.mjs @@ -169,11 +169,24 @@ function localManifest(root) { } : undefined; + // package.json (parsed) so the biome-linter rule can read the @biomejs/biome + // dep. null = not a node repo (Python/Rust/etc) → the rule is N/A there. + let packageJson = null; + try { + const pkgPath = join(root, 'package.json'); + if (tracked ? tracked.has('package.json') : existsSync(pkgPath)) { + packageJson = JSON.parse(readFileSync(pkgPath, 'utf8')); + } + } catch { + packageJson = null; + } + return { hasFile: (p) => (tracked ? tracked.has(p) : existsSync(join(root, p))), gitignore, ...(isIgnored ? { isIgnored } : {}), workflowRunners, + packageJson, }; } @@ -220,6 +233,8 @@ async function remoteManifest(repo, ref) { '.beads/issues.jsonl', '.beads/beads.db', '.automaker/settings.json', + 'biome.json', + 'biome.jsonc', ]; const present = new Set(); await Promise.all( @@ -269,7 +284,20 @@ async function remoteManifest(repo, ref) { /* no .github/workflows dir */ } - return { hasFile: (p) => present.has(p), gitignore, workflowRunners }; + // Fetch + parse package.json (for the biome-linter rule). null = not a node repo. + let packageJson = null; + try { + const { stdout } = await execFileAsync( + 'gh', + ['api', `repos/${owner}/${name}/contents/package.json?ref=${resolvedRef}`, '--jq', '.content'], + { encoding: 'utf8' } + ); + packageJson = JSON.parse(Buffer.from(stdout.trim(), 'base64').toString('utf8')); + } catch { + packageJson = null; + } + + return { hasFile: (p) => present.has(p), gitignore, workflowRunners, packageJson }; } async function defaultBranch(owner, name) { diff --git a/docs/reference/workspace-config-standard.md b/docs/reference/workspace-config-standard.md index f0c8f02..561210a 100644 --- a/docs/reference/workspace-config-standard.md +++ b/docs/reference/workspace-config-standard.md @@ -22,6 +22,7 @@ Enforced by `verify-workspace-config` (this package). | `automaker-transient-gitignored` | warn | `.automaker/features/`, `checkpoints/`, `trajectory/` gitignored; `settings.json` + `context/` stay committed | | `workflows-use-owned-runners` | error | every `runs-on:` is an org-owned runner (`namespace-profile-protolabs-linux`), never GitHub-hosted (`ubuntu-*`, `windows-*`, `macos-*`) | | `workflow-security-lint` | warn | `.github/workflows/workflow-security-lint.yml` exists — zizmor + actionlint run in CI (CWE-94 script-injection, unpinned actions, token over-privilege) | +| `biome-linter` | warn | JS/TS repos (those with a `package.json`) lint with **biome** at the fleet-pinned version (`FLEET_BIOME_VERSION`); non-node repos are N/A | **Errors gate CI. Warnings are advisory.** @@ -81,6 +82,29 @@ jobs: `warn` for now so the fleet can roll out without breaking CI on day one; tighten to `error` once every managed repo carries it. +### Why JS/TS repos track one biome version + +protoLabs standardizes JS/TS linting on [biome](https://biomejs.dev) — one fast +single-binary linter, no ESLint plugin sprawl. The fleet tracks **one** biome +version together so a lint rule means the same thing in every repo and upgrades +land in lockstep instead of scattering into per-repo drift. + +The pinned version lives in **one place** — `FLEET_BIOME_VERSION` in +`release-tools` (`lib/workspace-config.mjs`). Bump it there and +`verify-workspace-config` flags every node repo that hasn't followed, with the +gap spelled out in the detail line (no `@biomejs/biome` dep → still on ESLint; +biome declared but no `biome.json`; or a version different from the fleet pin). +Adopt or catch up with: + +```bash +npm i -D @biomejs/biome@ # the version from the table above +npm pkg set scripts.lint="biome lint ." # add a biome.json, drop ESLint +``` + +The rule is **N/A for non-node repos** (Python, Rust — no `package.json`), so it +never fires there. `warn` for now so adoption rolls out without breaking CI; +tighten to `error` once every node repo is on biome. + ### Why `.automaker/settings.json` is committed per-repo The per-repo settings file pins the agent baseline — gateway model tiers diff --git a/lib/workspace-config.mjs b/lib/workspace-config.mjs index fa70860..e1aaca6 100644 --- a/lib/workspace-config.mjs +++ b/lib/workspace-config.mjs @@ -118,6 +118,29 @@ jobs: /** * The standard. Ordered; errors gate CI, warns are advisory. */ +/** + * The biome version the fleet tracks together. Single source of truth: bump it + * here and `verify-workspace-config` flags every JS/TS repo that hasn't followed, + * so the fleet upgrades biome in lockstep instead of drifting per-repo. + */ +export const FLEET_BIOME_VERSION = '2.4.16'; + +/** Strip a semver range prefix (^ ~ >= <= = and whitespace) → the bare version. */ +export function cleanVersion(spec) { + return String(spec ?? '') + .trim() + .replace(/^[\^~>=<\s]+/, ''); +} + +/** + * The declared @biomejs/biome version spec from a parsed package.json, or null + * if the repo doesn't depend on biome. Checks devDependencies then dependencies. + */ +export function biomeVersion(pkg) { + if (!pkg || typeof pkg !== 'object') return null; + return pkg.devDependencies?.['@biomejs/biome'] ?? pkg.dependencies?.['@biomejs/biome'] ?? null; +} + export const WORKSPACE_STANDARD = /** @type {Rule[]} */ ([ { id: 'beads-issues-jsonl', @@ -196,6 +219,33 @@ export const WORKSPACE_STANDARD = /** @type {Rule[]} */ ([ ok: (m) => m.hasFile(WORKFLOW_SECURITY_LINT_PATH), fix: `Add ${WORKFLOW_SECURITY_LINT_PATH} (run \`init-workspace-config\`, or call the reusable workflow: \`uses: protoLabsAI/release-tools/.github/workflows/workflow-security-lint.yml@main\`)`, }, + { + id: 'biome-linter', + description: `JS/TS repos lint with biome ${FLEET_BIOME_VERSION} — the fleet tracks one biome version together (no ESLint, no per-repo version drift)`, + // warn (not error) so adoption rolls out across the fleet without breaking CI + // on day one — same posture as workflow-security-lint. Tighten to error once + // every node repo is on biome. N/A for non-node repos (Python/Rust have no + // package.json) → ok=true so they don't show as violations. + severity: 'warn', + ok: (m) => { + if (!m.packageJson) return true; + const v = biomeVersion(m.packageJson); + if (!v) return false; + if (!m.hasFile('biome.json') && !m.hasFile('biome.jsonc')) return false; + return cleanVersion(v) === cleanVersion(FLEET_BIOME_VERSION); + }, + fix: `Track the fleet biome: \`npm i -D @biomejs/biome@${FLEET_BIOME_VERSION}\`, add a biome.json, set \`"lint": "biome lint ."\` (drop ESLint). Fleet version is pinned in release-tools FLEET_BIOME_VERSION.`, + detail: (m) => { + if (!m.packageJson) return []; + const v = biomeVersion(m.packageJson); + if (!v) return ['no @biomejs/biome dependency (still on ESLint, or unlinted?)']; + if (!m.hasFile('biome.json') && !m.hasFile('biome.jsonc')) + return [`@biomejs/biome ${v} declared but no biome.json / biome.jsonc`]; + if (cleanVersion(v) !== cleanVersion(FLEET_BIOME_VERSION)) + return [`biome ${v} — fleet tracks ${FLEET_BIOME_VERSION}`]; + return []; + }, + }, ]); /** diff --git a/test/workspace-config.test.mjs b/test/workspace-config.test.mjs index 85ecec7..94f1936 100644 --- a/test/workspace-config.test.mjs +++ b/test/workspace-config.test.mjs @@ -12,6 +12,9 @@ import { SCAFFOLD_FILES, REQUIRED_GITIGNORE, WORKFLOW_SECURITY_LINT_PATH, + FLEET_BIOME_VERSION, + cleanVersion, + biomeVersion, } from '../lib/workspace-config.mjs'; /** Build a manifest from a list of present files + gitignore text. */ @@ -364,3 +367,82 @@ test('planScaffold leaves selective .automaker/features/ alone (no false removal assert.deepEqual(plan.gitignoreRemovals, []); assert.deepEqual(plan.gitignoreAdditions, []); }); + +// ── biome-linter (fleet biome version tracking) ────────────────────────────── + +/** Build a manifest for a node repo with a given biome dep + biome.json presence. */ +function nodeManifest({ biomeDep, hasBiomeJson = true, dev = true } = {}) { + const files = new Set(hasBiomeJson ? ['biome.json'] : []); + const deps = biomeDep ? { '@biomejs/biome': biomeDep } : {}; + return { + hasFile: (p) => files.has(p), + gitignore: '', + packageJson: { [dev ? 'devDependencies' : 'dependencies']: deps }, + }; +} + +const biomeViolation = (m) => + evaluateStandard(m).violations.find((v) => v.id === 'biome-linter'); + +test('cleanVersion strips semver range prefixes', () => { + assert.equal(cleanVersion('2.4.16'), '2.4.16'); + assert.equal(cleanVersion('^2.4.16'), '2.4.16'); + assert.equal(cleanVersion('~2.4.16'), '2.4.16'); + assert.equal(cleanVersion('>=2.4.16'), '2.4.16'); + assert.equal(cleanVersion(' 2.4.16 '), '2.4.16'); + assert.equal(cleanVersion(undefined), ''); +}); + +test('biomeVersion reads devDependencies then dependencies, null when absent', () => { + assert.equal(biomeVersion({ devDependencies: { '@biomejs/biome': '2.4.16' } }), '2.4.16'); + assert.equal(biomeVersion({ dependencies: { '@biomejs/biome': '^2.0.0' } }), '^2.0.0'); + assert.equal(biomeVersion({ devDependencies: { eslint: '^9' } }), null); + assert.equal(biomeVersion(null), null); +}); + +test('biome-linter is N/A for non-node repos (no packageJson)', () => { + // Python/Rust repos have no package.json → rule passes, no violation. + const m = { hasFile: () => false, gitignore: '' }; + assert.equal(biomeViolation(m), undefined); + assert.ok(evaluateStandard(m).passed.includes('biome-linter')); +}); + +test('biome-linter passes when a node repo is on the fleet biome version', () => { + const m = nodeManifest({ biomeDep: FLEET_BIOME_VERSION }); + assert.equal(biomeViolation(m), undefined); + // a caret range on the same version still matches (cleanVersion strips ^) + assert.equal(biomeViolation(nodeManifest({ biomeDep: `^${FLEET_BIOME_VERSION}` })), undefined); +}); + +test('biome-linter warns (not errors) when a node repo has no biome dep', () => { + // Otherwise-conformant repo on ESLint → only the biome-linter warn fires, and + // a warn must not fail overall conformance. + const m = { ...goodManifest(), packageJson: { devDependencies: { eslint: '^9' } } }; + const r = evaluateStandard(m); + const v = r.violations.find((x) => x.id === 'biome-linter'); + assert.ok(v, 'expected a biome-linter violation'); + assert.equal(v.severity, 'warn'); + assert.match(v.detail.join(' '), /no @biomejs\/biome/); + assert.equal(r.ok, true, 'warn-severity biome gap must not fail conformance'); +}); + +test('biome-linter warns when biome is declared but no biome.json', () => { + const v = biomeViolation(nodeManifest({ biomeDep: FLEET_BIOME_VERSION, hasBiomeJson: false })); + assert.ok(v); + assert.match(v.detail.join(' '), /no biome\.json/); +}); + +test('biome-linter warns on version drift from the fleet pin', () => { + const v = biomeViolation(nodeManifest({ biomeDep: '2.3.0' })); + assert.ok(v); + assert.match(v.detail.join(' '), new RegExp(`fleet tracks ${FLEET_BIOME_VERSION.replace(/\./g, '\\.')}`)); +}); + +test('biome.jsonc satisfies the config requirement too', () => { + const m = { + hasFile: (p) => p === 'biome.jsonc', + gitignore: '', + packageJson: { devDependencies: { '@biomejs/biome': FLEET_BIOME_VERSION } }, + }; + assert.equal(biomeViolation(m), undefined); +});