Files
shopdb-flask/scripts/check-naming-and-style.sh
benjamin.baker 0288587522 Check the installed packages against the lockfiles
A clean lockfile does not mean a clean workstation. pip-audit reported
vulnerabilities here while requirements.txt was already pinned to fixed
versions - the installed environment had drifted from the pins, and nothing
compared the two. The same drift had left requirements-dev.txt uninstalled, so
pytest.ini's -n auto failed for want of pytest-xdist.

Add scripts/check-dependency-drift.py, comparing installed distributions
against both lockfiles. MISSING is reported separately from MISMATCHED: a
package that was never installed and a package at the wrong version need
different fixes, and folding them together hid the missing dev requirements.

Wire it into the style gate advisorially, with DEPENDENCY_DRIFT_ENFORCE=1 for
CI. A developer mid-upgrade should not be blocked by a gate that is telling
them something they already know.

The gate now probes python, python3 and py by executing them rather than by
looking them up on PATH. On Windows, python3 resolves to the Microsoft Store
alias stub, which is present on PATH and does nothing when run.

CONTRIBUTING.md gains the Windows-specific traps this exposed: npm.cmd rather
than npm under a Restricted execution policy, the explicit Git Bash path
because bare bash finds the WSL shim, and npm test rather than npx vitest run
because only the pretest hook stages plugin frontends.
2026-09-20 13:22:55 -04:00

682 lines
34 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Pre-commit naming + style check for shopdb-flask.
# Enforces CONTRIBUTING.md rules:
# 1. No non-ASCII chars in source (em-dashes, smart quotes, arrows, emojis)
# 2. No banned shorthand identifiers (cfg, ctx, mgr, req, res, env, util, helper)
# as standalone names (suffix usage like printers_bp, request_obj is allowed)
# 3. No snake_case DB column names in __tablename__ or db.Column attrs
# 4. No snake_case API params in frontend that should match DB column names
#
# Exits non-zero if any violation found.
# Skips: venv/, node_modules/, __pycache__/, frontend/dist/, migrations/versions/,
# deploy/windows/installer/bundle/ (installer build output)
set -e
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
cd "$REPO_ROOT"
VIOLATIONS=0
# Use git ls-files for 5x+ speedup - only scans tracked files, respects .gitignore automatically
# Exclude generated/vendor directories that might be tracked but shouldn't be scanned
USE_GIT_LS_FILES=${USE_GIT_LS_FILES:-1}
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
# Filter out paths we don't want to scan even if tracked
# NUL-delimited end to end (git ls-files -z | grep -z | xargs -0) so a path
# with a space or newline is scanned as one file, not split into several.
GIT_FILES_FILTER="grep -zv '^migrations/versions/' | grep -zv '^frontend/dist/' | grep -zv '^staticdocs/' | grep -zv '\.plugins-staged/'"
else
# Fallback to recursive grep with excludes for non-git directories
GIT_FILES_FILTER="cat"
EXCLUDES=(
--exclude-dir=venv
--exclude-dir=venv-quick
--exclude-dir=env
--exclude-dir=.venv
--exclude-dir=node_modules
--exclude-dir=__pycache__
--exclude-dir=dist
# The installer's build output: a staged copy of the whole application plus
# a second SPA build under dist-subpath, which --exclude-dir=dist does not
# match. Linting it means linting vendored minified JS and failing on
# characters nobody in this repository wrote.
--exclude-dir=bundle
--exclude-dir=dist-subpath
--exclude-dir=.git
# Visual Studio's local state, gitignored. CopilotSnapshots keeps a full
# per-edit copy of touched source files, so every real finding is reported
# again once per snapshot - hundreds of duplicates that bury the actual
# hits and make the scan take minutes instead of seconds.
--exclude-dir=.vs
--exclude-dir=versions
--exclude-dir=staticdocs
--exclude-dir=_transfer
--exclude-dir=instance
# Local, gitignored scratch notes (see .gitignore devnotes/). Not product
# source, never committed, and free to hold pasted logs full of em-dashes
# and arrows - grep does not consult .gitignore, so exclude it explicitly.
--exclude-dir=devnotes
# .NET build output. The agent projects emit vendored runtime DLLs and
# generated *.nuget.g.props/.targets under bin/ and obj/; both are full of
# bytes nobody here wrote. Same failure as the .vs case above: thousands of
# hits that bury every real finding and make the gate unreadable, so it
# stops being run at all.
--exclude-dir=bin
--exclude-dir=obj
--exclude-dir=publish
--exclude-dir=dev-bin
# Data, not source: exported CSVs carry vendor mojibake and backups are
# snapshots of a moment. Neither is something a convention applies to.
--exclude-dir=exports
--exclude-dir=backups
)
fi
# DO NOT ADD --exclude=<file> TO THE ARRAY ABOVE.
#
# In this grep build a single file-level --exclude silently CANCELS EVERY
# --include filter that comes AFTER it on the same command. Bisected: with only
# --exclude-dir flags a shorthand scan restricted to --include='*.py' returns 0
# markdown hits; add any one --exclude ahead of the includes and the same scan
# returns 1142. Re-proved on GNU grep 3.11 with six files of one literal:
# --include='*.py' --include='*.cs' -> 2 hits, as written
# --exclude=README.md --include='*.py' --include='*.cs' -> all 6 hits
# --include='*.py' --include='*.cs' --exclude=README.md -> 2 hits
# Directory excludes are unaffected in any position - it is specifically the
# file form. DO NOT rely on writing the exclude last: the position rule is not
# documented behaviour, the array above is expanded ahead of every include list
# in this file, and a later edit that reorders the flags would silently widen
# the scan again with nothing failing. Post-filter instead.
#
# This is not theoretical, it was live: --exclude=findings.sarif and
# --exclude=report.md sat in this array and had been quietly disabling the
# include lists for every check that used them. That is why the shorthand check
# reported .md, .ps1, .cs, .json and .gitignore hits when its flags say it only
# ever looks at .py/.vue/.js/.ts, and why the gate accumulated thousands of
# unactionable findings that made it unreadable enough to be ignored.
#
# To skip a FILE, filter it out of the pipeline afterwards (see drop_scratch and
# the post-filters on the ADR-015 check) or exclude the directory holding it.
# Binaries are handled by grep -I on the scan itself.
# Root-level gitignored scratch: review writeups and chat handoffs, free to hold
# pasted logs full of em-dashes and arrows. grep does not consult .gitignore, so
# it has to be dropped here.
#
# A POST-FILTER, not --exclude-dir=devnotes. That flag matches a directory of
# that NAME at any depth, so a force-added plugins/<plugin>/devnotes/ would be
# invisible to every check in this file, ADR-015 included - a hole in the gate
# that nothing would report. This drops only the path we mean.
drop_scratch() { grep -v '^\./devnotes/' || true; }
# Generated scan output, replacing the --exclude=findings.sarif and
# --exclude=report.md that used to live in EXCLUDES. Same intent, but as a
# post-filter, because the flag form cancelled every --include on the command
# (see the warning above the EXCLUDES array). Also drops .env, which is local
# configuration rather than source.
#
# scan.log arrived from wj as --exclude=scan.log. Carried here rather than to
# the array for the same reason: the flag form would have cancelled the include
# lists it sat beside.
drop_generated() {
# Match with or without leading ./ for git ls-files compatibility
# [^:]* rather than .*: on a long minified hit line the greedy form blows
# PCRE's backtracking limit, and grep then errors instead of filtering.
grep -vP '^(\./)?([^:]*/)?(findings\.sarif|report\.md|scan\.log|\.env|[^:/]*\.standalone\.js|[^:/]*\.min\.js):' || true
}
# CONTRIBUTING.md documents the conventions by SHOWING violations as examples,
# so the naming checks hit it every time. Came from wj as
# --exclude=CONTRIBUTING.md; it is a post-filter here because the flag form
# cancels every --include on the same command (see the EXCLUDES warning above).
drop_contributing() { grep -v '^\./CONTRIBUTING\.md:' || true; }
INCLUDES_CODE=(
--include='*.py'
--include='*.vue'
--include='*.js'
--include='*.ts'
)
INCLUDES_ALL=(
--include='*.py'
--include='*.vue'
--include='*.js'
--include='*.ts'
--include='*.json'
--include='*.md'
--include='*.yaml'
--include='*.yml'
)
# INCLUDES_ALL, not INCLUDES_CODE: the check only ever covered .py/.vue/.js/.ts,
# so documentation was free to accumulate em-dashes, arrows and smart quotes -
# and did, including in files this repo's own convention forbids them in.
# Markdown, JSON and YAML are now covered too.
echo "==> Checking for non-ASCII characters..."
# -I skips binary files. Without it grep reports "Binary file X matches" for any
# archive or compiled artifact that slips past the excludes, which is noise no
# author can act on: there is no line to fix. The excludes above are the primary
# defence; this is the backstop for whatever they miss.
#
# wj wrote this as --binary-files=without-match, which is the same behaviour
# spelled long-hand; -I is kept for consistency with the rest of the file.
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
NON_ASCII=$(git ls-files -z \
'*.py' '*.vue' '*.js' '*.ts' '*.json' '*.md' '*.yaml' '*.yml' \
| eval "$GIT_FILES_FILTER" \
| xargs -0 -r grep -PnI '[^\x00-\x7F]' 2>/dev/null \
| drop_scratch | drop_generated || true)
else
NON_ASCII=$(grep -rPnI '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_ALL[@]}" . 2>/dev/null | drop_scratch | drop_generated || true)
fi
if [ -n "$NON_ASCII" ]; then
echo "FAIL: non-ASCII characters found (em-dashes, smart quotes, arrows, emojis):"
echo "$NON_ASCII"
echo
VIOLATIONS=$((VIOLATIONS + 1))
fi
echo "==> Checking for banned shorthand (standalone)..."
# Match the word as a standalone identifier: not preceded or followed by underscore/word char
# Word boundary in grep is \b but we want to exclude suffix usage like printers_bp
# So: match (^|[^a-zA-Z0-9_])(banned)([^a-zA-Z0-9_]|$)
# Exclude: docs (CONTRIBUTING.md examples), external code (agent/, installer/), this script
# Only check PRODUCT CODE in shopdb/ plugins/ frontend/src/ - not docs, examples, or tools
# Exclude C# client code (.cs files) - they're not Python and have different conventions
for word in cfg ctx mgr req res; do
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
# :(glob) is required: without it git matches pathspecs with fnmatch,
# where 'shopdb/**/*.py' needs a second slash and silently skips
# top-level files such as shopdb/__init__.py and frontend/src/main.js.
HITS=$(git ls-files -z ':(glob)shopdb/**/*.py' ':(glob)shopdb/**/*.vue' ':(glob)shopdb/**/*.js' ':(glob)shopdb/**/*.ts' \
':(glob)plugins/**/*.py' ':(glob)plugins/**/*.vue' ':(glob)plugins/**/*.js' ':(glob)plugins/**/*.ts' \
':(glob)frontend/src/**/*.py' ':(glob)frontend/src/**/*.vue' ':(glob)frontend/src/**/*.js' ':(glob)frontend/src/**/*.ts' \
| grep -zv '/tests\?/' | grep -zv '\.spec\.js' | grep -zv '\.plugins-staged' \
| xargs -0 -r grep -Pn "(^|[^a-zA-Z0-9_])${word}([^a-zA-Z0-9_]|\$)" 2>/dev/null \
| grep -v '\.cs:' \
| grep -vP "(^|[^a-zA-Z0-9_])(request_obj|response_obj)" \
| drop_scratch | drop_generated || true)
else
HITS=$(grep -rPn --binary-files=without-match \
"(^|[^a-zA-Z0-9_])${word}([^a-zA-Z0-9_]|\$)" "${EXCLUDES[@]}" \
--include='*.py' --include='*.vue' --include='*.js' --include='*.ts' \
shopdb/ plugins/ frontend/src/ 2>/dev/null \
| grep -v '/tests\?/' \
| grep -v '\.spec\.js' \
| grep -v '\.plugins-staged' \
| grep -v '\.cs:' \
| grep -vP "(^|[^a-zA-Z0-9_])(request_obj|response_obj)" \
| drop_scratch \
| drop_generated \
|| true)
fi
if [ -n "$HITS" ]; then
echo "FAIL: banned shorthand '$word' (standalone) found:"
echo "$HITS"
echo
VIOLATIONS=$((VIOLATIONS + 1))
fi
done
echo "==> Checking for snake_case DB tablenames..."
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
SNAKE_TABLES=$(git ls-files -z '*.py' | eval "$GIT_FILES_FILTER" \
| xargs -0 -r grep -Pn "__tablename__\s*=\s*['\"][^'\"]*_" 2>/dev/null \
| drop_scratch | drop_contributing || true)
else
SNAKE_TABLES=$(grep -rPn "__tablename__\s*=\s*['\"][^'\"]*_" "${EXCLUDES[@]}" \
--include='*.py' . 2>/dev/null | drop_scratch | drop_contributing || true)
fi
if [ -n "$SNAKE_TABLES" ]; then
echo "FAIL: snake_case __tablename__ found (must be lowercase concatenated):"
echo "$SNAKE_TABLES"
echo
VIOLATIONS=$((VIOLATIONS + 1))
fi
echo "==> Checking for snake_case DB column attrs..."
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
SNAKE_COLS=$(git ls-files -z '*.py' | eval "$GIT_FILES_FILTER" \
| xargs -0 -r grep -Pn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" 2>/dev/null \
| drop_scratch | drop_contributing || true)
else
SNAKE_COLS=$(grep -rPn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" "${EXCLUDES[@]}" \
--include='*.py' . 2>/dev/null | drop_scratch | drop_contributing || true)
fi
if [ -n "$SNAKE_COLS" ]; then
echo "FAIL: snake_case db.Column attribute found (must match column name, no underscores):"
echo "$SNAKE_COLS"
echo
VIOLATIONS=$((VIOLATIONS + 1))
fi
# The check above reads MODEL attributes, and a model attribute is not where a
# column is born - a migration is. migrations/versions/ is filtered out of
# GIT_FILES_FILTER and out of EXCLUDES for every other check in this file, so
# from the day this gate was written not one created column name was ever
# looked at. A model whose attribute reads fine still ships whatever the
# migration wrote, because the migration is what the database got.
#
# CREATION SITES ONLY: sa.Column(<name>) and an alter_column rename. drop_column
# and an ordinary alter_column name a column that already exists, so a bad name
# there was already wrong where it was created and flagging it twice only makes
# the same rename look like two findings.
#
# awk, not grep, because sa.Column( is routinely followed by a newline before
# the name (op.add_column('applications', sa.Column(\n 'isrequired', ...)) and a
# line-at-a-time grep sees neither half: the first line has no name and the
# second has no sa.Column. One line of lookahead joins them - that shape is 9
# of the ~1180 call sites here, so dropping it is not acceptable.
#
# KNOWN LIMIT, shared with every text scan in this file: a name held in a module
# constant or walked out of a list (_SNAPSHOT_COLUMNS and the COLUMN_NAME
# constants in the endpoints and notifications chains) is not a literal and is
# not seen. Those are all clean today; a text scan cannot promise they stay so.
echo "==> Checking for snake_case DB column names in migrations..."
# NUL-delimited end to end, and the list is PIPED rather than held in a
# variable: command substitution drops null bytes, which would silently rejoin
# a path containing a space into two half-paths.
SNAKE_MIG_COLS=$(
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
git ls-files -z -- '*migrations/versions/*.py' 2>/dev/null
else
find . -path '*/migrations/versions/*.py' -not -path '*/venv/*' \
-not -path '*/node_modules/*' -not -path '*/.plugins-staged/*' -print0 2>/dev/null
fi | xargs -0 -r awk -v Q="'" '
# THE RULE IS LOWERCASE CONCATENATED, so the test is on the NAME, not on
# the shape of the name. The first version of this matched
# [a-z0-9]*_[a-z0-9_]* inside the quotes, which asks "does a lowercase
# name contain an underscore" - so sa.Column("Monitor_Name", ...) passed
# because of the leading capital, and so did every CamelCase name with no
# underscore at all. Extract whatever literal the call names and test it
# against ^[a-z][a-z0-9]*$ instead: anything else is a finding.
#
# SHAPES a migration writes a column name in, all of which land here:
# sa.Column("x", ...) positional
# sa.Column(name="x", type_=sa.String()) keyword, first or later
# op.add_column("t", sa.Column("x", ...)) the sa.Column( inside it
# with op.batch_alter_table(...) as batch_op:
# batch_op.add_column(sa.Column("x", ...)) likewise
# op.alter_column("t", "old", new_column_name="x") the rename target
# drop_column and a plain alter_column name a column that already exists,
# so they stay out: a bad name there was already wrong where it was
# created, and reporting it twice makes one rename look like two.
function nameat(s, re, quote, rest, stop) {
# the literal that follows re, where re ENDS on the opening quote
if (!match(s, re)) return ""
quote = substr(s, RSTART + RLENGTH - 1, 1)
rest = substr(s, RSTART + RLENGTH)
stop = index(rest, quote)
if (stop == 0) return ""
return substr(rest, 1, stop - 1)
}
BEGIN {
QQ = Q "\""
callre = "sa\\.Column\\("
posre = "^[ \t]*[" QQ "]"
kwre = "(^|[^A-Za-z0-9_])name[ \t]*=[ \t]*[" QQ "]"
renre = "new_column_name[ \t]*=[ \t]*[" QQ "]"
contre = "sa\\.Column\\([ \t]*$"
okre = "^[a-z][a-z0-9]*$"
}
FNR == 1 { held = ""; heldline = 0 }
{
line = $0; lineno = FNR
if (held != "") { line = held line; lineno = heldline; held = "" }
if (line ~ contre) { held = line; heldline = lineno; next }
# walk every sa.Column( on the line: op.create_table() writes several
# on one line and each names a column of its own.
rest = line
while (match(rest, callre)) {
rest = substr(rest, RSTART + RLENGTH)
# bound this call at the next sa.Column( so a later call cannot
# lend this one its name= argument
if (match(rest, callre)) chunk = substr(rest, 1, RSTART - 1)
else chunk = rest
colname = nameat(chunk, posre)
if (colname == "") colname = nameat(chunk, kwre)
if (colname != "" && colname !~ okre)
printf "%s:%d: %s\n", FILENAME, lineno, colname
}
colname = nameat(line, renre)
if (colname != "" && colname !~ okre)
printf "%s:%d: %s\n", FILENAME, lineno, colname
}' || true)
if [ -n "$SNAKE_MIG_COLS" ]; then
echo "FAIL: column name created in a migration is not lowercase concatenated"
echo " (see CONTRIBUTING.md):"
echo "$SNAKE_MIG_COLS"
echo
VIOLATIONS=$((VIOLATIONS + 1))
fi
echo "==> Checking for snake_case ForeignKey targets..."
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
SNAKE_FK=$(git ls-files -z '*.py' | eval "$GIT_FILES_FILTER" \
| xargs -0 -r grep -Pn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" 2>/dev/null \
| drop_scratch || true)
else
SNAKE_FK=$(grep -rPn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null | drop_scratch || true)
fi
if [ -n "$SNAKE_FK" ]; then
echo "FAIL: snake_case ForeignKey target found:"
echo "$SNAKE_FK"
echo
VIOLATIONS=$((VIOLATIONS + 1))
fi
echo "==> Checking for snake_case API params in frontend (DB-mirrored fields)..."
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
SNAKE_FE=$(git ls-files -z '*.vue' '*.js' '*.ts' | eval "$GIT_FILES_FILTER" \
| grep -zv 'DEVELOPMENT-STANDARDS.md' \
| xargs -0 -r grep -Pn "params\.(machine_id|location_id|vendor_id|type_id|business_unit_id|model_id|status_id|operating_system_id|asset_id|user_id|is_active|is_shopfloor)" 2>/dev/null \
| drop_scratch || true)
else
SNAKE_FE=$(grep -rPn "params\.(machine_id|location_id|vendor_id|type_id|business_unit_id|model_id|status_id|operating_system_id|asset_id|user_id|is_active|is_shopfloor)" "${EXCLUDES[@]}" \
--exclude=DEVELOPMENT-STANDARDS.md --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null | drop_scratch || true)
fi
if [ -n "$SNAKE_FE" ]; then
echo "FAIL: snake_case API params in frontend (must match DB column names without underscores):"
echo "$SNAKE_FE"
echo
VIOLATIONS=$((VIOLATIONS + 1))
fi
# ADR-013 Phase 4: a plugin frontend (plugins/<name>/frontend/) may import core
# via the @/ alias or its own tree (./, ../ within the plugin), never another
# plugin's tree and never an escaping ../../ into src. Keeps plugin frontends
# self-contained so a per-site build can drop one cleanly.
echo "==> Checking for cross-plugin / escaping imports in plugin frontends..."
if [ -d plugins ]; then
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
PLUGIN_FE_IMPORTS=$(git ls-files -z ':(glob)plugins/*/frontend/**/*.vue' ':(glob)plugins/*/frontend/**/*.js' \
| xargs -0 -r grep -Pn "(import|from)\s+['\"]([^'\"]*\.\./\.\./|[^'\"]*/plugins/)" 2>/dev/null || true)
else
PLUGIN_FE_IMPORTS=$(grep -rPn "(import|from)\s+['\"]([^'\"]*\.\./\.\./|[^'\"]*/plugins/)" \
--include='*.vue' --include='*.js' plugins/*/frontend/ 2>/dev/null || true)
fi
if [ -n "$PLUGIN_FE_IMPORTS" ]; then
echo "FAIL: plugin frontend imports must use the @/ alias for core, not"
echo " an escaping ../../ or another plugin's path:"
echo "$PLUGIN_FE_IMPORTS"
echo
VIOLATIONS=$((VIOLATIONS + 1))
fi
fi
# ADR-015: one site's data does not belong in product code. A site host, a site
# FQDN or a site network in plugins/ or shopdb/ ships another site a value it
# cannot see and did not choose. Use a setting with a NEUTRAL default, a
# site-namespaced directory (scripts/site_imports/<site>/), or seed data.
#
# Code generation lives in one module. Seven views were importing qrcode and
# jsbarcode directly, each with its own margin, width and error correction, and
# the answers had already drifted - which on a label means one page scans and
# another does not. frontend/src/utils/codes.js owns that knowledge now; a view
# passes what is specific to its label and nothing else.
echo "==> Checking that only the shared module imports the code libraries..."
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
CODE_LIB_IMPORTS=$(git ls-files -z ':(glob)frontend/src/**/*.vue' ':(glob)frontend/src/**/*.js' ':(glob)plugins/**/*.vue' ':(glob)plugins/**/*.js' \
| grep -zv '\.plugins-staged/' \
| xargs -0 -r grep -n "from ['\"]\(qrcode\|jsbarcode\)['\"]" 2>/dev/null \
| grep -v 'frontend/src/utils/codes\.js' || true)
else
CODE_LIB_IMPORTS=$(grep -rn "from ['\"]\(qrcode\|jsbarcode\)['\"]" \
--include='*.vue' --include='*.js' \
frontend/src plugins/ 2>/dev/null \
| grep -v 'frontend/src/utils/codes\.js' \
| grep -v '\.plugins-staged/' || true)
fi
if [ -n "$CODE_LIB_IMPORTS" ]; then
echo "FAIL: import qrcode/jsbarcode through @/utils/codes, not directly:"
echo "$CODE_LIB_IMPORTS"
echo
VIOLATIONS=$((VIOLATIONS + 1))
fi
# ADR-017: a marker position is pixels in ONE LEVEL's coordinate space, so a
# payload that emits mapx/mapy without levelid gives the consumer coordinates and
# no drawing to put them on. The consumer then either renders nothing or - worse,
# and what a reasonable implementation does - falls back to the default level,
# drawing one building's ground floor behind a marker positioned for another
# building's mezzanine. That renders perfectly and points at the wrong place.
#
# Eight files emit positions across twenty sites. This is the check that says
# whether all of them travel with their level, because reading them by eye is
# how the twentieth gets missed.
echo "==> Checking that emitted map positions carry their level (ADR-017)..."
# PER OCCURRENCE, not per file. The file-level form passed a module that emitted
# 'mapx' six times and 'levelid' once, and two payloads shipped without a level:
# the toner report and the enforcement report, both feeding a hover preview that
# then said "no level". Every 'mapx' must have a 'levelid' in the same literal -
# the window is wide enough for a comment between them, and no wider.
MISSING_LEVEL=""
while IFS= read -r hit; do
[ -z "$hit" ] && continue
file=${hit%%:*}
line=${hit#*:}; line=${line%%:*}
# Python writes 'levelid', Vue writes levelid: - accept either, so the
# check covers the payloads a browser sends as well as the ones the server
# emits. It was Python-only, which is how three asset forms shipped a
# position with no level.
if ! sed -n "${line},$((line + 8))p" "$file" | grep -qE "'levelid'|levelid:"; then
MISSING_LEVEL="$MISSING_LEVEL$file:$line"$'\n'
fi
done <<EOF
$(grep -rn "'mapx':" --include='*.py' shopdb/ plugins/ scripts/ 2>/dev/null | grep -v '/tests\?/' || true)
$(grep -rn "mapx: " --include='*.vue' --include='*.js' \
frontend/src plugins/*/frontend 2>/dev/null \
| grep -v '.plugins-staged' | grep -v '\.spec\.js' \
| grep -vE "mapx: (null|0)," || true)
EOF
if [ -n "$MISSING_LEVEL" ]; then
echo "FAIL: these emit 'mapx' with no 'levelid' beside it - a position with no"
echo " level cannot be drawn on the right floor plan (ADR-017):"
echo "$MISSING_LEVEL" | sed 's/^/ /'
VIOLATIONS=$((VIOLATIONS + 1))
fi
# The same rule for the hover preview: binding coordinates into
# LocationMapTooltip without a level makes it report "no level" for every asset,
# which is exactly what shipped in 0.11.0 - all seven call sites missed it.
TOOLTIP_MISSING=""
for candidate in $(grep -rl "LocationMapTooltip" --include='*.vue' frontend/src plugins/ 2>/dev/null | grep -v plugins-staged || true); do
grep -q ':left=' "$candidate" || continue
grep -q ':levelid=' "$candidate" || TOOLTIP_MISSING="$TOOLTIP_MISSING$candidate"$'\n'
done
if [ -n "$TOOLTIP_MISSING" ]; then
echo "FAIL: these bind LocationMapTooltip coordinates without :levelid, so the"
echo " preview cannot know which drawing to use (ADR-017):"
echo "$TOOLTIP_MISSING" | sed 's/^/ /'
VIOLATIONS=$((VIOLATIONS + 1))
fi
# ENFORCING. It was report-only while the backlog was worked off, and the hit
# count then did not move for weeks - a rule that only prints is read as no rule.
# Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run.
SITE_LITERALS_ENFORCE=${SITE_LITERALS_ENFORCE:-1}
echo "==> Checking for site-specific literals in product code (ADR-015)..."
# A real site hostname, a site FQDN, a site name or a site network.
#
# SCOPE. It used to look at .py/.vue/.js under plugins/ and shopdb/ only, and
# every literal that actually reached a second site was somewhere else: the
# PowerShell clients, the installer, the seeds, generated JSON. Case-sensitive
# too, so a capitalised spelling of the host passed. Both fixed - the scan is
# only worth having where
# the leaks are.
#
# agent/ arrived late and is the reason this note now names it. The C# endpoint
# agent is compiled once and installed on every bay at every site, and it holds
# exactly the values this rule is about - a default base URL, a server hostname,
# an SPKI pin. A site literal baked into a .cs file there ships to the next site
# INSIDE A BINARY, where no reviewer reads it and no grep over the deployed tree
# finds it. agent/appsettings.json and the agent's .ps1 utilities come in free,
# since *.json and *.ps1 were already in the include list.
#
# --exclude=README.md IS GONE, and nothing replaces it. That flag used to sit on
# this command ABOVE the --include list, and on GNU grep 3.11 a file-level
# --exclude placed before the includes cancels all of them: proven
# here by planting the same literal in x.py, x.cs, x.txt, x.md, x.csproj and
# x.gitignore, then running the scan with and without the flag. Without it, two
# files match (.py and .cs, as the flags say). With it, all six match. So this
# check was reading every file under every directory listed below, and
# --include='*.cs' was decorative - adding agent/ to the scan did NOT mean the
# C# agent was being scanned for the reason the include list claims, and any
# future narrowing of the include list would have been silently inert.
#
# No post-filter took its place because it never had anything to skip: *.md is
# not in the include list below, so README.md cannot match this scan at all. The
# flag's only effect was the one above.
#
# The .csproj post-filter is kept even though *.csproj is no longer in scope. The
# only site names under agent/ sit in the version-history comment of one project
# file, recording which sites a past build went to; that is a record of what
# happened, not a value a bay receives, and a .csproj is a build input that is
# never deployed. Keeping the filter means adding *.csproj to the include list
# later cannot quietly turn that comment into a gate failure.
#
# bin/, obj/, publish/ and .vs/ are excluded ON THIS COMMAND rather than left to
# EXCLUDES, which is empty whenever USE_GIT_LS_FILES is 1. A developer machine
# with Visual Studio has all four under agent/, holding vendored runtime DLLs
# and per-edit source snapshots, and walking them is what would make this scan
# slow enough to stop being run. Directory excludes are safe here; a file-level
# --exclude is not (see the warning above the EXCLUDES array).
#
# Fleet-wide vocabulary (gea-shopfloor-*) is NOT matched: it is overridable
# through the pctypemap settings and is not one site's data.
#
# A line may declare itself deliberate with a trailing `ADR-015-OK: <reason>`
# marker. That is for an organisation-wide default that is genuinely right for
# every site and configurable anyway - not for "we will fix it later". The
# marker makes the claim visible in review; silence would not.
# ASSEMBLED FROM FRAGMENTS, deliberately. This script is published, and the
# publication scrub greps for the very strings below - written literally, the
# rule's own definition trips the gate that enforces it. Same reason
# tests/test_docs_publishable.py splits its terms.
# Adjacent quoted strings CONCATENATE: 'ab''cd' is the four-character string
# abcd. The apostrophe-escape form ('ab'\''cd') looks almost identical and is
# not - it embeds a literal ' in the middle, so every pattern below matched
# nothing and this check silently passed everything from the day it was written
# until 2026-08-26. That is how a QA SaaS tenant sat in the shipped defaults for
# months with the gate green. Keep the fragments split (the scrub greps this
# file for the very strings the patterns are made of) and keep them ADJACENT.
SITE_HOST='tsg''wp00525'
SITE_DOMAIN='\.geaero''space\.net'
SITE_NAME='West Jeff''erson'
SITE_NETS='10\.134\.48\.|10\.48\.249\.'
# A named SaaS TENANT is as site-specific as a hostname, and this one shipped as
# the default ServiceNow URL for months: every adopting site would have linked
# its technicians into another organisation's QA instance. The vendor domain
# itself is fine to mention; the tenant is not.
SITE_TENANTS='geaero''spaceqa'
SITE_PATTERNS="${SITE_HOST}|${SITE_DOMAIN}|\bwjs\b|${SITE_NAME}|${SITE_NETS}|${SITE_TENANTS}"
SITE_HITS=$(grep -rPni --binary-files=without-match \
"$SITE_PATTERNS" "${EXCLUDES[@]}" \
--include='*.py' --include='*.vue' --include='*.js' \
--include='*.ps1' --include='*.psm1' --include='*.sh' --include='*.iss' \
--include='*.json' --include='*.html' --include='*.cs' \
--exclude-dir=bin --exclude-dir=obj --exclude-dir=publish --exclude-dir=.vs \
plugins/ shopdb/ scripts/ deploy/ frontend/src/ tools/ agent/ 2>/dev/null \
| grep -v '/tests\?/' \
| grep -v '\.spec\.js:' \
| grep -v 'site_imports/' \
| grep -v 'installer/bundle/' \
| grep -v 'offline-packages/' \
| grep -v '\.plugins-staged/' \
| grep -v 'check-naming-and-style\.sh' \
| grep -v 'export-github\.sh' \
| grep -v '\.csproj:' \
| grep -v 'ADR-015-OK' || true)
if [ -n "$SITE_HITS" ]; then
COUNT=$(echo "$SITE_HITS" | wc -l)
if [ "$SITE_LITERALS_ENFORCE" = "1" ]; then
echo "FAIL: $COUNT site-specific literal(s) in product code:"
echo "$SITE_HITS"
echo
VIOLATIONS=$((VIOLATIONS + 1))
else
echo " $COUNT site-specific literal(s) found (report only, not failing):"
echo "$SITE_HITS" | sed 's/^/ /'
echo " See docs/adr/ADR-015-site-specific-configuration.md"
echo
fi
fi
# --- Dependency drift -------------------------------------------------------
#
# Not a naming rule, but it belongs to the same question: can this run be
# trusted. A stale environment makes every OTHER check here a statement about
# libraries that are not the ones the lockfile specifies.
#
# ADVISORY BY DEFAULT, and deliberately so. Set DEPENDENCY_DRIFT_ENFORCE=1 to
# fail the gate (CI should). Two considerations pull against each other here:
# a warning nobody reads is worthless, but a blocking check wedges anyone who
# is mid-upgrade and legitimately holds an unpinned version for an afternoon.
# The tie-breaker is the comment above the site-literal scan: this file has
# twice acquired a reasonable-looking flag that silently disabled a check, once
# for months with the gate green. A check that ANNOUNCES it is advisory cannot
# become that, because its output changes the moment drift appears.
#
# Skipped when python is absent rather than failing: this script is also run on
# machines that only build the frontend.
#
# Each candidate is TEST-EXECUTED, not merely located with command -v. On
# Windows, "python3" resolves to the Microsoft Store app-execution alias - a
# zero-byte stub that exists on PATH, satisfies command -v, and then prints
# "Python was not found; run without arguments to install from the Microsoft
# Store" to stderr and exits non-zero. Locating it and trusting it reported the
# drift check as failing on a machine whose environment was in fact clean.
DEPENDENCY_DRIFT_ENFORCE=${DEPENDENCY_DRIFT_ENFORCE:-0}
echo "==> Checking installed packages against the lockfiles..."
DRIFT_PYTHON=""
for CANDIDATE in python python3 py; do
if command -v "$CANDIDATE" >/dev/null 2>&1 \
&& "$CANDIDATE" -c 'import sys' >/dev/null 2>&1; then
DRIFT_PYTHON="$CANDIDATE"
break
fi
done
# The checker and the lockfiles must both be present. They are not when the gate
# is copied alone into a throwaway tree, which is exactly what
# tests/test_naming_gate_migrations.py does to exercise the non-git fallback
# branch. Absent inputs mean "nothing to compare", not "drift".
if [ ! -f scripts/check-dependency-drift.py ] || [ ! -f requirements.txt ]; then
DRIFT_PYTHON=""
fi
if [ -z "$DRIFT_PYTHON" ]; then
echo " python not found, skipping dependency drift check"
echo
else
DRIFT_OUTPUT=$("$DRIFT_PYTHON" scripts/check-dependency-drift.py --quiet 2>&1) || DRIFT_STATUS=$?
DRIFT_STATUS=${DRIFT_STATUS:-0}
if [ "$DRIFT_STATUS" -ne 0 ]; then
if [ "$DEPENDENCY_DRIFT_ENFORCE" = "1" ]; then
echo "$DRIFT_OUTPUT"
echo
VIOLATIONS=$((VIOLATIONS + 1))
else
echo " dependency drift found (report only, not failing):"
echo "$DRIFT_OUTPUT" | sed 's/^/ /'
echo " Set DEPENDENCY_DRIFT_ENFORCE=1 to make this fail the gate."
echo
fi
fi
fi
if [ "$VIOLATIONS" -gt 0 ]; then
echo "=================================================="
echo "$VIOLATIONS naming/style violation(s) found."
echo "See CONTRIBUTING.md for the full convention."
echo "=================================================="
exit 1
fi
echo "==> All naming/style checks passed."
exit 0