diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 155333837e..71294470c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,6 +178,10 @@ jobs: npm run check:tui-copy node --test scripts/check-tui-copy.test.mjs + - name: Test staged Biome hook + if: steps.plan.outputs.code == 'true' + run: node --test scripts/biome-staged-check.test.mjs + # The header audit above remains install-free. The complete source gate # also exercises generation and therefore runs after its pinned formatter # dependency is installed, matching the source-candidate workflow. diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..1d3abf6074 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,21 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +node scripts/biome-staged-check.mjs +node scripts/asf-license-headers.mjs check-staged +node scripts/protocol-epoch-check.mjs --staged +git diff --cached --check diff --git a/package-lock.json b/package-lock.json index 1f8d76d718..48cd2ded83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@electron/asar": "4.3.0", "@types/node": "^26.2.0", "esbuild": "^0.28.1", + "husky": "^9.1.7", "knip": "^6.32.2", "patch-package": "8.0.1", "typescript": "^7.0.2", @@ -8962,6 +8963,22 @@ "node": ">= 6" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", diff --git a/package.json b/package.json index 9a845842a5..9ee149ca8d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "apps/desktop" ], "scripts": { - "prepare": "npm run sync:model-metadata", + "prepare": "npm run sync:model-metadata && node scripts/install-husky.mjs", "postinstall": "node scripts/apply-dependency-patches.mjs && node scripts/install-electron-with-retry.mjs", "lint": "biome lint .", "format": "biome format --write .", @@ -110,6 +110,7 @@ "@biomejs/biome": "2.5.9", "@types/node": "^26.2.0", "esbuild": "^0.28.1", + "husky": "^9.1.7", "knip": "^6.32.2", "patch-package": "8.0.1", "typescript": "^7.0.2", diff --git a/scripts/asf-license-headers.mjs b/scripts/asf-license-headers.mjs index 1e035f61e9..81707ed697 100644 --- a/scripts/asf-license-headers.mjs +++ b/scripts/asf-license-headers.mjs @@ -130,6 +130,7 @@ const coveredExtensions = new Map([ /** Covered files whose name carries no extension. */ const coveredNames = new Map([ ['Dockerfile', 'hash'], + ['pre-commit', 'hash'], // A POSIX shell script that the Eval egress sidecar invokes by name. ['network-policy', 'hash'], ]); @@ -617,6 +618,38 @@ export function auditTree({ root = defaultRepoRoot } = {}) { return { ...result, mode: listing.mode, root }; } +export function auditStaged({ root = defaultRepoRoot } = {}) { + const output = execFileSync('git', ['diff', '--cached', '--name-only', '--diff-filter=A', '-z'], { + cwd: root, + encoding: 'utf8', + maxBuffer: maxCommandBuffer, + }); + const files = output.split('\0').filter(Boolean); + const result = auditSourceFiles({ files, mode: 'staged' }); + for (const path of files) { + const classification = classifyPath(path); + if (classification.status !== 'covered') continue; + const contents = execFileSync('git', ['show', `:${path}`], { + cwd: root, + encoding: 'utf8', + maxBuffer: maxCommandBuffer, + }); + const status = classifyHeader(contents, classification.style, { + textAsData: licenseTextAsData.has(path), + }); + if (status === 'absent') result.missing.push(path); + else if (status === 'duplicated') result.duplicated.push(path); + else if (status === 'unrecognized') result.unrecognized.push(path); + if ( + !reviewedProvenance.has(path) && + provenanceMarkers.some((marker) => marker.test(contents)) + ) { + result.unreviewedProvenance.push(path); + } + } + return { ...result, mode: 'staged', root }; +} + export function writeHeaders({ root = defaultRepoRoot } = {}) { const { files } = listSourceFiles(root); const changed = []; @@ -651,8 +684,8 @@ function reportExclusions(result) { } } -function runCheck({ report, root }) { - const result = auditTree({ root }); +function runCheck({ report, root, staged = false }) { + const result = staged ? auditStaged({ root }) : auditTree({ root }); const excluded = [...result.excludedByRule.values()].reduce( (total, paths) => total + paths.length, 0, @@ -732,6 +765,10 @@ function main() { runCheck({ report, root }); return; } + if (command === 'check-staged') { + runCheck({ report, root, staged: true }); + return; + } if (command === 'write') { const changed = writeHeaders({ root }); console.log(`Added the ASF header to ${changed.length} file(s)`); diff --git a/scripts/asf-license-headers.test.mjs b/scripts/asf-license-headers.test.mjs index 20a2fc6b8b..fcb5bd9e20 100644 --- a/scripts/asf-license-headers.test.mjs +++ b/scripts/asf-license-headers.test.mjs @@ -213,6 +213,7 @@ describe('ASF header classification', () => { 'experiments/windows-sandbox/launcher/src/main.rs', 'packages/eval/harbor/egress-proxy/Dockerfile', 'packages/eval/harbor/egress-proxy/network-policy', + '.husky/pre-commit', '.github/workflows/ci.yml', 'README.md', ]) { diff --git a/scripts/biome-staged-check.mjs b/scripts/biome-staged-check.mjs new file mode 100644 index 0000000000..96c222882a --- /dev/null +++ b/scripts/biome-staged-check.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { execFileSync, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const defaultRepoRoot = resolve(scriptDirectory, '..'); +const defaultBiomePath = join( + defaultRepoRoot, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'biome.cmd' : 'biome', +); + +export function checkStagedWithBiome({ + root = defaultRepoRoot, + biomePath = defaultBiomePath, +} = {}) { + const output = execFileSync( + 'git', + ['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z'], + { cwd: root }, + ); + const paths = output.toString('utf8').split('\0').filter(Boolean); + + for (const path of paths) { + const contents = execFileSync('git', ['show', `:${path}`], { cwd: root }); + const result = spawnSync( + biomePath, + [ + 'check', + '--write', + `--stdin-file-path=${path}`, + '--files-ignore-unknown=true', + '--no-errors-on-unmatched', + ], + { cwd: root, input: contents }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + if (result.stdout.length > 0) process.stdout.write(result.stdout); + if (result.stderr.length > 0) process.stderr.write(result.stderr); + return false; + } + if (!result.stdout.equals(contents)) { + process.stderr.write(`${path}: staged content is not formatted by Biome\n`); + return false; + } + } + + return true; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (!checkStagedWithBiome()) process.exitCode = 1; +} diff --git a/scripts/biome-staged-check.test.mjs b/scripts/biome-staged-check.test.mjs new file mode 100644 index 0000000000..9c53d6e98e --- /dev/null +++ b/scripts/biome-staged-check.test.mjs @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { checkStagedWithBiome } from './biome-staged-check.mjs'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const biomePath = join( + repoRoot, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'biome.cmd' : 'biome', +); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'maka-biome-staged-')); + execFileSync('git', ['init', '-q'], { cwd: root }); + writeFileSync( + join(root, 'biome.json'), + JSON.stringify({ formatter: { enabled: true }, linter: { enabled: false } }), + ); + return root; +} + +test('checks staged bytes when the working tree was formatted afterward', () => { + const root = fixture(); + try { + const path = join(root, 'example.js'); + writeFileSync(path, 'const value={answer:42};\n'); + execFileSync('git', ['add', 'example.js'], { cwd: root }); + writeFileSync(path, 'const value = { answer: 42 };\n'); + + assert.equal(checkStagedWithBiome({ root, biomePath }), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('ignores unstaged formatting drift when staged bytes are formatted', () => { + const root = fixture(); + try { + const path = join(root, 'example.js'); + writeFileSync(path, 'const value = { answer: 42 };\n'); + execFileSync('git', ['add', 'example.js'], { cwd: root }); + writeFileSync(path, 'const value={answer:42};\n'); + + assert.equal(checkStagedWithBiome({ root, biomePath }), true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/install-husky.mjs b/scripts/install-husky.mjs new file mode 100644 index 0000000000..7bd9165cde --- /dev/null +++ b/scripts/install-husky.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +try { + const install = (await import('husky')).default; + process.stdout.write(install()); +} catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ERR_MODULE_NOT_FOUND' && + error.message.includes("package 'husky'") + ) { + console.warn('husky is not installed; skipping Git hook setup.'); + } else { + throw error; + } +} diff --git a/scripts/protocol-epoch-check.mjs b/scripts/protocol-epoch-check.mjs index db4dc96518..d4cf832d06 100644 --- a/scripts/protocol-epoch-check.mjs +++ b/scripts/protocol-epoch-check.mjs @@ -140,6 +140,56 @@ export function epochAtRevision(revision, exec = execFileSync) { return extractCompatibilityEpoch(git(['show', `${revision}:${EPOCH_FILE}`], exec)); } +function stagedFile(file, exec = execFileSync) { + return git(['show', `:${file}`], exec); +} + +export function evaluateStagedEpochCheck(exec = execFileSync) { + const changedProtocolFiles = git( + ['diff', '--cached', '--no-renames', '--name-only', 'HEAD', '--', PROTOCOL_DIR], + exec, + ) + .split('\n') + .filter(Boolean) + .filter((file) => !isStagedHeaderOnlyChange(file, exec)); + const declarations = git( + ['diff', '--cached', '--diff-filter=A', '--name-only', 'HEAD', '--', COMPATIBLE_CHANGE_DIR], + exec, + ) + .split('\n') + .filter(Boolean); + const compatibleProtocolFiles = []; + const headEpoch = extractCompatibilityEpoch(stagedFile(EPOCH_FILE, exec)); + for (const declaration of declarations) { + const value = JSON.parse(stagedFile(declaration, exec)); + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + value.epoch !== headEpoch || + !Array.isArray(value.files) || + value.files.length === 0 || + typeof value.reason !== 'string' || + value.reason.trim().length === 0 || + Object.keys(value).some((key) => !['epoch', 'files', 'reason'].includes(key)) + ) { + throw new Error(`Invalid compatible protocol change declaration: ${declaration}`); + } + for (const file of value.files) { + if (typeof file !== 'string' || !file.startsWith(PROTOCOL_DIR)) { + throw new Error(`Invalid protocol file in compatible change declaration: ${declaration}`); + } + compatibleProtocolFiles.push(file); + } + } + return evaluateEpochCheck({ + baseEpoch: epochAtRevision('HEAD', exec), + headEpoch, + changedProtocolFiles, + compatibleProtocolFiles, + }); +} + /** * Whether a file changed only by gaining the ASF license header. * @@ -174,19 +224,39 @@ export function isHeaderOnlyChange(file, base, head, exec = execFileSync) { } } +export function isStagedHeaderOnlyChange(file, exec = execFileSync) { + const style = classifyPath(file).style; + if (!style) return false; + try { + const before = git(['show', `HEAD:${file}`], exec); + const after = stagedFile(file, exec); + return applyHeader(before, style) === after; + } catch { + return false; + } +} + function parseArgs(args) { - const parsed = { base: undefined, head: 'HEAD' }; + const parsed = { base: undefined, head: 'HEAD', staged: false }; for (let index = 0; index < args.length; index += 1) { if (args[index] === '--base') parsed.base = args[++index]; else if (args[index] === '--head') parsed.head = args[++index]; + else if (args[index] === '--staged') parsed.staged = true; else throw new Error(`Unknown argument: ${args[index]}`); } + if (parsed.staged) return parsed; if (!parsed.base) throw new Error('Expected --base (and optionally --head )'); return parsed; } function main(args) { - const { base, head } = parseArgs(args); + const { base, head, staged } = parseArgs(args); + if (staged) { + const verdict = evaluateStagedEpochCheck(); + process.stderr.write(`Protocol epoch guard: ${verdict.reason}\n`); + if (!verdict.ok) process.exitCode = 1; + return; + } const headEpoch = epochAtRevision(head); const verdict = evaluateEpochCheck({ baseEpoch: epochAtRevision(base), diff --git a/scripts/protocol-epoch-check.test.mjs b/scripts/protocol-epoch-check.test.mjs index 69f8e8d23b..2982a52e72 100644 --- a/scripts/protocol-epoch-check.test.mjs +++ b/scripts/protocol-epoch-check.test.mjs @@ -29,6 +29,7 @@ import { EPOCH_FILE, epochAtRevision, evaluateEpochCheck, + evaluateStagedEpochCheck, extractCompatibilityEpoch, isHeaderOnlyChange, } from './protocol-epoch-check.mjs'; @@ -206,6 +207,37 @@ test('passes when nothing under the protocol directory changed', () => { } }); +test('checks staged protocol changes against the current HEAD epoch', () => { + const repo = mkdtempSync(join(tmpdir(), 'maka-protocol-epoch-staged-')); + const epochPath = join(repo, EPOCH_FILE); + const protocolFile = join(dirname(epochPath), 'example.ts'); + const runGit = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8' }); + const runInFixture = (file, args, options) => + execFileSync(file, args, { ...options, cwd: repo, encoding: 'utf8' }); + + try { + runGit('init', '--initial-branch=main'); + runGit('config', 'user.email', 'epoch-guard@example.invalid'); + runGit('config', 'user.name', 'Epoch Guard Test'); + runGit('config', 'commit.gpgSign', 'false'); + mkdirSync(dirname(epochPath), { recursive: true }); + writeFileSync(epochPath, 'export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 27 as const;\n'); + writeFileSync(protocolFile, 'export const example = 1;\n'); + runGit('add', '.'); + runGit('commit', '-m', 'base'); + + writeFileSync(protocolFile, 'export const example = 2;\n'); + runGit('add', protocolFile); + assert.equal(evaluateStagedEpochCheck(runInFixture).ok, false); + + writeFileSync(epochPath, 'export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 28 as const;\n'); + runGit('add', epochPath); + assert.equal(evaluateStagedEpochCheck(runInFixture).ok, true); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + /** * The guard asks whether the protocol changed, and uses "a file under the * protocol directory was touched" as a conservative proxy. Inserting the ASF