#!/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// exposes its Public API via a barrel // (src//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//...` 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=] // // 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=]\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 '/?//'` // - 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 /) // - intra-component: from './sse' (starts with ./, not ../) const COMPONENT_DIRS = 'api|auth|class-colors|components|features/[a-z-]+|hooks|i18n' const DEEP_IMPORT_RE = new RegExp( String.raw`from\s+['"](?:\.\./)+(?:src/)?(?:${COMPONENT_DIRS})/[A-Za-z]`, ) // STC-ARCH-01 has no exemptions today. F3 (the classColors carry-over) was // closed by AZ-511 — the file moved to its own component (`src/class-colors/`) // with a proper barrel, and consumers now import via that barrel. const ARCH_IMPORTS_EXEMPT_RE = null const ARCH_IMPORTS_SCAN_ROOTS = ['src', 'tests', 'e2e'] // ---------------------------------------------------------------------------- // STC-ARCH-02 — hardcoded /api// literals (mode=api-literals) // ---------------------------------------------------------------------------- // Hardcoded API path pattern: a quote/backtick immediately followed by // `/api//`. 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 && 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// 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()