feat: bootstrap pnpm workspace (E00-S01)
CI / Workspace bootstrap checks (pull_request) Successful in 4m41s
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.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user