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)
+               }
+       }
+}


Reply via email to