You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
80 lines
3.0 KiB
TypeScript
80 lines
3.0 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
/**
|
|
* Bundle isolation: the store apps (store-server / pos / backoffice) and the HQ
|
|
* apps (hq / hq-web) are separate deliverables that must never import each other.
|
|
* A static, dependency-free scan of their src/ trees enforces the wall — shared
|
|
* @sims/* packages are the only sanctioned bridge. See docs/15 (D15 one-repo/two-apps).
|
|
*/
|
|
|
|
const APPS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
|
|
const STORE_APPS = ['store-server', 'pos', 'backoffice']
|
|
const HQ_APPS = ['hq', 'hq-web']
|
|
|
|
/** Every .ts/.tsx file under apps/<name>/src, recursively. */
|
|
function sourceFiles(app: string): string[] {
|
|
const root = path.join(APPS_DIR, app, 'src')
|
|
const out: string[] = []
|
|
const walk = (dir: string): void => {
|
|
if (!fs.existsSync(dir)) return
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) walk(full)
|
|
else if (/\.tsx?$/.test(entry.name)) out.push(full)
|
|
}
|
|
}
|
|
walk(root)
|
|
return out
|
|
}
|
|
|
|
/** Static import / re-export / dynamic-import / require specifiers in a source file. */
|
|
function importSpecifiers(file: string): string[] {
|
|
const src = fs.readFileSync(file, 'utf8')
|
|
const re = /\b(?:from|import|require)\b\s*\(?\s*['"]([^'"]+)['"]/g
|
|
const specs: string[] = []
|
|
let m: RegExpExecArray | null
|
|
while ((m = re.exec(src)) !== null) specs.push(m[1]!)
|
|
return specs
|
|
}
|
|
|
|
/** Does `spec`, imported from `file`, reach into one of the forbidden app trees? */
|
|
function crossesInto(file: string, spec: string, forbiddenDirs: string[], forbiddenNames: string[]): boolean {
|
|
if (spec.startsWith('.')) {
|
|
const resolved = path.resolve(path.dirname(file), spec)
|
|
return forbiddenDirs.some((d) => resolved === d || resolved.startsWith(d + path.sep))
|
|
}
|
|
// Bare specifiers: @sims/* and third-party deps are fine; an explicit apps/<other> path is not.
|
|
return forbiddenNames.some((name) => spec.includes(`apps/${name}`) || spec.includes(`apps\\${name}`))
|
|
}
|
|
|
|
function violations(fromApps: string[], toApps: string[]): string[] {
|
|
const forbiddenDirs = toApps.map((a) => path.join(APPS_DIR, a))
|
|
const found: string[] = []
|
|
for (const app of fromApps) {
|
|
for (const file of sourceFiles(app)) {
|
|
for (const spec of importSpecifiers(file)) {
|
|
if (crossesInto(file, spec, forbiddenDirs, toApps)) {
|
|
found.push(`${path.relative(APPS_DIR, file)} imports '${spec}'`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
describe('bundle isolation between store apps and HQ apps', () => {
|
|
it('scans a non-empty set of source files (guards against a broken path)', () => {
|
|
expect([...STORE_APPS, ...HQ_APPS].flatMap(sourceFiles).length).toBeGreaterThan(0)
|
|
})
|
|
it('store apps never import HQ apps', () => {
|
|
expect(violations(STORE_APPS, HQ_APPS)).toEqual([])
|
|
})
|
|
it('HQ apps never import store apps', () => {
|
|
expect(violations(HQ_APPS, STORE_APPS)).toEqual([])
|
|
})
|
|
})
|