Six fixes that were ours regardless of what the sister site does. Four came out of our own reviews; two you reported from the settings pages. MAX_CONTENT_LENGTH was set nowhere. Every upload route caps its own payload, but each of those checks runs after the body has arrived: the first touch of request.form or request.files makes Werkzeug parse the whole multipart and spool the file to a temp file, so a route can reject the content and still have paid for the transfer and the disk. Three separate reviews found that shape in three different routes, which makes it a property of the app rather than of any handler. 128 MB, clearing the 64 MB agent binary with room for the envelope. The naming gate excluded devnotes with --exclude-dir, which matches a directory of that NAME at any depth - so a force-added plugins/<plugin>/devnotes/ was invisible to every check in the file, ADR-015 included. Replaced with a path anchored post-filter. Verified by planting the same site literal in root devnotes/ AND in plugins/endpoints/devnotes/: the gate now reports exactly one, the nested one. Before, it reported none. BaseModel.to_dict walked __table__.columns and published every one, so a model without an override published whatever it carried - including columns added later by someone not thinking about this method. User has no override, so User.to_dict() returned its bcrypt hash. Nothing calls it in a response path today, which is luck rather than design. A blanket allowlist would empty the output of all 34 models relying on the default, so this pins a floor instead: named secret-bearing columns are never emitted, and a model may extend the set. Eight tests, including a sweep over every mapped model. ActionPicker's Esc handlers had .prevent but not .stop, so the event bubbled to the wrapper and closed the whole picker instead of returning to the monitor list. The branding page reported "error loading image" on the site and badge logos. Two bugs stacked. The load failure was real: the preview used the stored value as a raw src, and that value is root-relative, so under /shopdb or /ops it resolved against the SERVER root and 404d - siteSettings.js wraps every logo in withBase() and this page was the one place that did not. With that fixed two of three were still invisible, because the shipped GE mark is fill="white" for the dark sidebar and was rendering white-on-white. The preview tile now sits on a mid-tone checker so artwork of any colour reads against it. The floor-map levels table ran off the right with its cells crushed - nine columns of inputs, no scroll wrapper, and .data-table is styled nowhere. My first attempt reached for the global .table-container and made it WORSE: that class also sets white-space: nowrap, and a full sentence of hint text lives in one of the cells, so nowrap forced the table wider still. It now scrolls in a local wrapper, the hint wraps, and the name input has a width instead of collapsing to one character. Suite 2377, frontend 324, gate green. Scope: product
303 lines
14 KiB
Bash
Executable File
303 lines
14 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
|
|
|
|
EXCLUDES=(
|
|
--exclude-dir=venv
|
|
--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
|
|
--exclude-dir=versions
|
|
--exclude-dir=staticdocs
|
|
)
|
|
|
|
# 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; }
|
|
|
|
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..."
|
|
NON_ASCII=$(grep -rPn '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_ALL[@]}" . 2>/dev/null | drop_scratch || true)
|
|
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_]|$)
|
|
for word in cfg ctx mgr req res; do
|
|
HITS=$(grep -rPn "(^|[^a-zA-Z0-9_])${word}([^a-zA-Z0-9_]|\$)" "${EXCLUDES[@]}" --include='*.py' --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null \
|
|
| grep -vP "(^|[^a-zA-Z0-9_])(request_obj|response_obj)" \
|
|
|| true)
|
|
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..."
|
|
SNAKE_TABLES=$(grep -rPn "__tablename__\s*=\s*['\"][^'\"]*_" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null | drop_scratch || true)
|
|
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..."
|
|
SNAKE_COLS=$(grep -rPn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null | drop_scratch || true)
|
|
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
|
|
|
|
echo "==> Checking for snake_case ForeignKey targets..."
|
|
SNAKE_FK=$(grep -rPn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null | drop_scratch || true)
|
|
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)..."
|
|
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[@]}" --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null | drop_scratch || true)
|
|
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
|
|
PLUGIN_FE_IMPORTS=$(grep -rPn "(import|from)\s+['\"]([^'\"]*\.\./\.\./|[^'\"]*/plugins/)" \
|
|
--include='*.vue' --include='*.js' plugins/*/frontend/ 2>/dev/null || true)
|
|
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..."
|
|
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)
|
|
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%%:*}
|
|
if ! sed -n "${line},$((line + 8))p" "$file" | grep -q "'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)
|
|
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.
|
|
#
|
|
# 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 "$SITE_PATTERNS" "${EXCLUDES[@]}" \
|
|
--include='*.py' --include='*.vue' --include='*.js' \
|
|
--include='*.ps1' --include='*.psm1' --include='*.sh' --include='*.iss' \
|
|
--include='*.json' --include='*.html' \
|
|
plugins/ shopdb/ scripts/ deploy/ frontend/src/ tools/ 2>/dev/null \
|
|
| grep -v '/tests\?/' \
|
|
| grep -v '\.spec\.js:' \
|
|
| grep -v 'site_imports/' \
|
|
| grep -v 'installer/bundle/' \
|
|
| grep -v '\.plugins-staged/' \
|
|
| grep -v 'check-naming-and-style\.sh' \
|
|
| grep -v 'export-github\.sh' \
|
|
| 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
|
|
|
|
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
|