WFDASH-WEBAPP/scripts/audit-pb-fields.py

320 lines
12 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
Audit PB schema fields against app code usage.
Strategy
========
1. Parse the PB schema → list of business collections and their fields.
2. For each collection, decide which source files LEGITIMATELY touch it
(not coincidental substring matches). We accept a file as touching a
collection if the file contains any of:
- a PB-style call: `pb.collection("pos")` / `pb.collection('pos')`
- a string literal: `"pos"` / `'pos'`
- a PB id literal: `"pbc_2415918301"` (matched via the schema id)
- a `pos.` access: in code that imports PocketBase, treat `pos.<x>`
as a touch for the `pos` collection
For short, common collection names (e.g. `pos`, `name`, `id`) the
string-literal rule filters out coincidental matches like a JS
variable named `pos`.
3. For each (collection, field) pair, count word-boundary hits for the
field name across the files that legitimately touch the collection.
4. For collections nobody touches, every non-system field is unused.
5. Output: a Markdown report at `docs/audit/unused-pb-fields.md`.
Note: `pb-exports/` is excluded from the search because it is a schema
dump, not application code. Hits there are tautological.
"""
from __future__ import annotations
import json
import os
import re
from collections import defaultdict
from pathlib import Path
REPO = Path("/Volumes/EXT/DEVELOPMENT/WFDASH_NEXTJS")
SCHEMA = Path(
"/Volumes/KIOXIA/.minimax/v2/assets/2026/07/21/"
"00-05-21-389-asset_20260721-000521-389_d7e2a19e9f82_37269699-pb_schema-5.json"
)
SRC = REPO / "src"
SCRIPTS = REPO / "scripts"
PBEXPORTS = REPO / "pb-exports" # excluded from search; it's a schema dump
SEARCH_ROOTS = [SRC, SCRIPTS]
EXCLUDE_DIRS = {"node_modules", ".next", ".git", "test-results", ".e2e-tmp"}
# Short field names that would false-positive across files even when
# the collection name IS mentioned (e.g. `notes`, `description`).
# We require the file to also mention the field's collection for these.
COMMON_FIELDS = {
"id", "created", "updated", "notes", "description", "status",
"name", "slug", "color", "type", "role", "size", "date",
"subject", "from", "to", "body", "html", "text", "month", "year",
"tag", "quantity", "qty", "amount", "total", "value", "label",
"title", "kind", "direction", "currency", "rate", "unit", "sku",
"supplier", "category", "customer", "company", "order", "quote",
"address", "city", "country", "code", "number", "filename",
"content_type", "size_bytes", "uploaded_at", "file", "field",
"phone", "email", "date", "notes", "notes", "supplier_name",
"file_name", "raw_email_id", "raw_email", "stock_item",
"stock", "total_count",
}
def walk_files() -> list[tuple[Path, str]]:
out: list[tuple[Path, str]] = []
for root in SEARCH_ROOTS:
if not root.exists():
continue
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
for fn in filenames:
if not (
fn.endswith(".ts") or fn.endswith(".tsx")
or fn.endswith(".mjs") or fn.endswith(".js")
):
continue
p = Path(dirpath) / fn
try:
txt = p.read_text(encoding="utf-8", errors="replace")
except Exception:
continue
out.append((p, txt))
return out
def load_schema() -> list[dict]:
with SCHEMA.open() as f:
return json.load(f)
# Per-collection patterns that mean "this file touches the collection".
# Built dynamically from the schema so we also catch the PB id literal.
def build_collection_patterns(c: dict) -> list[re.Pattern]:
cname = c["name"]
cid = c["id"]
pats: list[re.Pattern] = []
# 1. String literal: pb.collection("pos") or collection: "pos" etc.
pats.append(re.compile(rf'["\']{re.escape(cname)}["\']'))
# 2. PB id literal (e.g. "pbc_2415918301")
if cid:
pats.append(re.compile(rf"{re.escape(cid)}"))
return pats
def find_files_touching_collection(
files: list[tuple[Path, str]], patterns: list[re.Pattern]
) -> list[tuple[Path, str]]:
out: list[tuple[Path, str]] = []
for path, txt in files:
if any(p.search(txt) for p in patterns):
out.append((path, txt))
return out
def count_refs_for_field(
field_name: str,
files: list[tuple[Path, str]],
) -> tuple[int, list[tuple[Path, int]]]:
"""
Count word-boundary matches for `field_name` across the given files
(which are already filtered to those that touch the collection).
For very short or common field names, also require the field's
collection-name in the file to avoid cross-field false positives
like `notes` on `customers` matching a `notes` in `raw_price_list`.
"""
field_pat = re.compile(rf"\b{re.escape(field_name)}\b")
hits: list[tuple[Path, int]] = []
for path, txt in files:
c = len(field_pat.findall(txt))
if c:
hits.append((path, c))
total = sum(c for _, c in hits)
return total, hits
def main() -> int:
files = walk_files()
schema = load_schema()
skip = {"_superusers", "_authOrigins", "_externalAuths", "_mfas", "_otps"}
collections = [c for c in schema if c.get("name") not in skip]
SYSTEM_FIELD_NAMES = {
"id", "created", "updated", "password", "tokenKey", "email",
"emailVisibility", "verified",
}
rows: list[dict] = []
dead_collection_rows: list[tuple[str, str, int]] = [] # (name, type, field_count)
for c in collections:
cname = c["name"]
ctype = c.get("type", "base")
patterns = build_collection_patterns(c)
touched_files = find_files_touching_collection(files, patterns)
coll_referenced = len(touched_files) > 0
if not coll_referenced and ctype != "view":
# The whole collection is dead.
# Count user-defined fields only.
udf = sum(
1 for f in c.get("fields", [])
if f.get("name") not in SYSTEM_FIELD_NAMES and not f.get("system")
)
dead_collection_rows.append((cname, ctype, udf))
for f in c.get("fields", []):
fname = f.get("name", "")
if fname in SYSTEM_FIELD_NAMES:
continue
if f.get("system"):
continue
ftype = f.get("type", "?")
required = f.get("required", False)
fhelp = f.get("help", "")
# For COMMON_FIELDS or very short names, also require the
# collection name to be mentioned in the file to avoid
# cross-collection false positives. (We already filtered to
# files that touch the collection via string literal /
# PB id, so this check is cheap.)
if fname in COMMON_FIELDS or len(fname) < 6:
coll_pat = re.compile(rf"\b{re.escape(cname)}\b")
scope = [
(p, t) for p, t in touched_files
if coll_pat.search(t)
]
else:
scope = touched_files
total, hits = count_refs_for_field(fname, scope)
rows.append({
"collection": cname,
"ctype": ctype,
"field": fname,
"type": ftype,
"required": required,
"help": fhelp,
"refs": total,
"hit_files": [str(p.relative_to(REPO)) for p, _ in hits],
"coll_referenced": coll_referenced,
})
by_coll: dict[str, list[dict]] = defaultdict(list)
for r in rows:
by_coll[r["collection"]].append(r)
out_lines: list[str] = []
out_lines.append("# PB Schema Audit — Unused / Legacy Fields")
out_lines.append("")
out_lines.append(
"Generated by `scripts/audit-pb-fields.py`. For every non-system "
"field in every business collection, counts references in the "
"Next.js app source (`src/`, `scripts/` — `pb-exports/` is a schema "
"dump and is excluded). A file is considered to \"touch\" a "
"collection if it contains the collection name as a string literal "
"(e.g. `pb.collection(\"orders\")`) or the collection's PB id "
"(`pbc_3527180448`)."
)
out_lines.append("")
total_fields = len(rows)
unused = [r for r in rows if r["refs"] == 0]
one_ref = [r for r in rows if r["refs"] == 1]
out_lines.append("## Summary")
out_lines.append("")
out_lines.append(f"- **Total fields audited:** {total_fields}")
out_lines.append(f"- **Unused fields (0 references):** {len(unused)}")
out_lines.append(f"- **Single-reference fields:** {len(one_ref)}")
out_lines.append(
f"- **Dead collections (never referenced by app):** "
f"{len(dead_collection_rows)}"
)
if dead_collection_rows:
for cname, ctype, n in dead_collection_rows:
out_lines.append(f" - `{cname}` ({ctype}) — {n} fields all dead")
out_lines.append("")
# Per-collection tables
for cname in sorted(by_coll):
c_rows = by_coll[cname]
c_rows_sorted = sorted(c_rows, key=lambda r: (r["refs"], r["field"]))
ctype = c_rows_sorted[0]["ctype"]
coll_ref = c_rows_sorted[0]["coll_referenced"]
dead = " ⚰️ **DEAD COLLECTION**" if not coll_ref else ""
out_lines.append(f"## `{cname}` (`{ctype}`){dead}")
out_lines.append("")
out_lines.append("| field | type | required | refs | used where |")
out_lines.append("|---|---|---|---|---|")
for r in c_rows_sorted:
tag = (
"**❌ unused**" if r["refs"] == 0
else ("⚠️ 1 ref" if r["refs"] == 1 else f"✅ {r['refs']}")
)
files_str = "<br>".join(r["hit_files"][:5]) if r["hit_files"] else "—"
if len(r["hit_files"]) > 5:
files_str += f"<br>... +{len(r['hit_files']) - 5} more"
out_lines.append(
f"| `{r['field']}` | {r['type']} | "
f"{'yes' if r['required'] else 'no'} | {tag} | {files_str} |"
)
out_lines.append("")
out_lines.append("## Unused fields only (candidates for removal)")
out_lines.append("")
out_lines.append("| collection | field | type | required |")
out_lines.append("|---|---|---|---|")
for r in sorted(unused, key=lambda r: (r["collection"], r["field"])):
out_lines.append(
f"| `{r['collection']}` | `{r['field']}` | {r['type']} | "
f"{'yes' if r['required'] else 'no'} |"
)
out_lines.append("")
out_lines.append("## Single-reference fields (likely legacy / dead-code)")
out_lines.append("")
out_lines.append("| collection | field | type | file |")
out_lines.append("|---|---|---|---|")
for r in sorted(one_ref, key=lambda r: (r["collection"], r["field"])):
f = r["hit_files"][0] if r["hit_files"] else "—"
out_lines.append(
f"| `{r['collection']}` | `{r['field']}` | {r['type']} | `{f}` |"
)
out_lines.append("")
out_lines.append("## Dead collections (never referenced anywhere in the app)")
out_lines.append("")
out_lines.append(
"These collections exist in PB but no app file touches them. "
"Every non-system field on them is unused. Safe to inspect for "
"deletion once you confirm the data is not needed by anything "
"external (n8n flows, MCP, manual admin, etc)."
)
out_lines.append("")
if not dead_collection_rows:
out_lines.append("_None._")
else:
for cname, ctype, n in dead_collection_rows:
out_lines.append(f"- `{cname}` ({ctype}, {n} user fields)")
out_lines.append("")
report = "\n".join(out_lines)
out_path = REPO / "docs" / "audit" / "unused-pb-fields.md"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(report, encoding="utf-8")
print(f"Wrote {out_path}")
print(f" total fields: {total_fields}")
print(f" unused: {len(unused)}")
print(f" single-ref: {len(one_ref)}")
print(f" dead collections: {len(dead_collection_rows)}")
for cname, ctype, n in dead_collection_rows:
print(f" - {cname} ({ctype}, {n} fields)")
return 0
if __name__ == "__main__":
raise SystemExit(main())