ci: import-boundary law + CI workflow (P0 complete)
Boundary script enforces contracts<testkit<service (catches from/side-effect/ dynamic/require imports); wired into pnpm test + CI. CI: install --frozen-lockfile -> boundary -> typecheck -> build -> test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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)');
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -13,6 +13,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ['packages/**/src/**/*.{test,spec}.ts'],
|
include: ['packages/**/src/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user