CI / Workspace bootstrap checks (pull_request) Successful in 4m41s
pnpm 11.23.0 workspace with a committed frozen lockfile, Node engine restricted to the 24.x line, TypeScript 6.0.3 pinned exactly, strict base tsconfig, apps/packages/extensions kept as separate workspaces, root build/test/typecheck commands, and the FIT-001 architecture test rejecting core-to-extension imports. Minimal CI runs only the root scripts (S05 stages stay out of scope). Closes #58.
146 lines
5.4 KiB
TypeScript
146 lines
5.4 KiB
TypeScript
/**
|
|
* FIT-001: no core package imports a concrete extension.
|
|
*
|
|
* Architecture guard (CI-enforced, see .gitea/workflows/ci.yml):
|
|
* - a "concrete extension" is any workspace package under extensions/
|
|
* - code under packages/ (the core packages) must never import one, neither
|
|
* by package name nor by a relative path that resolves into extensions/
|
|
*
|
|
* The rule is discovered from the filesystem, so adding an extension package
|
|
* under extensions/ automatically extends the guard; no manifest edits needed.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
const packagesRoot = join(repoRoot, 'packages');
|
|
const extensionsRoot = join(repoRoot, 'extensions');
|
|
|
|
interface ExtensionInfo {
|
|
/** Absolute path of the extension package directory. */
|
|
dir: string;
|
|
/** Package name from its manifest (e.g. "@personal-blog/example-extension"). */
|
|
name: string;
|
|
}
|
|
|
|
/** True when `candidate` is `parent` itself or lies inside it. */
|
|
function isWithin(parent: string, candidate: string): boolean {
|
|
const rel = relative(parent, candidate);
|
|
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
}
|
|
|
|
function findExtensionPackages(): ExtensionInfo[] {
|
|
if (!existsSync(extensionsRoot)) {
|
|
return [];
|
|
}
|
|
return readdirSync(extensionsRoot, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry): ExtensionInfo | null => {
|
|
const manifestPath = join(extensionsRoot, entry.name, 'package.json');
|
|
if (!existsSync(manifestPath)) {
|
|
return null;
|
|
}
|
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
|
|
name?: string;
|
|
};
|
|
return manifest.name ? { dir: join(extensionsRoot, entry.name), name: manifest.name } : null;
|
|
})
|
|
.filter((info): info is ExtensionInfo => info !== null);
|
|
}
|
|
|
|
/** Matches static imports, side-effect imports, dynamic imports and require. */
|
|
const IMPORT_SPECIFIER_RE = /(?:from\s*|import\s*\(\s*|require\s*\(\s*|import\s+)(['"])([^'"]+)\1/g;
|
|
|
|
function extractImportSpecifiers(source: string): string[] {
|
|
return [...source.matchAll(IMPORT_SPECIFIER_RE)]
|
|
.map((match) => match[2] ?? '')
|
|
.filter((specifier) => specifier.length > 0);
|
|
}
|
|
|
|
/** True when `specifier` targets a concrete extension from `importerFile`. */
|
|
function isExtensionImport(
|
|
specifier: string,
|
|
importerFile: string,
|
|
extensions: ExtensionInfo[],
|
|
): boolean {
|
|
for (const ext of extensions) {
|
|
if (specifier === ext.name || specifier.startsWith(`${ext.name}/`)) {
|
|
return true;
|
|
}
|
|
}
|
|
if (specifier.startsWith('.')) {
|
|
const resolved = resolve(dirname(importerFile), specifier);
|
|
if (isWithin(extensionsRoot, resolved)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function listSourceFiles(dir: string): string[] {
|
|
if (!existsSync(dir)) {
|
|
return [];
|
|
}
|
|
const files: string[] = [];
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...listSourceFiles(path));
|
|
} else if (entry.isFile() && /\.(?:[cm]?ts|tsx)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
|
|
files.push(path);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
describe('FIT-001: no core package imports a concrete extension', () => {
|
|
const extensions = findExtensionPackages();
|
|
|
|
it('finds at least one concrete extension package to check against', () => {
|
|
// Guards against a vacuous pass when extensions/ is empty.
|
|
expect(extensions.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('packages/* sources import no concrete extension', () => {
|
|
const violations: string[] = [];
|
|
for (const file of listSourceFiles(packagesRoot)) {
|
|
const source = readFileSync(file, 'utf8');
|
|
for (const specifier of extractImportSpecifiers(source)) {
|
|
if (isExtensionImport(specifier, file, extensions)) {
|
|
violations.push(
|
|
`${relative(repoRoot, file)}: import "${specifier}" targets concrete extension "${extensions
|
|
.filter((ext) => isExtensionImport(specifier, file, [ext]))
|
|
.map((ext) => ext.name)
|
|
.join(', ')}"`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
expect(violations).toEqual([]);
|
|
});
|
|
|
|
it('detector rejects extension imports and accepts benign imports (sanity)', () => {
|
|
const ext = extensions[0];
|
|
expect(ext, 'expected at least one extension package').toBeDefined();
|
|
if (!ext) {
|
|
return;
|
|
}
|
|
const importer = join(packagesRoot, 'core', 'src', 'index.ts');
|
|
|
|
// By package name (bare and subpath).
|
|
expect(isExtensionImport(ext.name, importer, extensions)).toBe(true);
|
|
expect(isExtensionImport(`${ext.name}/server`, importer, extensions)).toBe(true);
|
|
// By relative path resolving into extensions/.
|
|
expect(
|
|
isExtensionImport(relative(dirname(importer), join(ext.dir, 'src', 'index.js')), importer, extensions),
|
|
).toBe(true);
|
|
// Benign imports must pass.
|
|
expect(isExtensionImport('node:test', importer, extensions)).toBe(false);
|
|
expect(isExtensionImport('vitest', importer, extensions)).toBe(false);
|
|
expect(isExtensionImport('@personal-blog/core', importer, extensions)).toBe(false);
|
|
expect(isExtensionImport('./site.js', importer, extensions)).toBe(false);
|
|
});
|
|
});
|