[AZ-486] F7 endpoint builders + STC-ARCH-02 (cycle 1 close)

Single source of truth for every /api/<service>/... URL the UI talks to:
src/api/endpoints.ts (25 typed builders) re-exported via the F4 barrel.
Migrates 13 production callsites in admin / annotations / flights /
settings / dataset / auth / api-client / FlightContext / DetectionClasses
to endpoints.* . Adds the STC-ARCH-02 static gate (--mode=api-literals
in scripts/check-arch-imports.mjs, wired into scripts/run-tests.sh)
that fails any new hardcoded /api/<service>/ literal in src/ outside
endpoints.ts and *.test.tsx? files.

Tests: +36 contract assertions in src/api/endpoints.test.ts (every
builder, character-identical), +6 STC-ARCH-02 architecture cases in
tests/architecture_imports.test.ts (single / double / template literal
fail paths, *.test.* exemption, line-comment skip, migrated codebase
pass). Fast profile 167 -> 209 PASS / 13 SKIP / 0 FAIL, +42 new,
0 regressions. Static profile 31 / 31 PASS.

Closes architecture baseline finding F7. Cycle 1 of Phase B closed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 23:03:45 +03:00
parent 23746ec61d
commit 8a461a2051
23 changed files with 777 additions and 127 deletions
+126 -31
View File
@@ -1,21 +1,27 @@
#!/usr/bin/env node
// AZ-485 / F4 STC-ARCH-01: no cross-component deep imports.
// AZ-485 / F4 (STC-ARCH-01) + AZ-486 / F7 (STC-ARCH-02).
//
// Every component under src/<component>/ exposes its Public API via a barrel
// (src/<component>/index.ts). Cross-component imports MUST go through the
// barrel; reaching into another component's internal files is a layering
// violation.
// STC-ARCH-01 (mode=arch-imports, default) — no cross-component deep imports.
// Every component under src/<component>/ exposes its Public API via a barrel
// (src/<component>/index.ts). Cross-component imports MUST go through the
// barrel; reaching into another component's internal files is a layering
// violation.
//
// STC-ARCH-02 (mode=api-literals) — no hardcoded `/api/<service>/...` literals
// in production source. The single source of truth for those URLs is
// src/api/endpoints.ts (AZ-486 / F7). Any production callsite of api.* or
// createSSE() that needs an API path must use an `endpoints.*` builder.
//
// Single source of truth — scripts/run-tests.sh delegates here, and the
// architecture unit test calls this script with a synthetic fixture to verify
// the check fails on a deep import (AC-4) and passes on the migrated codebase
// (AC-5). Mirrors the scripts/check-banned-deps.mjs pattern (AZ-482).
// architecture unit test (tests/architecture_imports.test.ts) calls this
// script with synthetic fixtures to verify each gate fails on a regression
// and passes on the migrated codebase. Mirrors the scripts/check-banned-deps.mjs
// pattern (AZ-482).
//
// Usage:
// node scripts/check-arch-imports.mjs [--root=<repo-root>]
// node scripts/check-arch-imports.mjs [--mode=arch-imports|api-literals] [--root=<repo-root>]
//
// Exit code 0 on PASS (no offending deep imports); non-zero on FAIL with the
// hit list on stderr.
// Exit code 0 on PASS; non-zero on FAIL with the hit list on stderr.
import { readFileSync, statSync, readdirSync } from 'node:fs'
import { join, dirname, resolve, relative } from 'node:path'
@@ -24,25 +30,37 @@ import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const MODES = new Set(['arch-imports', 'api-literals'])
function parseArgs(argv) {
const out = { root: resolve(__dirname, '..') }
const out = { root: resolve(__dirname, '..'), mode: 'arch-imports' }
for (const a of argv.slice(2)) {
if (a.startsWith('--root=')) out.root = resolve(a.slice('--root='.length))
else if (a.startsWith('--mode=')) out.mode = a.slice('--mode='.length)
else if (a === '-h' || a === '--help') {
process.stderr.write('Usage: check-arch-imports.mjs [--root=<repo-root>]\n')
process.stderr.write(
'Usage: check-arch-imports.mjs [--mode=arch-imports|api-literals] [--root=<repo-root>]\n',
)
process.exit(0)
}
}
if (!MODES.has(out.mode)) {
process.stderr.write(`unknown --mode=${out.mode}; expected one of: ${[...MODES].join(', ')}\n`)
process.exit(2)
}
return out
}
const SCAN_ROOTS = ['src', 'tests', 'e2e']
const IGNORED_DIRS = new Set([
'node_modules', 'dist', 'build', 'test-output', 'test-results',
'coverage', '.git', '.cache', 'playwright-report', 'blob-report',
])
const SOURCE_EXT = new Set(['.ts', '.tsx'])
// ----------------------------------------------------------------------------
// STC-ARCH-01 — cross-component deep imports (mode=arch-imports)
// ----------------------------------------------------------------------------
// Cross-component deep-import pattern: `from '<up>/<src>?/<component>/<File>'`
// - one or more `..` segments
// - optional `src/` prefix (used by tests/ and e2e/ imports)
@@ -57,7 +75,7 @@ const DEEP_IMPORT_RE = new RegExp(
String.raw`from\s+['"](?:\.\./)+(?:src/)?(?:${COMPONENT_DIRS})/[A-Za-z]`,
)
// F3-pending exemptions:
// F3-pending exemptions for STC-ARCH-01:
// - `features/annotations/classColors` — classColors is logically owned by
// 11_class-colors but physically lives under 06_annotations. Re-exporting
// it through the 06_annotations barrel creates a circular import:
@@ -66,7 +84,44 @@ const DEEP_IMPORT_RE = new RegExp(
// so consumers (DetectionClasses, tests/detection_classes.test.tsx)
// import the file directly. F3 will move the file and remove this
// exemption.
const EXEMPT_RE = /features\/annotations\/classColors/
const ARCH_IMPORTS_EXEMPT_RE = /features\/annotations\/classColors/
const ARCH_IMPORTS_SCAN_ROOTS = ['src', 'tests', 'e2e']
// ----------------------------------------------------------------------------
// STC-ARCH-02 — hardcoded /api/<service>/ literals (mode=api-literals)
// ----------------------------------------------------------------------------
// Hardcoded API path pattern: a quote/backtick immediately followed by
// `/api/<lowercase-hyphen-service>/`. Catches single-quoted ('...'),
// double-quoted ("..."), and template-literal (`...`) styles equally — quote
// style is not a meaningful difference for "no hardcoded URLs in production".
//
// Examples that MATCH:
// '/api/admin/users'
// "/api/admin/auth/refresh"
// `/api/annotations/dataset?${params}`
// `/api/flights/${id}/waypoints`
//
// Examples that DO NOT match (intentional):
// /api/admin (bare prefix, no trailing /) — kept out so getApiBase()
// prefixes the base path
// unobstructed.
// /api/users (no service segment, hypothetical) — pattern requires the
// SECOND segment.
const API_LITERAL_RE = /[`'"]\/api\/[a-z][a-z-]*\//
// STC-ARCH-02 exemptions:
// - the contract owner itself (src/api/endpoints.ts)
// - test files (*.test.ts, *.test.tsx) — tests legitimately assert URL
// strings, and the contract is precisely "the test file IS the contract"
const API_LITERAL_EXEMPT_FILES_RE = /(?:^|\/)(?:api\/endpoints\.ts|.+\.test\.tsx?)$/
const API_LITERALS_SCAN_ROOTS = ['src']
// ----------------------------------------------------------------------------
// Walk helpers
// ----------------------------------------------------------------------------
function* walkSourceFiles(rootDir) {
let entries
@@ -90,41 +145,81 @@ function* walkSourceFiles(rootDir) {
}
}
function scanFile(file, root) {
const hits = []
let text
function readLines(file) {
try {
text = readFileSync(file, 'utf8')
return readFileSync(file, 'utf8').split('\n')
} catch {
return hits
return null
}
}
// ----------------------------------------------------------------------------
// Scanners — one per mode. Both share the walker and line-comment skip.
// ----------------------------------------------------------------------------
function scanArchImports(file, root) {
const hits = []
const lines = readLines(file)
if (lines === null) return hits
const rel = relative(root, file).replaceAll('\\', '/')
const lines = text.split('\n')
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (/^\s*\/\//.test(line)) continue
if (!DEEP_IMPORT_RE.test(line)) continue
if (EXEMPT_RE.test(line)) continue
if (ARCH_IMPORTS_EXEMPT_RE.test(line)) continue
hits.push(`${rel}:${i + 1}: ${line.trim().slice(0, 200)}`)
}
return hits
}
function main() {
const { root } = parseArgs(process.argv)
function scanApiLiterals(file, root) {
const hits = []
for (const sub of SCAN_ROOTS) {
const rel = relative(root, file).replaceAll('\\', '/')
if (API_LITERAL_EXEMPT_FILES_RE.test(rel)) return hits
const lines = readLines(file)
if (lines === null) return hits
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (/^\s*\/\//.test(line)) continue
if (!API_LITERAL_RE.test(line)) continue
hits.push(`${rel}:${i + 1}: ${line.trim().slice(0, 200)}`)
}
return hits
}
// ----------------------------------------------------------------------------
// Main
// ----------------------------------------------------------------------------
function main() {
const { root, mode } = parseArgs(process.argv)
const config = mode === 'api-literals'
? {
roots: API_LITERALS_SCAN_ROOTS,
scan: scanApiLiterals,
failHeader:
'STC-ARCH-02 — hardcoded /api/<service>/ literals detected in ' +
'production source (must go through endpoints.* builders, see ' +
'src/api/endpoints.ts):\n',
}
: {
roots: ARCH_IMPORTS_SCAN_ROOTS,
scan: scanArchImports,
failHeader:
'STC-ARCH-01 — cross-component deep imports detected ' +
'(must go through component barrel, see module-layout.md):\n',
}
const hits = []
for (const sub of config.roots) {
const full = join(root, sub)
try { statSync(full) } catch { continue }
for (const file of walkSourceFiles(full)) {
hits.push(...scanFile(file, root))
hits.push(...config.scan(file, root))
}
}
if (hits.length) {
process.stderr.write(
'STC-ARCH-01 — cross-component deep imports detected ' +
'(must go through component barrel, see module-layout.md):\n',
)
process.stderr.write(config.failHeader)
for (const h of hits) process.stderr.write(` ${h}\n`)
process.exit(1)
}
+16 -1
View File
@@ -485,7 +485,21 @@ if [ "$RUN_STATIC" = "true" ]; then
# through the 06_annotations barrel would create a circular import
# AnnotationsPage → DetectionClasses → barrel → AnnotationsPage.)
static_check_no_cross_component_deep_imports() {
node "$PROJECT_ROOT/scripts/check-arch-imports.mjs"
node "$PROJECT_ROOT/scripts/check-arch-imports.mjs" --mode=arch-imports
}
# AZ-486 F7 — STC-ARCH-02: no hardcoded `/api/<service>/` literals in
# production source. After F7 the single source of truth for API paths is
# `src/api/endpoints.ts`; every production callsite of `api.*` / `createSSE`
# must use an `endpoints.*` builder. Flags string literals of the form
# '/api/admin/users'
# "/api/admin/auth/refresh"
# `/api/flights/${id}/waypoints`
# Allowed:
# - the contract owner itself: src/api/endpoints.ts
# - test files: *.test.ts / *.test.tsx (the test file IS the contract)
static_check_no_api_literals() {
node "$PROJECT_ROOT/scripts/check-arch-imports.mjs" --mode=api-literals
}
# AZ-479 NFT-PERF-01 / NFT-RES-LIM-01 — initial JS bundle ≤ 2 MB gzipped.
@@ -535,6 +549,7 @@ if [ "$RUN_STATIC" = "true" ]; then
run_static "STC-B1" "vite build succeeds" "AC-6" "n/a" static_check_vite_build
run_static "STC-S5" "mission-planner not in dist/" "AC-31" "n/a" static_check_dist_no_mission_planner
run_static "STC-ARCH-01" "no cross-component deep imports (barrel-only)" "F4" "AZ-485" static_check_no_cross_component_deep_imports
run_static "STC-ARCH-02" "no hardcoded /api/<service>/ literals in src/" "F7" "AZ-486" static_check_no_api_literals
run_static "STC-PERF01" "initial JS bundle ≤ 2 MB gz" "NFT-PERF-01" "40" static_check_bundle_size
run_static "STC-RES02" "nginx client_max_body_size 500M" "NFT-RES-LIM-02" "n/a" static_check_nginx_body_cap
run_static "STC-RES03" "Dockerfile final stage nginx:alpine no Node" "NFT-RES-LIM-03" "n/a" static_check_dockerfile_nginx_alpine