mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 18:01:12 +00:00
8a461a2051
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>
230 lines
8.6 KiB
JavaScript
230 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
|
// AZ-485 / F4 (STC-ARCH-01) + AZ-486 / F7 (STC-ARCH-02).
|
|
//
|
|
// 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 (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 [--mode=arch-imports|api-literals] [--root=<repo-root>]
|
|
//
|
|
// 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'
|
|
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, '..'), 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 [--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 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)
|
|
// - a known component directory
|
|
// - a path segment starting with a letter (i.e. NOT the barrel `index`)
|
|
//
|
|
// Allowed by construction:
|
|
// - barrel: from '../api' (no further /<File>)
|
|
// - intra-component: from './sse' (starts with ./, not ../)
|
|
const COMPONENT_DIRS = 'api|auth|components|features/[a-z-]+|hooks|i18n'
|
|
const DEEP_IMPORT_RE = new RegExp(
|
|
String.raw`from\s+['"](?:\.\./)+(?:src/)?(?:${COMPONENT_DIRS})/[A-Za-z]`,
|
|
)
|
|
|
|
// 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:
|
|
// AnnotationsPage -> DetectionClasses -> 06_annotations barrel
|
|
// -> AnnotationsPage
|
|
// so consumers (DetectionClasses, tests/detection_classes.test.tsx)
|
|
// import the file directly. F3 will move the file and remove this
|
|
// exemption.
|
|
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
|
|
try {
|
|
entries = readdirSync(rootDir, { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
for (const entry of entries) {
|
|
const full = join(rootDir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
if (IGNORED_DIRS.has(entry.name)) continue
|
|
yield* walkSourceFiles(full)
|
|
} else if (entry.isFile()) {
|
|
const dot = entry.name.lastIndexOf('.')
|
|
if (dot < 0) continue
|
|
const ext = entry.name.slice(dot)
|
|
if (!SOURCE_EXT.has(ext)) continue
|
|
yield full
|
|
}
|
|
}
|
|
}
|
|
|
|
function readLines(file) {
|
|
try {
|
|
return readFileSync(file, 'utf8').split('\n')
|
|
} catch {
|
|
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('\\', '/')
|
|
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 (ARCH_IMPORTS_EXEMPT_RE.test(line)) continue
|
|
hits.push(`${rel}:${i + 1}: ${line.trim().slice(0, 200)}`)
|
|
}
|
|
return hits
|
|
}
|
|
|
|
function scanApiLiterals(file, root) {
|
|
const hits = []
|
|
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(...config.scan(file, root))
|
|
}
|
|
}
|
|
if (hits.length) {
|
|
process.stderr.write(config.failHeader)
|
|
for (const h of hits) process.stderr.write(` ${h}\n`)
|
|
process.exit(1)
|
|
}
|
|
process.exit(0)
|
|
}
|
|
|
|
main()
|