This is an automated email from the ASF dual-hosted git repository. pawarprasad123 pushed a commit to branch ATLAS-5293 in repository https://gitbox.apache.org/repos/asf/atlas.git
commit b02b1f082038e4273eedd1d8092d1d4a9e77b35a Author: Prasad Pawar <[email protected]> AuthorDate: Thu May 14 13:38:43 2026 +0530 ATLAS-5293: ATLAS UI: Add repo-wide Git hooks and dashboard pre-commit/push verification --- .githooks/pre-commit | 7 ++ .githooks/pre-push | 7 ++ dashboard/.githooks/pre-commit | 4 + dashboard/.githooks/pre-push | 3 + dashboard/.husky/pre-commit | 4 + dashboard/docs/GIT_HOOKS.md | 127 ++++++++++++++++++++ dashboard/lint-staged.config.mjs | 14 +++ .../scripts/check-staged-new-file-license.mjs | 106 +++++++++++++++++ dashboard/scripts/git-precommit-verify.mjs | 33 ++++++ dashboard/scripts/git-prepush-verify.mjs | 105 +++++++++++++++++ dashboard/scripts/install-git-hooks.mjs | 41 +++++++ dashboard/scripts/lib/git-changed-files.mjs | 71 +++++++++++ dashboard/scripts/lib/license-header-policy.mjs | 38 ++++++ dashboard/scripts/lib/test-path-helpers.mjs | 131 +++++++++++++++++++++ dashboard/scripts/run-precommit-local.mjs | 67 +++++++++++ scripts/git-hooks/check-added-license-generic.mjs | 65 ++++++++++ scripts/git-hooks/lib/extra-license-skip.mjs | 46 ++++++++ scripts/git-hooks/lib/git-helpers.mjs | 104 ++++++++++++++++ scripts/git-hooks/run-precommit.mjs | 104 ++++++++++++++++ scripts/git-hooks/run-prepush.mjs | 72 +++++++++++ scripts/git-hooks/syntax-check-staged.mjs | 71 +++++++++++ 21 files changed, 1220 insertions(+) diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..b0db14366 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# 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 000000000..0048cf4e6 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# 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 000000000..c715084e4 --- /dev/null +++ b/dashboard/.githooks/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +# 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 000000000..536610d2c --- /dev/null +++ b/dashboard/.githooks/pre-push @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +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 000000000..95fe05b17 --- /dev/null +++ b/dashboard/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/sh +# 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 000000000..b5e204e6d --- /dev/null +++ b/dashboard/docs/GIT_HOOKS.md @@ -0,0 +1,127 @@ +# 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) **ASF license** on **new** files under `dashboard/src/`; (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** | 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 ... # ASF on new files under dashboard/src +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 000000000..70527d770 --- /dev/null +++ b/dashboard/lint-staged.config.mjs @@ -0,0 +1,14 @@ +/** + * 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/scripts/check-staged-new-file-license.mjs b/dashboard/scripts/check-staged-new-file-license.mjs new file mode 100644 index 000000000..b3dff7048 --- /dev/null +++ b/dashboard/scripts/check-staged-new-file-license.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** + * Pre-commit: newly added (staged) source files under dashboard/src must carry + * the standard ASF header (same policy as apache-license-header.test.ts). + * + * 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 { + HEADER_READ_BYTES, + contentHasAsfHeader, + isLicenseCheckSkippedForSrcRel, +} from './lib/license-header-policy.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 SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx']) +const missing = [] + +for (const repoPath of added) { + 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 = String( + execFileSync('git', ['-C', repoRoot, '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[dashboard 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 (see nearby files), 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 000000000..6cdf94386 --- /dev/null +++ b/dashboard/scripts/git-precommit-verify.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/** + * 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 000000000..4880913c4 --- /dev/null +++ b/dashboard/scripts/git-prepush-verify.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * Pre-push: impact-related Jest tests, ESLint (src), production build. + * Skip: SKIP_DASHBOARD_HOOKS=1 or HUSKY=0 + */ + +import { 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) +} + +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 000000000..6330c9bd8 --- /dev/null +++ b/dashboard/scripts/install-git-hooks.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +/** + * 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 000000000..0a32089de --- /dev/null +++ b/dashboard/scripts/lib/git-changed-files.mjs @@ -0,0 +1,71 @@ +/** + * Resolve paths changed in git (staged, committed range, or vs base branch). + * SPDX: Apache-2.0 (match dashboard) + */ + +import { 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/main', 'origin/master', '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 [] + } +} diff --git a/dashboard/scripts/lib/license-header-policy.mjs b/dashboard/scripts/lib/license-header-policy.mjs new file mode 100644 index 000000000..b55332259 --- /dev/null +++ b/dashboard/scripts/lib/license-header-policy.mjs @@ -0,0 +1,38 @@ +/** + * ASF license header policy aligned with src/__tests__/apache-license-header.test.ts + * (paths here are relative to dashboard/src/). + */ + +export const LICENSE_MARKERS = [ + 'Licensed to the Apache Software Foundation', + 'Apache License, Version 2.0', +] + +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 +} + +/** + * @param {string} head first bytes of file as string + * @returns {boolean} + */ +export const contentHasAsfHeader = (head) => + LICENSE_MARKERS.every((marker) => head.includes(marker)) diff --git a/dashboard/scripts/lib/test-path-helpers.mjs b/dashboard/scripts/lib/test-path-helpers.mjs new file mode 100644 index 000000000..7629b282a --- /dev/null +++ b/dashboard/scripts/lib/test-path-helpers.mjs @@ -0,0 +1,131 @@ +/** + * 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/run-precommit-local.mjs b/dashboard/scripts/run-precommit-local.mjs new file mode 100644 index 000000000..0aeadf95a --- /dev/null +++ b/dashboard/scripts/run-precommit-local.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * 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 000000000..bb0ead3e4 --- /dev/null +++ b/scripts/git-hooks/check-added-license-generic.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * 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<string>} 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 000000000..c5af3ab7b --- /dev/null +++ b/scripts/git-hooks/lib/extra-license-skip.mjs @@ -0,0 +1,46 @@ +/** + * 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 000000000..4b3730f29 --- /dev/null +++ b/scripts/git-hooks/lib/git-helpers.mjs @@ -0,0 +1,104 @@ +/** + * Git helpers for repo-root hook orchestration. + * SPDX-License-Identifier: Apache-2.0 + */ + +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 000000000..b6835b1dc --- /dev/null +++ b/scripts/git-hooks/run-precommit.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * Repo-wide pre-commit: dashboard (full), dashboardv2 + docs (license, syntax). + * SPDX-License-Identifier: Apache-2.0 + */ + +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 000000000..6be42d850 --- /dev/null +++ b/scripts/git-hooks/run-prepush.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * Repo-wide pre-push: dashboard (tests + eslint + build), dashboardv2 build, docs build. + * SPDX-License-Identifier: Apache-2.0 + */ + +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 000000000..7dce2837e --- /dev/null +++ b/scripts/git-hooks/syntax-check-staged.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * 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) + } + } +}
