diff --git a/mateclaw-ui/package.json b/mateclaw-ui/package.json
index 49fae294..5446c844 100644
--- a/mateclaw-ui/package.json
+++ b/mateclaw-ui/package.json
@@ -6,10 +6,10 @@
"description": "MateClaw - Personal AI Assistant Web Console",
"scripts": {
"dev": "vite",
- "build": "bash scripts/check-snowflake-precision.sh && node --max-old-space-size=6144 ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit && node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build",
+ "build": "node scripts/check-snowflake-precision.mjs && node --max-old-space-size=6144 ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit && node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build",
"preview": "vite preview",
- "lint": "eslint src --fix && bash scripts/check-snowflake-precision.sh",
- "lint:precision": "bash scripts/check-snowflake-precision.sh",
+ "lint": "eslint src --fix && node scripts/check-snowflake-precision.mjs",
+ "lint:precision": "node scripts/check-snowflake-precision.mjs",
"test": "vitest run",
"test:watch": "vitest"
},
diff --git a/mateclaw-ui/scripts/check-snowflake-precision.mjs b/mateclaw-ui/scripts/check-snowflake-precision.mjs
new file mode 100644
index 00000000..5b14a09e
--- /dev/null
+++ b/mateclaw-ui/scripts/check-snowflake-precision.mjs
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+
+import { readdirSync, readFileSync, statSync } from 'node:fs'
+import { dirname, join, relative } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const uiRoot = dirname(dirname(fileURLToPath(import.meta.url)))
+const sourceRoot = join(uiRoot, 'src')
+const allowlist = 'snowflake-precision-ok'
+
+const checks = [
+ ['v-model.number bound to an *Id field', /v-model\.number=".*[Ii]d"?/],
+ ['Number()/parseInt() on an *Id value', /(Number|parseInt)\(\s*\w*[Ii]d\b/],
+ ['Number()/parseInt() on an *Id member-access / lookup', /(Number|parseInt)\([^)]*(?:[a-z]Id\b|[Ii]d['"]|\.[Ii]d\b)/],
+ ['input[type="number"] bound to an *Id field', /]*\btype="number")(?=[^>]*\bv-model(?:\.[^=\s]+)?="[^"]*[Ii]d")/],
+ ["typeof === 'number' silently drops string IDs", /typeof\s+\S*[Ii]d\b\s*===\s*'number'/],
+]
+
+function filesUnder(directory) {
+ const files = []
+ for (const entry of readdirSync(directory).sort()) {
+ const path = join(directory, entry)
+ const stat = statSync(path)
+ if (stat.isDirectory()) files.push(...filesUnder(path))
+ else if (stat.isFile()) files.push(path)
+ }
+ return files
+}
+
+let failed = false
+for (const [label, pattern] of checks) {
+ const hits = []
+ for (const path of filesUnder(sourceRoot)) {
+ const content = readFileSync(path)
+ if (content.includes(0)) continue
+ const lines = content.toString('utf8').split(/\r?\n/)
+ lines.forEach((line, index) => {
+ if (!line.includes(allowlist) && pattern.test(line)) {
+ hits.push(`${relative(uiRoot, path)}:${index + 1}:${line}`)
+ }
+ })
+ }
+ if (hits.length > 0) {
+ failed = true
+ console.error(`\x1b[31m✘ ${label}\x1b[0m`)
+ console.error(hits.join('\n'))
+ console.error()
+ }
+}
+
+if (failed) {
+ console.error('\x1b[31mSnowflake ID precision violations found.\x1b[0m')
+ console.error('\x1b[33mKeep backend-issued Snowflake IDs as strings throughout the UI.\x1b[0m')
+ console.error('\x1b[33mFor a bounded non-ID value, append `// snowflake-precision-ok: ` on the same line.\x1b[0m')
+ process.exit(1)
+}
+
+console.log('\x1b[32m✓ Snowflake ID precision check: clean\x1b[0m')
diff --git a/mateclaw-ui/scripts/check-snowflake-precision.sh b/mateclaw-ui/scripts/check-snowflake-precision.sh
index c2032ad4..e4aa4a0e 100755
--- a/mateclaw-ui/scripts/check-snowflake-precision.sh
+++ b/mateclaw-ui/scripts/check-snowflake-precision.sh
@@ -1,111 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
-# Snowflake ID precision checker for mateclaw-ui.
-#
-# Scans the Vue / TypeScript sources for patterns that round-trip a
-# backend-issued Snowflake ID through JS Number, silently truncating the
-# last digits whenever the value exceeds Number.MAX_SAFE_INTEGER (2^53-1).
-#
-# Backend-issued Snowflake IDs must remain strings throughout the UI because
-# JavaScript Number cannot exactly represent every 64-bit integer.
-#
-# Run locally from mateclaw-ui:
-# bash scripts/check-snowflake-precision.sh
-#
-# Run in CI: invoked automatically from this package's `lint` and `build`
-# scripts. Keeping the checker inside mateclaw-ui also makes those commands
-# self-contained in the public repository, where private root scripts are not
-# published.
-#
-# Allowlisting a real exception:
-# Append a trailing comment `// snowflake-precision-ok: `
-# on the SAME line as the match. Only do this when the value is provably
-# a small bounded integer (e.g. a per-stream sequence counter, a port
-# number, a retry budget) — never as a shortcut to silence a real bug.
-#
-# Exit codes:
-# 0 — all checks clean
-# 1 — one or more violations found
-
-UI_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-UI_SRC="${UI_ROOT}/src"
-
-red() { printf '\033[31m%s\033[0m\n' "$*"; }
-green() { printf '\033[32m%s\033[0m\n' "$*"; }
-yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
-
-if [[ ! -d "$UI_SRC" ]]; then
- red "Missing UI src directory: $UI_SRC"
- exit 1
-fi
-
-if ! command -v rg >/dev/null 2>&1; then
- red "ripgrep (rg) is required. Install with: brew install ripgrep"
- exit 1
-fi
-
-exit_code=0
-
-# Run one grep check. Hits carrying the allowlist annotation are skipped.
-#
-# Args:
-# $1 — human-readable label printed when violations are found
-# $2 — rg flags ("-n" for fixed-string-ish, "-nP" for PCRE)
-# $3 — pattern
-run_check() {
- local label="$1" flags="$2" pattern="$3"
- local hits
- # `|| true` so a "no match" exit-1 from rg doesn't trip `set -e`.
- hits=$(rg $flags "$pattern" "$UI_SRC" 2>/dev/null | grep -vF 'snowflake-precision-ok' || true)
- if [[ -n "$hits" ]]; then
- red "✘ $label"
- echo "$hits"
- echo
- exit_code=1
- fi
-}
-
-# (1) v-model.number on a field whose name ends in Id — Vue's .number
-# modifier runs looseToNumber on the bound value before write-back.
-run_check 'v-model.number bound to an *Id field' \
- '-n' 'v-model\.number=".*[Ii]d"?'
-
-# (2) Number() / parseInt() / +id explicit coercion of an *Id-named value.
-run_check 'Number()/parseInt() on an *Id value' \
- '-nP' '(Number|parseInt)\(\s*\w*[Ii]d\b'
-
-# (2b) Number()/parseInt() wrapping a member-access or call expression that
-# ends in an id — e.g. Number(localStorage.getItem('mc-workspace-id')),
-# Number(route.params.agentId), Number(store.currentKB.id). Check (2) only
-# catches a bare *Id identifier; these compound forms slip past it but
-# truncate just the same. The `\.[Ii]d\b` alternative covers a plain `.id`
-# property access (e.g. `.currentKB.id)`) that the camelCase `[a-z]Id`
-# and quoted `[Ii]d['"]` alternatives miss.
-run_check 'Number()/parseInt() on an *Id member-access / lookup' \
- '-nP' '(Number|parseInt)\([^)]*(?:[a-z]Id\b|[Ii]d['"'"'"]|\.[Ii]d\b)'
-
-# (3) v-modeled to an *Id field. Vue's vModelText
-# runtime auto-applies looseToNumber whenever el.type === 'number',
-# even WITHOUT the `.number` modifier, so the input type itself is
-# the bug source.
-run_check 'input[type="number"] bound to an *Id field' \
- '-nP' 'type="number"[^>]*v-model[^"]*[Ii]d"'
-
-# (4) `typeof xxxId === 'number'` presence checks. These silently drop the
-# value when the backend hands us the string form (ToStringSerializer
-# output), masking the real problem as "field unset".
-run_check "typeof === 'number' silently drops string IDs" \
- '-nP' "typeof\s+\S*[Ii]d\b\s*===\s*'number'"
-
-if [[ $exit_code -eq 0 ]]; then
- green '✓ Snowflake ID precision check: clean'
-else
- echo
- red 'Snowflake ID precision violations found.'
- yellow 'Keep backend-issued Snowflake IDs as strings throughout the UI.'
- yellow 'If a hit is a confirmed small-integer exception, append'
- yellow '`// snowflake-precision-ok: ` on the same line.'
-fi
-
-exit $exit_code
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+exec node "${SCRIPT_DIR}/check-snowflake-precision.mjs" "$@"