Ports 37 of Benjamin Baker's commits from the wilm branch, mirrored from the
corporate GitHub at 7905917c. wilm rides the publication lineage and shares no
ancestor with wj, so this is a filtered tree patch rather than a merge:
94 files, and every path the publication export strips (docs/, tools/, mcp/,
CLAUDE.md, start-*.sh, .gitea/, the three excluded tests) is held back, because
a raw diff would delete each of them from this tree.
What comes across:
* Endpoint telemetry, phase 2. Physical disk health from two sources, NIC link
state, boot type, Defender health, processor/memory/chassis inventory, the
listening TCP set, mapped network drives and Windows Update history. Five
additive migrations, 0012 through 0016, off the 0011 head.
* Mapped drives are collected by a probe running in the interactive user's
session, because they do not exist in the agent's session 0. Two Windows
sources can disagree about one drive and the worse status wins, so a
half-broken share is never rendered as healthy.
* Windows events are reported once each, keyed on the channel's own record id,
instead of being re-sent whenever a report overlapped the last one.
* PC Detail rework: Details cards flow, the hand-written Network card gives way
to a full address history, Storage and disk health merge into one card with
two sections, Operator Reports and Corporate Tools get their own tabs.
* An opt-in restic backup overlay with a third container, restore gated on the
dump's own data revision rather than the code head, and backup/restore history.
* The agent's version is read from the binary's PE version rather than trusted
from the csproj, and the Fleet view gains an Agent Version column.
* The enqueue route now asks caller_holds instead of the owner's permissions,
which closes the scoped-token elevation on run_script, update_app and
system_restart.
Held back deliberately:
* agent/installer/install-shopdbagent.ps1 stays deleted, as upstream deleted it.
The installer that reaches a bay is generated by _install_script() in
plugins/endpoints/api/siteagent.py; the standalone script had stopped being
shipped and was collecting fixes no endpoint ever ran.
* The .gitignore hunk adding docs/*.md. Correct on the publication lineage,
where docs/ becomes the wiki and its absence is how the gates detect a
published checkout. Here it would silently ignore every new doc.
* Their deletions of docs/DEV-BUILD-TEST.md and docs/PLUGIN-HOOKS.md. Upstream
those were stubs; here PLUGIN-HOOKS.md is the 31KB document CLAUDE.md points
readers at.
* Five zero-byte files committed by an accidental `git add -A`, a run log from
the author's own machine, and docs/notes/, which is working scratch that says
so in its own header and belongs in devnotes/ on this side.
Carried with the import so it lands green: the 17 new routes are described in
docs/api-inventory.json with the spec and project map regenerated, and two new
frontend specs read fixtures through fileURLToPath(URL) which node rejects under
jsdom's own URL global.
Suite 2591 passed / 9 skipped; frontend 391 passed; naming, style and doc gates
clean.
Scope: product
315 lines
14 KiB
Bash
Executable File
315 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
|
|
# 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
|
|
)
|
|
|
|
# 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%%:*}
|
|
# 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.
|
|
#
|
|
# 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
|