#!/usr/bin/env node // AZ-485 / F4 — STC-ARCH-01: 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. // // 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). // // Usage: // node scripts/check-arch-imports.mjs [--root=] // // Exit code 0 on PASS (no offending deep imports); 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) function parseArgs(argv) { const out = { root: resolve(__dirname, '..') } for (const a of argv.slice(2)) { if (a.startsWith('--root=')) out.root = resolve(a.slice('--root='.length)) else if (a === '-h' || a === '--help') { process.stderr.write('Usage: check-arch-imports.mjs [--root=]\n') process.exit(0) } } 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']) // 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|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: // - `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 EXEMPT_RE = /features\/annotations\/classColors/ 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 scanFile(file, root) { const hits = [] let text try { text = readFileSync(file, 'utf8') } catch { 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 hits.push(`${rel}:${i + 1}: ${line.trim().slice(0, 200)}`) } return hits } function main() { const { root } = parseArgs(process.argv) const hits = [] for (const sub of SCAN_ROOTS) { const full = join(root, sub) try { statSync(full) } catch { continue } for (const file of walkSourceFiles(full)) { hits.push(...scanFile(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', ) for (const h of hits) process.stderr.write(` ${h}\n`) process.exit(1) } process.exit(0) } main()