diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000000..63f35f074db --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# +# 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. +# +# Atlas monorepo: pre-commit (license, lint, typecheck, syntax; dashboard test guard) +# Skip: SKIP_ATLAS_HOOKS=1 | SKIP_ALL_ATLAS_GIT_HOOKS=1 +set -e +if [ "$SKIP_ATLAS_HOOKS" = "1" ] || [ "$SKIP_ALL_ATLAS_GIT_HOOKS" = "1" ]; then exit 0; fi +ROOT="$(git rev-parse --show-toplevel)" +exec node "$ROOT/scripts/git-hooks/run-precommit.mjs" diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000000..aef7dc7545f --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# +# 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. +# +# Atlas monorepo: pre-push (dashboard Jest/eslint/build; dashboardv2 + docs build) +# Skip: SKIP_ATLAS_HOOKS=1 | SKIP_ALL_ATLAS_GIT_HOOKS=1 +set -e +if [ "$SKIP_ATLAS_HOOKS" = "1" ] || [ "$SKIP_ALL_ATLAS_GIT_HOOKS" = "1" ]; then exit 0; fi +ROOT="$(git rev-parse --show-toplevel)" +exec node "$ROOT/scripts/git-hooks/run-prepush.mjs" diff --git a/dashboard/.githooks/pre-commit b/dashboard/.githooks/pre-commit new file mode 100755 index 00000000000..1edfb24ea4f --- /dev/null +++ b/dashboard/.githooks/pre-commit @@ -0,0 +1,21 @@ +#!/usr/bin/env sh +# +# 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. +# +# Deprecated path: use repo root core.hooksPath=.githooks (see dashboard/scripts/install-git-hooks.mjs). +ROOT="$(git rev-parse --show-toplevel)" +exec "$ROOT/.githooks/pre-commit" diff --git a/dashboard/.githooks/pre-push b/dashboard/.githooks/pre-push new file mode 100755 index 00000000000..899c8031812 --- /dev/null +++ b/dashboard/.githooks/pre-push @@ -0,0 +1,20 @@ +#!/usr/bin/env sh +# +# 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. +# +ROOT="$(git rev-parse --show-toplevel)" +exec "$ROOT/.githooks/pre-push" diff --git a/dashboard/.husky/pre-commit b/dashboard/.husky/pre-commit new file mode 100644 index 00000000000..2be87c1c37b --- /dev/null +++ b/dashboard/.husky/pre-commit @@ -0,0 +1,21 @@ +#!/usr/bin/sh +# +# 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. +# +# Git hooks: core.hooksPath=.githooks at Atlas repo root (see dashboard npm prepare). +printf '%s\n' '[atlas] Hooks: git config core.hooksPath .githooks OR npm install (in dashboard/)' +exit 0 diff --git a/dashboard/docs/GIT_HOOKS.md b/dashboard/docs/GIT_HOOKS.md new file mode 100644 index 00000000000..1265b0a02d4 --- /dev/null +++ b/dashboard/docs/GIT_HOOKS.md @@ -0,0 +1,145 @@ +/** + * 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. + */ + +# Atlas Git hooks (dashboard, dashboardv2, docs) + +Hooks run **locally** before `git commit` and `git push` so common issues are +caught early. **CI** on the server is still required to enforce merges. + +## One-time setup (per clone) + +From the **Atlas repo root** (`atlas/`, where `.git` lives): + +```bash +git config core.hooksPath .githooks +``` + +Or run **`npm install`** inside **`dashboard/`** — the **`prepare`** script runs +`dashboard/scripts/install-git-hooks.mjs`, which sets **`core.hooksPath=.githooks`** +when Git config is writable. + +Verify: + +```bash +git config --get core.hooksPath +# expect: .githooks +``` + +The active hook scripts live in **`.githooks/`** at the **repository root**. +`dashboard/.githooks/*` only forwards to the root hooks (legacy path compat). + +## What runs when + +### `pre-commit` (root: `scripts/git-hooks/run-precommit.mjs`) + +Runs **only for packages that have staged paths** under that prefix. + +| Area | When staged under … | Checks | +|------|---------------------|--------| +| **dashboard** | `dashboard/` | (1) **UI test guard** — `src/views`, `src/components`, `App.tsx` / `Main.tsx` / `ErrorBoundary.tsx` must include a **staged** test file; (2) **RAT-aligned ASF license** on **new** files under `dashboard/src/` (`license-header-policy.mjs` markers, same bar as CI RAT); (3) **lint-staged** → ESLint on staged TS/TSX; (4) **`npm run typecheck`** (`tsc --noEmit`). | +| **dashboardv2** | `dashboardv2/` | (1) **ASF license** on **new** `.js`/`.jsx`/`.ts`/`.tsx` (skips `node_modules`, `bin/`, `external_lib`, `.min.js`); (2) **`node --check`** on staged plain `.js` under `dashboardv2/public/js/` (syntax). **No** Jest/test guard (legacy Grunt UI). | +| **docs** | `docs/` | (1) **ASF license** on **new** sources (skips `node_modules`, `site/`, `bin/`, `docz-lib/`); (2) **`node --check`** on staged **plain** `docs/**/*.js` outside theme/webapp JSX trees. | + +### `pre-push` (root: `scripts/git-hooks/run-prepush.mjs`) + +Runs for each package **if commits in the push range** touch that prefix. + +| Area | Checks | +|------|--------| +| **dashboard** | **RAT-aligned ASF header** on **new** `dashboard/src/` files in the push range, colocated tests on disk, **`jest --findRelatedTests`**, **`eslint src`**, **`npm run build`**. | +| **dashboardv2** | **`npm run build`** (Grunt). | +| **docs** | **`npm run build`** (Docz). | + +## Skip hooks (emergency / slow machines) + +Disable **everything**: + +```bash +SKIP_ATLAS_HOOKS=1 git commit ... +SKIP_ATLAS_HOOKS=1 git push ... +``` + +Per **package**: + +```bash +SKIP_DASHBOARD_HOOKS=1 git commit ... +SKIP_DASHBOARDV2_HOOKS=1 git commit ... +SKIP_DOCS_HOOKS=1 git commit ... +``` + +**dashboard** only (still documented): + +```bash +SKIP_DASHBOARD_TEST_GUARD=1 git commit ... # staged test file rule +SKIP_DASHBOARD_LICENSE_CHECK=1 git commit ... # RAT-aligned ASF on new dashboard/src (also used by pre-push added-file check) +SKIP_DASHBOARD_TYPECHECK=1 git commit ... # tsc on commit +``` + +**dashboardv2 / docs** ASF license on new files: + +```bash +SKIP_ATLAS_LICENSE_CHECK=1 git commit ... +``` + +Skip **long builds** on push: + +```bash +SKIP_DASHBOARDV2_BUILD=1 git push ... +SKIP_DOCS_BUILD=1 git push ... +``` + +## Manual run (no Git hook) + +From **repo root** `atlas/`: + +```bash +node scripts/git-hooks/run-precommit.mjs +node scripts/git-hooks/run-prepush.mjs +``` + +**dashboard**-only local verify (same as before): + +```bash +cd dashboard && npm run verify:precommit +cd dashboard && npm run verify:prepush +``` + +## Limitations + +- **dashboardv2** has no ESLint in-repo; **`node --check`** only catches **syntax** on selected `.js` paths, not style. +- **docs** JSX/theme files are not run through `node --check`. +- Hooks can be bypassed with env vars; **rely on CI** for PR enforcement. + +## Files (reference) + +| Path | Role | +|------|------| +| `.githooks/pre-commit` | Root hook → `run-precommit.mjs` | +| `.githooks/pre-push` | Root hook → `run-prepush.mjs` | +| `scripts/git-hooks/run-precommit.mjs` | Monorepo pre-commit orchestration | +| `scripts/git-hooks/run-prepush.mjs` | Monorepo pre-push orchestration | +| `scripts/git-hooks/check-added-license-generic.mjs` | ASF header for v2/docs new files | +| `scripts/git-hooks/syntax-check-staged.mjs` | `node --check` for v2/docs | +| `scripts/git-hooks/lib/git-helpers.mjs` | `git diff` helpers | +| `scripts/git-hooks/lib/extra-license-skip.mjs` | Path skip rules for v2/docs | +| `dashboard/scripts/install-git-hooks.mjs` | Sets `core.hooksPath=.githooks` | +| `dashboard/scripts/git-precommit-verify.mjs` | Dashboard staged UI ↔ test guard | +| `dashboard/scripts/check-staged-new-file-license.mjs` | Dashboard ASF on new files | +| `dashboard/scripts/git-prepush-verify.mjs` | Dashboard Jest, ESLint, build | +| `dashboard/scripts/run-precommit-local.mjs` | `npm run verify:precommit` (dashboard only) | +| `dashboard/lint-staged.config.mjs` | ESLint on staged dashboard sources | diff --git a/dashboard/lint-staged.config.mjs b/dashboard/lint-staged.config.mjs new file mode 100644 index 00000000000..aaf7dbec3ff --- /dev/null +++ b/dashboard/lint-staged.config.mjs @@ -0,0 +1,32 @@ +/** + * 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. + */ + +/** + * lint-staged config: Git reports paths as dashboard/src/... from repo root; + * ESLint runs with cwd = dashboard, so strip the dashboard/ prefix. + */ + +export default { + 'dashboard/src/**/*.{ts,tsx}': (filenames) => { + if (filenames.length === 0) { + return process.platform === 'win32' ? 'node -e "process.exit(0)"' : 'true' + } + const relative = filenames.map((f) => f.replace(/^dashboard\//, '')) + return `eslint --max-warnings 200 ${relative.join(' ')}` + }, +} diff --git a/dashboard/package.json b/dashboard/package.json index 6cb8364f188..38c3106ec06 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -8,6 +8,7 @@ "dev": "vite", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 200", + "typecheck": "tsc --noEmit", "preview": "vite preview", "prebuild": "cd src/views/Lineage/atlas-lineage && (npm ci 2>/dev/null || npm install) && npm run build" }, diff --git a/dashboard/scripts/check-push-new-file-license.mjs b/dashboard/scripts/check-push-new-file-license.mjs new file mode 100644 index 00000000000..20fe3b953d5 --- /dev/null +++ b/dashboard/scripts/check-push-new-file-license.mjs @@ -0,0 +1,80 @@ +/** + * 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. + */ + +/** + * Pre-push: any *new* dashboard/src source files in the push range must carry + * a RAT-aligned Apache license header at HEAD (same policy as pre-commit). + * + * Skip: SKIP_DASHBOARD_HOOKS=1 | SKIP_DASHBOARD_LICENSE_CHECK=1 + */ + +import { execFileSync } from 'node:child_process' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { getPushAddedRepoPaths } from './lib/git-changed-files.mjs' +import { + gitShowUtf8, + listDashboardSrcAddedMissingLicense, +} from './lib/verify-dashboard-src-license.mjs' + +if ( + process.env.SKIP_DASHBOARD_HOOKS === '1' || + process.env.SKIP_DASHBOARD_LICENSE_CHECK === '1' || + process.env.HUSKY === '0' +) { + process.exit(0) +} + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const dashboardDir = join(__dirname, '..') + +let repoRoot +try { + repoRoot = String( + execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', + cwd: dashboardDir, + }), + ).trim() +} catch { + console.warn('[license-check push] Not in a Git work tree; skipping.') + process.exit(0) +} + +const added = getPushAddedRepoPaths(repoRoot) +const missing = listDashboardSrcAddedMissingLicense( + added, + (norm) => gitShowUtf8(repoRoot, `HEAD:${norm}`), +) + +if (missing.length > 0) { + console.error( + '\x1b[31m[dashboard pre-push]\x1b[0m Added file(s) lack a RAT-aligned Apache license header:', + ) + for (const m of missing.sort()) { + console.error(` - ${m}`) + } + console.error( + '\nInclude the full standard ASF block at the top (see license-header-policy.mjs markers).', + 'Or set SKIP_DASHBOARD_LICENSE_CHECK=1 only for rare exceptions.\n', + ) + process.exit(1) +} + +process.exit(0) diff --git a/dashboard/scripts/check-staged-new-file-license.mjs b/dashboard/scripts/check-staged-new-file-license.mjs new file mode 100644 index 00000000000..93167e0515e --- /dev/null +++ b/dashboard/scripts/check-staged-new-file-license.mjs @@ -0,0 +1,99 @@ +/** + * 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. + */ + +/** + * Pre-commit: newly added (staged) source files under dashboard/src must carry + * a RAT-aligned Apache license header (see license-header-policy.mjs). + * + * Skip: SKIP_DASHBOARD_HOOKS=1 | SKIP_DASHBOARD_LICENSE_CHECK=1 + */ + +import { execFileSync } from 'node:child_process' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + gitShowUtf8, + listDashboardSrcAddedMissingLicense, +} from './lib/verify-dashboard-src-license.mjs' + +if ( + process.env.SKIP_DASHBOARD_HOOKS === '1' || + process.env.SKIP_DASHBOARD_LICENSE_CHECK === '1' || + process.env.HUSKY === '0' +) { + process.exit(0) +} + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const dashboardDir = join(__dirname, '..') + +const shLines = (args) => { + try { + return String( + execFileSync('git', args, { + encoding: 'utf8', + cwd: dashboardDir, + maxBuffer: 20 * 1024 * 1024, + }), + ) + .trim() + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + } catch { + return [] + } +} + +let repoRoot +try { + repoRoot = String( + execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', + cwd: dashboardDir, + }), + ).trim() +} catch { + console.warn('[license-check] Not in a Git work tree; skipping.') + process.exit(0) +} + +const added = shLines(['-C', repoRoot, 'diff', '--cached', '--name-only', '--diff-filter=A']) + +const missing = listDashboardSrcAddedMissingLicense( + added, + (norm) => gitShowUtf8(repoRoot, `:${norm}`), +) + +if (missing.length > 0) { + console.error( + '\x1b[31m[dashboard pre-commit]\x1b[0m New file(s) lack a RAT-aligned Apache license header:', + ) + for (const m of missing.sort()) { + console.error(` - ${m}`) + } + console.error( + '\nInclude the full standard ASF block at the top (see CreateDropdown.tsx or audit sibling files).', + 'Required markers match dashboard/scripts/lib/license-header-policy.mjs.', + 'Or set SKIP_DASHBOARD_LICENSE_CHECK=1 only for rare exceptions.\n', + ) + process.exit(1) +} + +process.exit(0) diff --git a/dashboard/scripts/git-precommit-verify.mjs b/dashboard/scripts/git-precommit-verify.mjs new file mode 100644 index 00000000000..15b69c55255 --- /dev/null +++ b/dashboard/scripts/git-precommit-verify.mjs @@ -0,0 +1,50 @@ +/** + * 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. + */ + +/** + * Pre-commit: ensure UI changes stage tests; lint-staged runs ESLint after this. + * Skip: SKIP_DASHBOARD_HOOKS=1 or HUSKY=0 or SKIP_DASHBOARD_TEST_GUARD=1 + */ + +import { stagedIncludesTestWhenUiChanges } from './lib/test-path-helpers.mjs' +import { getStagedFiles } from './lib/git-changed-files.mjs' + +if (process.env.SKIP_DASHBOARD_HOOKS === '1' || process.env.HUSKY === '0') { + process.exit(0) +} + +if (process.env.SKIP_DASHBOARD_TEST_GUARD === '1') { + process.exit(0) +} + +const staged = getStagedFiles() +const dashboardPaths = staged.filter( + (p) => p.startsWith('dashboard/') || p.startsWith('src/'), +) + +if (dashboardPaths.length === 0) { + process.exit(0) +} + +const guard = stagedIncludesTestWhenUiChanges(dashboardPaths) +if (!guard.ok) { + console.error('\x1b[31m[dashboard pre-commit]\x1b[0m', guard.message) + process.exit(1) +} + +process.exit(0) diff --git a/dashboard/scripts/git-prepush-verify.mjs b/dashboard/scripts/git-prepush-verify.mjs new file mode 100644 index 00000000000..9bb86682838 --- /dev/null +++ b/dashboard/scripts/git-prepush-verify.mjs @@ -0,0 +1,132 @@ +/** + * 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. + */ + +/** + * Pre-push: impact-related Jest tests, ESLint (src), production build. + * Skip: SKIP_DASHBOARD_HOOKS=1 or HUSKY=0 + */ + +import { execFileSync, execSync, spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { getPushRangeFiles } from './lib/git-changed-files.mjs' +import { + allUiChangesHaveTestHome, + isUiSourcePath, + toDashboardRelative, +} from './lib/test-path-helpers.mjs' + +if (process.env.SKIP_DASHBOARD_HOOKS === '1' || process.env.HUSKY === '0') { + process.exit(0) +} + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const dashboardRoot = join(__dirname, '..') +if (!existsSync(join(dashboardRoot, 'package.json'))) { + console.error('Could not find dashboard root', dashboardRoot) + process.exit(1) +} + +if (process.env.SKIP_DASHBOARD_LICENSE_CHECK !== '1') { + console.log( + '\x1b[35m[dashboard pre-push]\x1b[0m RAT-aligned ASF header on newly added dashboard/src files…', + ) + execFileSync(process.execPath, ['scripts/check-push-new-file-license.mjs'], { + cwd: dashboardRoot, + stdio: 'inherit', + }) +} + +const run = (cmd, opts = {}) => { + console.log(`\x1b[36m▶\x1b[0m ${cmd}`) + execSync(cmd, { stdio: 'inherit', cwd: dashboardRoot, ...opts }) +} + +const repoPaths = getPushRangeFiles() +const dashboardPaths = repoPaths.filter( + (p) => p.startsWith('dashboard/') || p.startsWith('src/'), +) + +const dashRelFiles = dashboardPaths.map(toDashboardRelative).filter((p) => { + if (p.startsWith('..')) return false + return existsSync(join(dashboardRoot, p)) +}) + +/** Source files Jest can map to related tests */ +const jestSourceArgs = dashRelFiles.filter((p) => { + if (!p.startsWith('src/')) return false + if (p.includes('__tests__')) return false + if (/\.(test|spec)\.(tsx?)$/.test(p)) return false + return /\.(ts|tsx)$/.test(p) +}) + +if (process.env.SKIP_DASHBOARD_TEST_GUARD !== '1') { + const hasUi = dashboardPaths.map(toDashboardRelative).some(isUiSourcePath) + if (hasUi) { + const { ok, missing } = allUiChangesHaveTestHome( + dashboardRoot, + dashboardPaths, + ) + if (!ok) { + console.error( + '\x1b[31m[dashboard pre-push]\x1b[0m These UI files have no colocated __tests__ or *.test.ts(x):', + ) + for (const m of missing) { + console.error(` - ${m}`) + } + console.error( + 'Add tests or set SKIP_DASHBOARD_TEST_GUARD=1 only for exceptions.\n', + ) + process.exit(1) + } + } +} + +console.log('\x1b[35m[dashboard pre-push]\x1b[0m Changed paths in range (sample):') +console.log( + dashRelFiles.slice(0, 20).join('\n') + (dashRelFiles.length > 20 ? '\n…' : ''), +) + +if (jestSourceArgs.length > 0) { + console.log( + '\x1b[35m[dashboard pre-push]\x1b[0m Running Jest --findRelatedTests (impact surface):', + ) + const rel = jestSourceArgs.map((f) => + relative(dashboardRoot, join(dashboardRoot, f)).replace(/\\/g, '/'), + ) + const res = spawnSync( + process.platform === 'win32' ? 'npx.cmd' : 'npx', + ['jest', '--bail', '--passWithNoTests', '--findRelatedTests', ...rel], + { cwd: dashboardRoot, stdio: 'inherit', shell: process.platform === 'win32' }, + ) + if (res.status !== 0) process.exit(res.status ?? 1) +} else { + console.log( + '\x1b[33m[dashboard pre-push]\x1b[0m No TS source files in diff for --findRelatedTests; skipping Jest.', + ) +} + +run( + 'npx eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 200', +) +run('npm run build') + +console.log('\x1b[32m[dashboard pre-push]\x1b[0m All checks passed.\n') +process.exit(0) diff --git a/dashboard/scripts/install-git-hooks.mjs b/dashboard/scripts/install-git-hooks.mjs new file mode 100644 index 00000000000..75624867e17 --- /dev/null +++ b/dashboard/scripts/install-git-hooks.mjs @@ -0,0 +1,58 @@ +/** + * 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. + */ + +/** + * Point this Git repo at .githooks (repo root) so pre-commit / pre-push run for + * dashboard, dashboardv2, and docs. Runs after `npm install` in dashboard/. + * Safe no-op if not inside a Git work tree. + */ + +import { execSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const dashboardDir = join(__dirname, '..') + +let top +try { + top = execSync('git rev-parse --show-toplevel', { + encoding: 'utf8', + cwd: dashboardDir, + }).trim() +} catch { + process.exit(0) +} + +const hooksPath = '.githooks' +const absHooks = join(top, hooksPath) +if (!existsSync(absHooks)) { + console.warn('[install-git-hooks] Skipping: missing', absHooks) + process.exit(0) +} + +try { + execSync(`git config core.hooksPath "${hooksPath}"`, { + cwd: top, + stdio: 'inherit', + }) + console.log('[install-git-hooks] core.hooksPath =', hooksPath) +} catch (e) { + console.warn('[install-git-hooks] Could not set core.hooksPath (read-only?)') +} diff --git a/dashboard/scripts/lib/git-changed-files.mjs b/dashboard/scripts/lib/git-changed-files.mjs new file mode 100644 index 00000000000..87ba710d78d --- /dev/null +++ b/dashboard/scripts/lib/git-changed-files.mjs @@ -0,0 +1,154 @@ +/** + * 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. + */ + +/** + * Resolve paths changed in git (staged, committed range, or vs base branch). + */ + +import { execFileSync, execSync } from 'node:child_process' + +/** + * @param {string} cmd + * @returns {string} + */ +const sh = (cmd) => + String(execSync(cmd, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 })).trim() + +/** + * @param {string} raw + * @returns {string[]} + */ +export const splitLines = (raw) => + raw + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + +/** + * Files staged for commit. + * @returns {string[]} + */ +export const getStagedFiles = () => { + try { + return splitLines(sh('git diff --cached --name-only --diff-filter=ACM')) + } catch { + return [] + } +} + +/** + * Files changed between remote tracking branch and HEAD (for pre-push). + * Falls back to merge-base with main/master or single last commit. + * @returns {string[]} + */ +export const getPushRangeFiles = () => { + const tryRange = (range) => { + try { + return splitLines(sh(`git diff --name-only ${range}`)) + } catch { + return null + } + } + + let files = tryRange('@{u}..HEAD') + if (files && files.length > 0) return files + + for (const base of ['origin/master', 'origin/main', 'main', 'master']) { + try { + const mergeBase = sh(`git merge-base HEAD ${base} 2>/dev/null`) + if (mergeBase) { + files = tryRange(`${mergeBase}..HEAD`) + if (files && files.length > 0) return files + } + } catch { + // continue + } + } + + try { + return splitLines(sh('git diff --name-only HEAD~1..HEAD')) + } catch { + return [] + } +} + +/** + * Revision range for `git diff` comparing this branch to its upstream / mainline. + * @param {string} repoRoot absolute repo root + * @returns {string} revRange e.g. `@{u}..HEAD`, `abc..HEAD`, `HEAD~1..HEAD` + */ +export const resolvePushRevRange = (repoRoot) => { + const execGit = (args) => + String( + execFileSync('git', ['-C', repoRoot, ...args], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }), + ).trim() + + const tryRange = (range) => { + try { + execGit(['diff', '--name-only', range]) + return range + } catch { + return null + } + } + + let range = tryRange('@{u}..HEAD') + if (range) return range + + for (const base of ['origin/master', 'origin/main', 'main', 'master']) { + try { + const mergeBase = execGit(['merge-base', 'HEAD', base]) + if (mergeBase) { + const r = `${mergeBase}..HEAD` + if (tryRange(r)) return r + } + } catch { + // continue + } + } + + return 'HEAD~1..HEAD' +} + +/** + * Repo-relative paths added (not present at merge-base / range start) in push range. + * @param {string} repoRoot + * @returns {string[]} + */ +export const getPushAddedRepoPaths = (repoRoot) => { + const range = resolvePushRevRange(repoRoot) + try { + return splitLines( + String( + execFileSync( + 'git', + ['-C', repoRoot, 'diff', '--name-only', '--diff-filter=A', range], + { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + }, + ), + ).trim(), + ) + } catch { + return [] + } +} diff --git a/dashboard/scripts/lib/license-header-policy.mjs b/dashboard/scripts/lib/license-header-policy.mjs new file mode 100644 index 00000000000..cb3f2abbdd7 --- /dev/null +++ b/dashboard/scripts/lib/license-header-policy.mjs @@ -0,0 +1,84 @@ +/** + * 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. + */ + +/** + * ASF license header policy aligned with src/__tests__/apache-license-header.test.ts + * (paths here are relative to dashboard/src/). + */ + +/** + * Substrings that must all appear in the first HEADER_READ_BYTES bytes of the file. + * Aligns with the standard Atlas dashboard header and Apache RAT expectations + * (ASL2-style notice), so CI RAT and local hooks enforce the same bar. + */ +export const RAT_ALIGNED_REQUIRED_MARKERS = [ + 'Licensed to the Apache Software Foundation', + 'Apache License, Version 2.0', + 'http://www.apache.org/licenses/LICENSE-2.0', + 'Unless required by applicable law or agreed to in writing', + 'WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND', + 'limitations under the License', +] + +/** @deprecated use {@link RAT_ALIGNED_REQUIRED_MARKERS} — kept for callers that only need the lead-in */ +export const LICENSE_MARKERS = RAT_ALIGNED_REQUIRED_MARKERS.slice(0, 2) + +export const HEADER_READ_BYTES = 12_000 + +/** + * @param {string} relativePosix path relative to src/, forward slashes + * @returns {boolean} + */ +export const isLicenseCheckSkippedForSrcRel = (relativePosix) => { + const segments = relativePosix.split('/') + if (segments.includes('__tests__')) return true + if (segments.includes('__mocks__')) return true + if (/\.test\.(ts|tsx|js|jsx)$/.test(relativePosix)) return true + if (relativePosix.endsWith('.d.ts')) return true + if ( + relativePosix === 'setupTests.ts' || + relativePosix === 'setupTests.simple.ts' + ) { + return true + } + if (relativePosix === 'utils/test-utils.tsx') return true + return false +} + +/** + * Collapse whitespace so wrapped ASF headers still match marker substrings. + * @param {string} head + * @returns {string} + */ +const normalizeHeaderWhitespace = (head) => head.replace(/\s+/gu, ' ').trim() + +/** + * @param {string} head first bytes of file as string + * @returns {boolean} + */ +export const contentHasAsfHeader = (head) => { + const n = normalizeHeaderWhitespace(head.slice(0, HEADER_READ_BYTES)) + return RAT_ALIGNED_REQUIRED_MARKERS.every((marker) => n.includes(marker)) +} + +/** + * Alias for {@link contentHasAsfHeader} (explicit RAT-oriented name for tools/tests). + * @param {string} head + * @returns {boolean} + */ +export const contentHasRatApprovedAsfHeader = (head) => contentHasAsfHeader(head) diff --git a/dashboard/scripts/lib/test-path-helpers.mjs b/dashboard/scripts/lib/test-path-helpers.mjs new file mode 100644 index 00000000000..1ee3894c438 --- /dev/null +++ b/dashboard/scripts/lib/test-path-helpers.mjs @@ -0,0 +1,149 @@ +/** + * 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. + */ + +/** + * Helpers: detect UI source files and colocated / __tests__ coverage. + */ + +import { existsSync, readdirSync } from 'node:fs' +import { basename, dirname, extname, join } from 'node:path' + +/** Root-level React entry files (same risk as views/components). */ +const ROOT_UI_FILES = new Set([ + 'src/App.tsx', + 'src/Main.tsx', + 'src/ErrorBoundary.tsx', +]) + +/** Normalize to path relative to dashboard/ */ +export const toDashboardRelative = (repoPath) => { + const norm = repoPath.replace(/\\/g, '/') + if (norm.startsWith('dashboard/')) return norm.slice('dashboard/'.length) + return norm +} + +/** Production React UI paths worth guarding with tests */ +export const isUiSourcePath = (dashboardRel) => { + if (!dashboardRel.startsWith('src/')) return false + if (dashboardRel.includes('__tests__')) return false + if (dashboardRel.includes('__mocks__')) return false + if (/\.(test|spec)\.(tsx?|jsx?)$/.test(dashboardRel)) return false + if (!/\.(tsx|jsx)$/.test(dashboardRel)) return false + if (ROOT_UI_FILES.has(dashboardRel)) return true + if ( + !dashboardRel.startsWith('src/views/') && + !dashboardRel.startsWith('src/components/') + ) { + return false + } + return true +} + +/** + * @param {string} absFile absolute path to source file + */ +export const hasColocatedOrDirTests = (absFile) => { + const dir = dirname(absFile) + const ext = extname(absFile) + const base = basename(absFile, ext) + + const candidates = [ + join(dir, `${base}.test.tsx`), + join(dir, `${base}.test.ts`), + join(dir, '__tests__', `${base}.test.tsx`), + join(dir, '__tests__', `${base}.test.ts`), + ] + + for (const c of candidates) { + if (existsSync(c)) return true + } + + if (base === 'App') { + const appTest = join(dir, 'components', '__tests__', 'App.test.tsx') + if (existsSync(appTest)) return true + } + + if (base === 'EntityForm') { + const viewsDir = dirname(dir) + const viewTests = join(viewsDir, '__tests__', 'EntityForm.test.tsx') + if (existsSync(viewTests)) return true + const viewTestsTs = join(viewsDir, '__tests__', 'EntityForm.test.ts') + if (existsSync(viewTestsTs)) return true + } + + const testsDir = join(dir, '__tests__') + if (existsSync(testsDir)) { + const entries = readdirSync(testsDir) + if (entries.some((e) => /\.(test|spec)\.(tsx?|jsx?)$/.test(e))) return true + } + + return false +} + +/** + * @param {string} dashboardRoot absolute path to dashboard package + * @param {string} dashboardRel e.g. src/views/Foo/Bar.tsx + */ +export const hasTestsOnDisk = (dashboardRoot, dashboardRel) => { + const abs = join(dashboardRoot, dashboardRel) + if (!existsSync(abs)) return false + return hasColocatedOrDirTests(abs) +} + +/** + * Staged files must include at least one test when UI sources are staged. + * @param {string[]} repoPaths paths from git (dashboard/... or src/...) + * @returns {{ ok: boolean, message?: string }} + */ +export const stagedIncludesTestWhenUiChanges = (repoPaths) => { + const dashPaths = repoPaths + .map(toDashboardRelative) + .filter((p) => !p.startsWith('..')) + + const uiChanged = dashPaths.filter(isUiSourcePath) + if (uiChanged.length === 0) return { ok: true } + + const testTouched = dashPaths.some( + (p) => + p.includes('__tests__') || /\.(test|spec)\.(tsx?|jsx?)$/.test(p), + ) + + if (testTouched) return { ok: true } + + return { + ok: false, + message: + 'Staged changes touch src/views or src/components (.tsx/.jsx) but no test file was staged.\n' + + 'Add or update a matching *.test.ts(x) or __tests__/* file, or set SKIP_DASHBOARD_TEST_GUARD=1 for a rare exception.', + } +} + +/** + * For each changed UI file, require on-disk test companion. + * @param {string} dashboardRoot + * @param {string[]} repoPaths + * @returns {{ ok: boolean, missing: string[] }} + */ +export const allUiChangesHaveTestHome = (dashboardRoot, repoPaths) => { + const dashPaths = repoPaths + .map(toDashboardRelative) + .filter((p) => p.startsWith('src/')) + const ui = [...new Set(dashPaths.filter(isUiSourcePath))] + const missing = ui.filter((p) => !hasTestsOnDisk(dashboardRoot, p)) + return { ok: missing.length === 0, missing } +} diff --git a/dashboard/scripts/lib/verify-dashboard-src-license.mjs b/dashboard/scripts/lib/verify-dashboard-src-license.mjs new file mode 100644 index 00000000000..53511e49baa --- /dev/null +++ b/dashboard/scripts/lib/verify-dashboard-src-license.mjs @@ -0,0 +1,78 @@ +/** + * 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. + */ + +/** + * Shared ASF header verification for dashboard/src (pre-commit + pre-push). + */ + +import { execFileSync } from 'node:child_process' + +import { + HEADER_READ_BYTES, + contentHasAsfHeader, + isLicenseCheckSkippedForSrcRel, +} from './license-header-policy.mjs' + +const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx']) + +/** + * @param {string[]} addedRepoPaths paths from git (e.g. dashboard/src/Foo.tsx) + * @param {(repoNormPath: string) => string} readBlob git show spec resolved to UTF-8 + * @returns {string[]} repo-relative paths missing a RAT-aligned header + */ +export const listDashboardSrcAddedMissingLicense = (addedRepoPaths, readBlob) => { + const missing = [] + + for (const repoPath of addedRepoPaths) { + const norm = repoPath.replace(/\\/g, '/') + if (!norm.startsWith('dashboard/src/')) continue + + const ext = norm.slice(norm.lastIndexOf('.')) + if (!SOURCE_EXT.has(ext)) continue + + const srcRel = norm.slice('dashboard/src/'.length) + if (isLicenseCheckSkippedForSrcRel(srcRel)) continue + + let content + try { + content = readBlob(norm) + } catch { + continue + } + + const head = content.slice(0, HEADER_READ_BYTES) + if (!contentHasAsfHeader(head)) { + missing.push(norm) + } + } + + return missing +} + +/** + * @param {string} repoRoot + * @param {string} gitShowArg e.g. `:${path}` (index) or `HEAD:${path}` + * @returns {string} + */ +export const gitShowUtf8 = (repoRoot, gitShowArg) => + String( + execFileSync('git', ['-C', repoRoot, 'show', gitShowArg], { + encoding: 'utf8', + maxBuffer: HEADER_READ_BYTES + 64_000, + }), + ) diff --git a/dashboard/scripts/run-precommit-local.mjs b/dashboard/scripts/run-precommit-local.mjs new file mode 100644 index 00000000000..bd97a62f555 --- /dev/null +++ b/dashboard/scripts/run-precommit-local.mjs @@ -0,0 +1,84 @@ +/** + * 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. + */ + +/** + * Run the same checks as .githooks/pre-commit (for manual verification). + * Execute from dashboard/: npm run verify:precommit + * + * Order: test guard → ASF header on new files → lint-staged → tsc --noEmit + */ + +import { execFileSync } from 'node:child_process' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const dashboardDir = join(__dirname, '..') + +const run = (title, fn) => { + console.log(`\x1b[36m▶\x1b[0m ${title}`) + fn() +} + +try { + run('UI ↔ staged test guard', () => { + execFileSync(process.execPath, ['scripts/git-precommit-verify.mjs'], { + cwd: dashboardDir, + stdio: 'inherit', + }) + }) + + run('ASF license on newly added staged files', () => { + execFileSync(process.execPath, ['scripts/check-staged-new-file-license.mjs'], { + cwd: dashboardDir, + stdio: 'inherit', + }) + }) + + const repoRoot = String( + execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', + cwd: dashboardDir, + }), + ).trim() + + run('lint-staged (ESLint on staged dashboard/src)', () => { + const lintStagedCli = join( + dashboardDir, + 'node_modules/lint-staged/bin/lint-staged.js', + ) + execFileSync(process.execPath, [lintStagedCli, '--config', 'dashboard/lint-staged.config.mjs'], { + cwd: repoRoot, + stdio: 'inherit', + }) + }) + + if (process.env.SKIP_DASHBOARD_TYPECHECK !== '1') { + run('TypeScript project check (tsc --noEmit)', () => { + execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'typecheck'], { + cwd: dashboardDir, + stdio: 'inherit', + shell: process.platform === 'win32', + }) + }) + } + + console.log('\x1b[32m[dashboard verify:precommit]\x1b[0m All steps passed.\n') +} catch (e) { + process.exit(e.status ?? 1) +} diff --git a/scripts/git-hooks/check-added-license-generic.mjs b/scripts/git-hooks/check-added-license-generic.mjs new file mode 100644 index 00000000000..aa1f42c6379 --- /dev/null +++ b/scripts/git-hooks/check-added-license-generic.mjs @@ -0,0 +1,82 @@ +/** + * 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. + */ + +/** + * ASF license on newly staged files for a path prefix (dashboardv2, docs). + * Reuses marker rules from dashboard/scripts/lib/license-header-policy.mjs + */ + +import { execFileSync } from 'node:child_process' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + HEADER_READ_BYTES, + contentHasAsfHeader, +} from '../../dashboard/scripts/lib/license-header-policy.mjs' +import { getRepoRoot, getStagedAddedFiles } from './lib/git-helpers.mjs' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const scriptDir = join(__dirname, '..') // git-hooks +const repoRootDefault = getRepoRoot(scriptDir) + +/** + * @param {object} opts + * @param {string} opts.label + * @param {(repoRel: string) => boolean} opts.shouldSkip + * @param {Set} opts.extensions + * @param {string} [opts.repoRoot] + */ +export const verifyAddedFilesAspLicense = (opts) => { + const root = opts.repoRoot ?? repoRootDefault + const added = getStagedAddedFiles(root) + const missing = [] + + for (const repoPath of added) { + const norm = repoPath.replace(/\\/g, '/') + if (opts.shouldSkip(norm)) continue + const ext = norm.slice(norm.lastIndexOf('.')) + if (!opts.extensions.has(ext)) continue + + let content + try { + content = String( + execFileSync('git', ['-C', root, 'show', `:${norm}`], { + encoding: 'utf8', + maxBuffer: HEADER_READ_BYTES + 64_000, + }), + ) + } catch { + continue + } + + const head = content.slice(0, HEADER_READ_BYTES) + if (!contentHasAsfHeader(head)) missing.push(norm) + } + + if (missing.length > 0) { + console.error( + `\x1b[31m[${opts.label} pre-commit]\x1b[0m New file(s) lack the Apache license header:`, + ) + for (const m of missing.sort()) console.error(` - ${m}`) + console.error( + '\nAdd the standard ASF block at the top (match sibling files), or set SKIP_ATLAS_LICENSE_CHECK=1 (emergency only).\n', + ) + process.exit(1) + } +} diff --git a/scripts/git-hooks/lib/extra-license-skip.mjs b/scripts/git-hooks/lib/extra-license-skip.mjs new file mode 100644 index 00000000000..5cf4376fed1 --- /dev/null +++ b/scripts/git-hooks/lib/extra-license-skip.mjs @@ -0,0 +1,64 @@ +/** + * 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. + */ + +/** + * License skip / scan rules for dashboardv2 and docs (dashboard uses dashboard/scripts). + */ + +/** @param {string} repoRel forward slashes */ +export const shouldSkipLicenseDashboardv2 = (repoRel) => { + if (!repoRel.startsWith('dashboardv2/')) return true + const r = repoRel.slice('dashboardv2/'.length) + if ( + r.startsWith('node_modules/') || + r.startsWith('bin/') || + r.includes('/node_modules/') || + r.includes('/external_lib/') + ) { + return true + } + if (r.endsWith('.min.js') || r.endsWith('.map')) return true + if (r.endsWith('package-lock.json') || r === 'package.json') return true + return false +} + +/** @param {string} repoRel */ +export const shouldSkipLicenseDocs = (repoRel) => { + if (!repoRel.startsWith('docs/')) return true + const r = repoRel.slice('docs/'.length) + if ( + r.startsWith('node_modules/') || + r.startsWith('site/') || + r.startsWith('bin/') || + r.startsWith('docz-lib/') || + r.includes('/node_modules/') + ) { + return true + } + if ( + r.endsWith('package-lock.json') || + r === 'package.json' || + r.endsWith('.png') || + r.endsWith('.svg') || + r.endsWith('.woff') || + r.endsWith('.ico') + ) { + return true + } + return false +} diff --git a/scripts/git-hooks/lib/git-helpers.mjs b/scripts/git-hooks/lib/git-helpers.mjs new file mode 100644 index 00000000000..1614c615bad --- /dev/null +++ b/scripts/git-hooks/lib/git-helpers.mjs @@ -0,0 +1,121 @@ +/** + * 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. + */ + +/** + * Git helpers for repo-root hook orchestration. + */ + +import { execFileSync } from 'node:child_process' + +/** + * @param {string} cwd + * @returns {string} + */ +export const getRepoRoot = (cwd) => + String( + execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', + cwd, + }), + ).trim() + +/** + * @param {string} root + * @param {...string} gitArgs + * @returns {string} + */ +export const git = (root, ...gitArgs) => + String( + execFileSync('git', ['-C', root, ...gitArgs], { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + }), + ).trim() + +/** + * @param {string} raw + * @returns {string[]} + */ +export const splitLines = (raw) => + raw + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + +/** + * @param {string} root + * @returns {string[]} + */ +export const getStagedFiles = (root) => { + try { + return splitLines(git(root, 'diff', '--cached', '--name-only', '--diff-filter=ACM')) + } catch { + return [] + } +} + +/** + * @param {string} root + * @returns {string[]} + */ +export const getStagedAddedFiles = (root) => { + try { + return splitLines(git(root, 'diff', '--cached', '--name-only', '--diff-filter=A')) + } catch { + return [] + } +} + +/** + * @param {string} root + * @returns {string[]} + */ +export const getPushRangeFiles = (root) => { + const tryRange = (range) => { + try { + return splitLines(git(root, 'diff', '--name-only', range)) + } catch { + return null + } + } + + let files = tryRange('@{u}..HEAD') + if (files && files.length > 0) return files + + for (const base of ['origin/master', 'origin/main', 'master', 'main']) { + try { + const mergeBase = String( + execFileSync('git', ['-C', root, 'merge-base', 'HEAD', base], { + encoding: 'utf8', + }), + ).trim() + if (mergeBase) { + files = tryRange(`${mergeBase}..HEAD`) + if (files && files.length > 0) return files + } + } catch { + // continue + } + } + + try { + return splitLines(git(root, 'diff', '--name-only', 'HEAD~1..HEAD')) + } catch { + return [] + } +} diff --git a/scripts/git-hooks/run-precommit.mjs b/scripts/git-hooks/run-precommit.mjs new file mode 100644 index 00000000000..68b2c63f8a1 --- /dev/null +++ b/scripts/git-hooks/run-precommit.mjs @@ -0,0 +1,120 @@ +/** + * 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. + */ + +/** + * Repo-wide pre-commit: dashboard (full), dashboardv2 + docs (license, syntax). + */ + +import { execFileSync } from 'node:child_process' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { verifyAddedFilesAspLicense } from './check-added-license-generic.mjs' +import { + shouldSkipLicenseDashboardv2, + shouldSkipLicenseDocs, +} from './lib/extra-license-skip.mjs' +import { getRepoRoot, getStagedFiles } from './lib/git-helpers.mjs' +import { + syntaxCheckDashboardv2Staged, + syntaxCheckDocsStaged, +} from './syntax-check-staged.mjs' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const scriptsDir = join(__dirname, '..') +const repoRoot = getRepoRoot(scriptsDir) + +if ( + process.env.SKIP_ATLAS_HOOKS === '1' || + process.env.SKIP_ALL_ATLAS_GIT_HOOKS === '1' +) { + process.exit(0) +} + +const staged = getStagedFiles(repoRoot) +const touchDashboard = staged.some((p) => p.startsWith('dashboard/')) +const touchV2 = staged.some((p) => p.startsWith('dashboardv2/')) +const touchDocs = staged.some((p) => p.startsWith('docs/')) + +const runDash = (title, file) => { + console.log(`\x1b[36m▶\x1b[0m [dashboard] ${title}`) + execFileSync(process.execPath, [join(repoRoot, 'dashboard', 'scripts', file)], { + cwd: join(repoRoot, 'dashboard'), + stdio: 'inherit', + }) +} + +if (touchDashboard && process.env.SKIP_DASHBOARD_HOOKS !== '1') { + runDash('UI ↔ staged test guard', 'git-precommit-verify.mjs') + runDash('ASF license (new staged files under src/)', 'check-staged-new-file-license.mjs') + + const lintStagedCli = join( + repoRoot, + 'dashboard/node_modules/lint-staged/bin/lint-staged.js', + ) + try { + execFileSync( + process.execPath, + [lintStagedCli, '--config', 'dashboard/lint-staged.config.mjs'], + { + cwd: repoRoot, + stdio: 'inherit', + }, + ) + } catch { + process.exit(1) + } + + if (process.env.SKIP_DASHBOARD_TYPECHECK !== '1') { + execFileSync( + process.platform === 'win32' ? 'npm.cmd' : 'npm', + ['run', 'typecheck'], + { + cwd: join(repoRoot, 'dashboard'), + stdio: 'inherit', + shell: process.platform === 'win32', + }, + ) + } +} + +if (touchV2 && process.env.SKIP_DASHBOARDV2_HOOKS !== '1') { + if (process.env.SKIP_ATLAS_LICENSE_CHECK !== '1') { + verifyAddedFilesAspLicense({ + label: 'dashboardv2', + shouldSkip: (p) => shouldSkipLicenseDashboardv2(p), + extensions: new Set(['.js', '.jsx', '.ts', '.tsx']), + repoRoot, + }) + } + syntaxCheckDashboardv2Staged(repoRoot) +} + +if (touchDocs && process.env.SKIP_DOCS_HOOKS !== '1') { + if (process.env.SKIP_ATLAS_LICENSE_CHECK !== '1') { + verifyAddedFilesAspLicense({ + label: 'docs', + shouldSkip: (p) => shouldSkipLicenseDocs(p), + extensions: new Set(['.js', '.jsx', '.ts', '.tsx']), + repoRoot, + }) + } + syntaxCheckDocsStaged(repoRoot) +} + +process.exit(0) diff --git a/scripts/git-hooks/run-prepush.mjs b/scripts/git-hooks/run-prepush.mjs new file mode 100644 index 00000000000..053b7bd45db --- /dev/null +++ b/scripts/git-hooks/run-prepush.mjs @@ -0,0 +1,88 @@ +/** + * 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. + */ + +/** + * Repo-wide pre-push: dashboard (tests + eslint + build), dashboardv2 build, docs build. + */ + +import { execFileSync } from 'node:child_process' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { getRepoRoot, getPushRangeFiles } from './lib/git-helpers.mjs' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const scriptsDir = join(__dirname, '..') +const repoRoot = getRepoRoot(scriptsDir) + +if ( + process.env.SKIP_ATLAS_HOOKS === '1' || + process.env.SKIP_ALL_ATLAS_GIT_HOOKS === '1' +) { + process.exit(0) +} + +const changed = getPushRangeFiles(repoRoot) +const touchDashboard = changed.some((p) => p.startsWith('dashboard/')) +const touchV2 = changed.some((p) => p.startsWith('dashboardv2/')) +const touchDocs = changed.some((p) => p.startsWith('docs/')) + +if (!touchDashboard && !touchV2 && !touchDocs) { + process.exit(0) +} + +if (touchDashboard && process.env.SKIP_DASHBOARD_HOOKS !== '1') { + console.log('\x1b[35m[atlas pre-push]\x1b[0m dashboard package…') + execFileSync(process.execPath, ['scripts/git-prepush-verify.mjs'], { + cwd: join(repoRoot, 'dashboard'), + stdio: 'inherit', + }) +} + +if (touchV2 && process.env.SKIP_DASHBOARDV2_HOOKS !== '1') { + if (process.env.SKIP_DASHBOARDV2_BUILD === '1') { + console.log( + '\x1b[33m[atlas pre-push]\x1b[0m SKIP_DASHBOARDV2_BUILD=1 — skipping dashboardv2 npm run build.', + ) + } else { + console.log('\x1b[35m[atlas pre-push]\x1b[0m dashboardv2 — npm run build…') + execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build'], { + cwd: join(repoRoot, 'dashboardv2'), + stdio: 'inherit', + shell: process.platform === 'win32', + }) + } +} + +if (touchDocs && process.env.SKIP_DOCS_HOOKS !== '1') { + if (process.env.SKIP_DOCS_BUILD === '1') { + console.log( + '\x1b[33m[atlas pre-push]\x1b[0m SKIP_DOCS_BUILD=1 — skipping docs npm run build.', + ) + } else { + console.log('\x1b[35m[atlas pre-push]\x1b[0m docs — npm run build…') + execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build'], { + cwd: join(repoRoot, 'docs'), + stdio: 'inherit', + shell: process.platform === 'win32', + }) + } +} + +console.log('\x1b[32m[atlas pre-push]\x1b[0m Done.\n') +process.exit(0) diff --git a/scripts/git-hooks/syntax-check-staged.mjs b/scripts/git-hooks/syntax-check-staged.mjs new file mode 100644 index 00000000000..6f117efec70 --- /dev/null +++ b/scripts/git-hooks/syntax-check-staged.mjs @@ -0,0 +1,88 @@ +/** + * 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 --check on staged plain JS (not .jsx) under dashboardv2/public/js. + */ + +import { execFileSync, spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { getStagedFiles } from './lib/git-helpers.mjs' + +/** + * @param {string} repoRel + */ +const isV2CheckableJs = (repoRel) => { + const n = repoRel.replace(/\\/g, '/') + if (!n.startsWith('dashboardv2/public/js/')) return false + if (!n.endsWith('.js')) return false + if (n.includes('/external_lib/')) return false + if (n.endsWith('.min.js')) return false + return true +} + +/** + * @param {string} root + */ +export const syntaxCheckDashboardv2Staged = (root) => { + const staged = getStagedFiles(root) + const files = staged.filter(isV2CheckableJs) + for (const f of files) { + const abs = join(root, f) + const r = spawnSync(process.execPath, ['--check', abs], { + encoding: 'utf8', + }) + if (r.status !== 0) { + console.error( + `\x1b[31m[dashboardv2 pre-commit]\x1b[0m Syntax error in ${f}:\n${r.stderr || r.stdout}`, + ) + process.exit(r.status ?? 1) + } + } +} + +/** Docs: only plain scripts (Node parses scripts/*.js, doczrc.js, webapp config). */ +const isDocsCheckableJs = (repoRel) => { + const n = repoRel.replace(/\\/g, '/') + if (!n.startsWith('docs/')) return false + if (!n.endsWith('.js')) return false + if (n.includes('/node_modules/')) return false + if (n.startsWith('docs/site/') || n.startsWith('docs/bin/')) return false + if (n.startsWith('docs/docz-lib/')) return false + // Avoid JSX-heavy paths (node cannot parse) + if (n.startsWith('docs/theme/') || n.startsWith('docs/webapp/')) return false + return true +} + +/** + * @param {string} root + */ +export const syntaxCheckDocsStaged = (root) => { + const staged = getStagedFiles(root) + const files = staged.filter(isDocsCheckableJs) + for (const f of files) { + const abs = join(root, f) + try { + execFileSync(process.execPath, ['--check', abs], { stdio: 'pipe' }) + } catch (e) { + console.error(`\x1b[31m[docs pre-commit]\x1b[0m Syntax error in ${f}`) + process.exit(1) + } + } +}