Make main the lean product a site receives

main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
This commit is contained in:
cproudlock
2026-09-19 11:24:40 -04:00
parent cb3975049c
commit 534f179ee6
675 changed files with 115759 additions and 60239 deletions

View File

@@ -9,8 +9,7 @@
# 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)
# Skips: venv/, node_modules/, __pycache__/, frontend/dist/, migrations/versions/
set -e
@@ -19,23 +18,88 @@ 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
)
# 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
@@ -47,6 +111,28 @@ EXCLUDES=(
# 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'
@@ -70,7 +156,22 @@ INCLUDES_ALL=(
# 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)
# -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"
@@ -82,10 +183,36 @@ 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
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 [ "$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"
@@ -95,7 +222,14 @@ for word in cfg ctx mgr req res; do
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 [ "$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"
@@ -104,7 +238,14 @@ if [ -n "$SNAKE_TABLES" ]; then
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 [ "$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"
@@ -112,8 +253,115 @@ if [ -n "$SNAKE_COLS" ]; then
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..."
SNAKE_FK=$(grep -rPn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null | drop_scratch || true)
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"
@@ -122,7 +370,15 @@ if [ -n "$SNAKE_FK" ]; then
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 [ "$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"
@@ -136,8 +392,13 @@ fi
# 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 [ "$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:"
@@ -158,11 +419,18 @@ fi
# 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 [ "$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"
@@ -242,6 +510,43 @@ echo "==> Checking for site-specific literals in product code (ADR-015)..."
# 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.
#
@@ -270,18 +575,21 @@ SITE_NETS='10\.134\.48\.|10\.48\.249\.'
# 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[@]}" \
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' \
plugins/ shopdb/ scripts/ deploy/ frontend/src/ tools/ 2>/dev/null \
--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)