diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..46043f8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + pull_request: + push: + branches: [main, develop] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm boundary + - run: pnpm -r typecheck + - run: pnpm -r build + - run: pnpm test diff --git a/scripts/check-import-boundary.mjs b/scripts/check-import-boundary.mjs new file mode 100644 index 0000000..4324bbc --- /dev/null +++ b/scripts/check-import-boundary.mjs @@ -0,0 +1,81 @@ +/** + * Import-boundary law (Bottom-Up §06 dependency law; Critics §12). + * + * Layers, low → high: contracts < testkit < service. A LOWER layer must never + * import a HIGHER one. This guards the "no specialization leaks into the kernel" + * rule from day one. Runnable as `pnpm boundary` (CI) and imported by a test. + */ +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +/** Lower rank = lower layer. A package may only import packages of <= its rank. */ +const RANK = { + '@insignia/iios-contracts': 0, + '@insignia/iios-testkit': 1, + '@insignia/iios-service': 2, +}; + +function listTsFiles(dir) { + if (!existsSync(dir)) return []; + const out = []; + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === 'dist') continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...listTsFiles(full)); + else if (full.endsWith('.ts')) out.push(full); + } + return out; +} + +function packageOf(importPath) { + return Object.keys(RANK).find((k) => importPath === k || importPath.startsWith(k + '/')); +} + +export function findBoundaryViolations() { + const pkgsDir = join(ROOT, 'packages'); + const violations = []; + if (!existsSync(pkgsDir)) return violations; + + for (const pkg of readdirSync(pkgsDir)) { + const pkgJsonPath = join(pkgsDir, pkg, 'package.json'); + if (!existsSync(pkgJsonPath)) continue; + const name = JSON.parse(readFileSync(pkgJsonPath, 'utf8')).name; + const ownRank = RANK[name]; + if (ownRank === undefined) continue; + + // Catch every module-specifier form: `from '…'`, side-effect `import '…'`, + // dynamic `import('…')`, and `require('…')`. + const patterns = [ + /from\s*['"](@insignia\/iios-[^'"]+)['"]/g, + /import\s*['"](@insignia\/iios-[^'"]+)['"]/g, + /import\s*\(\s*['"](@insignia\/iios-[^'"]+)['"]/g, + /require\(\s*['"](@insignia\/iios-[^'"]+)['"]/g, + ]; + for (const file of listTsFiles(join(pkgsDir, pkg, 'src'))) { + const content = readFileSync(file, 'utf8'); + for (const re of patterns) { + let m; + while ((m = re.exec(content))) { + const targetPkg = packageOf(m[1]); + if (targetPkg && RANK[targetPkg] > ownRank) { + violations.push({ file: file.replace(ROOT + '/', ''), from: name, imports: targetPkg }); + } + } + } + } + } + return violations; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const violations = findBoundaryViolations(); + if (violations.length > 0) { + console.error('✗ import-boundary violations (a lower layer imports a higher one):'); + for (const v of violations) console.error(` ${v.file}: ${v.from} → ${v.imports}`); + process.exit(1); + } + console.log('✓ import-boundary: OK (no lower→higher imports)'); +} diff --git a/test/import-boundary.test.ts b/test/import-boundary.test.ts new file mode 100644 index 0000000..bea94c4 --- /dev/null +++ b/test/import-boundary.test.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from 'vitest'; +// @ts-expect-error — plain ESM script, no type declarations +import { findBoundaryViolations } from '../scripts/check-import-boundary.mjs'; + +describe('import-boundary law', () => { + it('no lower layer imports a higher layer (contracts < testkit < service)', () => { + expect(findBoundaryViolations()).toEqual([]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 58019ef..c6fdefe 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,6 @@ export default defineConfig({ }, }, test: { - include: ['packages/**/src/**/*.{test,spec}.ts'], + include: ['packages/**/src/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts'], }, });