PSA/scripts/generate_route_inventory.py
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

207 lines
6.8 KiB
Python

import csv
import json
import os
import re
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import List, Optional, Set
BASE_DIR = Path(__file__).resolve().parents[1]
@dataclass
class RouteRecord:
edition: str
route_path: str
methods: List[str]
route_file: str
controller: str
controller_import: str
controller_file: str
def detect_methods(source: str) -> List[str]:
method_matches: Set[str] = set()
for match in re.findall(r"export\s+(?:const|async\s+function|function)\s+([A-Z]+)\b", source):
method_matches.add(match.upper())
for block in re.findall(r"export\s*{\s*([^}]*)}", source):
tokens = [token.strip() for token in block.split(',')]
for token in tokens:
if not token:
continue
if ' as ' in token:
token = token.rsplit(' as ', 1)[-1].strip()
if token.isupper():
method_matches.add(token)
for block in re.findall(r"export\s+const\s+{\s*([^}]*)}", source):
tokens = [token.strip() for token in block.split(',')]
for token in tokens:
if token.isupper():
method_matches.add(token)
return sorted(method_matches)
def detect_controller(source: str) -> str:
controller_match = re.search(r"new\s+(\w+Controller)\s*\(", source)
if controller_match:
return controller_match.group(1)
handler_match = re.search(r"class\s+(\w+Controller)\s+extends", source)
if handler_match:
return handler_match.group(1)
return ""
def detect_controller_import(source: str) -> str:
import_match = re.search(r"from\s+'([^']*controllers[^']*)'", source)
if import_match:
return import_match.group(1)
import_match = re.search(r'from\s+"([^"]*controllers[^"]*)"', source)
if import_match:
return import_match.group(1)
return ""
def resolve_controller_file(import_path: str, route_file: Path) -> str:
if not import_path:
return ""
candidates: List[Path] = []
if import_path.startswith('@/'):
rel = import_path[2:]
candidates.append(BASE_DIR / 'server' / 'src' / rel)
elif import_path.startswith('@ee/'):
rel = import_path[4:]
candidates.append(BASE_DIR / 'ee' / 'server' / 'src' / rel)
elif import_path.startswith('~/'):
rel = import_path[2:]
candidates.append(BASE_DIR / 'server' / 'src' / rel)
elif import_path.startswith('./') or import_path.startswith('../'):
candidates.append((route_file.parent / import_path).resolve())
else:
candidates.append(BASE_DIR / import_path)
extension_candidates = ['.ts', '.tsx', '.js', '.mjs', '.cjs']
def existing_path(base: Path) -> Optional[Path]:
if base.is_file():
return base
if base.suffix:
if base.exists():
return base
for ext in extension_candidates:
candidate = base.with_suffix(ext)
if candidate.exists():
return candidate
# Support index files in directories
for ext in extension_candidates:
candidate = base / f'index{ext}'
if candidate.exists():
return candidate
return None
for base in candidates:
resolved = existing_path(base)
if resolved and BASE_DIR in resolved.parents:
return str(resolved.relative_to(BASE_DIR))
return ""
def next_path_to_openapi(rel_path: Path) -> str:
segments = ['api']
for segment in rel_path.parts:
if segment in {'route.ts', 'route.tsx', 'route'}:
continue
if segment.startswith('(') and segment.endswith(')'):
# Next.js "group" segment, skip in route path
continue
if segment.startswith('['):
# Normalize dynamic segments to OpenAPI-style parameters
raw = segment
while raw.startswith('[') and raw.endswith(']'):
raw = raw[1:-1]
raw = raw.removeprefix('...').removeprefix('..')
if not raw:
raw = 'param'
segment = '{' + raw + '}'
segments.append(segment)
return '/' + '/'.join(segments)
def collect_records(base: Path, edition: str) -> List[RouteRecord]:
records: List[RouteRecord] = []
for dirpath, _, filenames in os.walk(base):
for filename in filenames:
if filename not in {'route.ts', 'route.tsx'}:
continue
route_file_path = Path(dirpath, filename)
rel = route_file_path.relative_to(base)
with route_file_path.open('r', encoding='utf-8') as fh:
source = fh.read()
methods = detect_methods(source)
controller = detect_controller(source)
controller_import = detect_controller_import(source)
controller_file = resolve_controller_file(controller_import, route_file_path)
route_path = next_path_to_openapi(rel)
records.append(
RouteRecord(
edition=edition,
route_path=route_path,
methods=methods,
route_file=str(route_file_path.relative_to(BASE_DIR)),
controller=controller,
controller_import=controller_import,
controller_file=controller_file,
)
)
records.sort(key=lambda r: (r.route_path, r.edition))
return records
def main() -> None:
targets = [
('CE', BASE_DIR / 'server' / 'src' / 'app' / 'api'),
('EE', BASE_DIR / 'ee' / 'server' / 'src' / 'app' / 'api'),
]
records: List[RouteRecord] = []
for edition, base in targets:
if base.exists():
records.extend(collect_records(base, edition))
output_dir = BASE_DIR / 'docs' / 'openapi'
output_dir.mkdir(parents=True, exist_ok=True)
json_path = output_dir / 'route-inventory.json'
csv_path = output_dir / 'route-inventory.csv'
with json_path.open('w', encoding='utf-8') as jf:
json.dump([asdict(r) for r in records], jf, indent=2)
jf.write('\n')
with csv_path.open('w', encoding='utf-8', newline='') as cf:
writer = csv.writer(cf)
writer.writerow([
'edition',
'route_path',
'methods',
'route_file',
'controller',
'controller_import',
'controller_file',
])
for record in records:
writer.writerow([
record.edition,
record.route_path,
' '.join(record.methods),
record.route_file,
record.controller,
record.controller_import,
record.controller_file,
])
print(f"Wrote {len(records)} routes to {json_path} and {csv_path}")
if __name__ == '__main__':
main()