#!/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= 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//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() 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//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//), 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 </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: ` # 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