Files
Genarrative/scripts/check-npm-workspaces.mjs
T
kdletters 3fc3399032
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m41s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m8s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m16s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m38s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m11s
Project CI / Repository checks (pull_request) Successful in 4m22s
Project CI / Frontend tests (pull_request) Successful in 5m52s
Project CI / Native shell tests (pull_request) Successful in 9m52s
Project CI / Backend tests (pull_request) Successful in 11m3s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m41s
接入 DotCraft Unity 编辑器插件
新增内置 Unity 插件与自包含 Attach helper,固定上游版本并保留许可证
统一 Runner 执行归属、项目身份校验、回执确认和不确定结果阻断
接入 Unity 工程导入、Agent 工具目录及 Windows 构建分发
补齐插件测试、CI 执行入口和 Windows .NET 10 工具链检查
记录真实 Unity 连接、执行、重载恢复及 Runner 边界验证
2026-09-18 19:44:36 +08:00

308 lines
9.1 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
export const REQUIRED_PACKAGE_MANAGER = 'npm@10.9.7';
export const REQUIRED_WORKSPACES = Object.freeze([
'apps/admin-web',
'apps/ai-game-creator-shell',
'apps/desktop-shell',
'apps/mobile-shell',
'apps/preview-deployer-web',
'packages/image-canvas-core',
'packages/image-canvas-react',
'packages/agc-plugin-sdk',
'packages/shared',
'plugins/agc-cocos-editor',
'plugins/agc-unity-editor',
'tools/spine-json-export-validator',
]);
const WORKSPACE_NAMES = Object.freeze({
'apps/admin-web': '@genarrative/admin-web',
'apps/ai-game-creator-shell': '@genarrative/ai-game-creator-shell',
'apps/desktop-shell': '@genarrative/desktop-shell',
'apps/mobile-shell': '@genarrative/mobile-shell',
'apps/preview-deployer-web': '@genarrative/preview-deployer-web',
'packages/image-canvas-core': '@genarrative/image-canvas-core',
'packages/image-canvas-react': '@genarrative/image-canvas-react',
'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk',
'packages/shared': '@genarrative/shared',
'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor',
'plugins/agc-unity-editor': '@genarrative/agc-plugin-unity-editor',
'tools/spine-json-export-validator':
'@genarrative/spine-json-export-validator',
});
const REQUIRED_LOCAL_DEPENDENCIES = Object.freeze({
'package.json': [
'@genarrative/image-canvas-core',
'@genarrative/image-canvas-react',
'@genarrative/shared',
],
'apps/admin-web/package.json': ['@genarrative/shared'],
'apps/ai-game-creator-shell/package.json': [
'@genarrative/image-canvas-core',
'@genarrative/image-canvas-react',
'@genarrative/shared',
],
'apps/mobile-shell/package.json': ['@genarrative/shared'],
'packages/image-canvas-react/package.json': [
'@genarrative/image-canvas-core',
],
'plugins/agc-cocos-editor/package.json': ['@genarrative/agc-plugin-sdk'],
'plugins/agc-unity-editor/package.json': ['@genarrative/agc-plugin-sdk'],
});
const DEPENDENCY_FIELDS = Object.freeze([
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
]);
const IGNORED_NESTED_DIRECTORIES = new Set([
'.git',
'.vite',
'build',
'dist',
'node_modules',
'target',
]);
const HOIST_SENSITIVE_CONFIGS = Object.freeze([
'vite.config.ts',
'vitest.config.ts',
'apps/ai-game-creator-shell/vite.config.ts',
]);
const FORBIDDEN_WORKSPACE_NODE_MODULES_PATH =
'apps/ai-game-creator-shell/node_modules/';
function readJson(rootDir, relativePath, errors) {
const absolutePath = path.join(rootDir, relativePath);
try {
return JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
} catch (error) {
errors.push(`${relativePath}: cannot read valid JSON (${error.message})`);
return null;
}
}
function normalizeWorkspaceLink(value) {
return typeof value === 'string'
? value.replace(/^\.\//u, '').replaceAll('\\', '/')
: value;
}
function findNestedLockfiles(rootDir, workspacePath) {
const matches = [];
const pending = [path.join(rootDir, workspacePath)];
while (pending.length > 0) {
const directory = pending.pop();
let entries;
try {
entries = fs.readdirSync(directory, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.isSymbolicLink()) {
continue;
}
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (!IGNORED_NESTED_DIRECTORIES.has(entry.name)) {
pending.push(entryPath);
}
continue;
}
if (
entry.name === 'package-lock.json' ||
entry.name === 'npm-shrinkwrap.json'
) {
matches.push(path.relative(rootDir, entryPath).replaceAll('\\', '/'));
}
}
}
return matches.sort();
}
export function collectNpmWorkspaceErrors(rootDir) {
const errors = [];
const rootPackage = readJson(rootDir, 'package.json', errors);
const lockfile = readJson(rootDir, 'package-lock.json', errors);
if (!rootPackage || !lockfile) {
return errors;
}
for (const configPath of HOIST_SENSITIVE_CONFIGS) {
const absolutePath = path.join(rootDir, configPath);
if (
fs.existsSync(absolutePath) &&
fs
.readFileSync(absolutePath, 'utf8')
.includes(FORBIDDEN_WORKSPACE_NODE_MODULES_PATH)
) {
errors.push(
`${configPath}: resolve dependencies from the workspace manifest instead of hard-coding ${FORBIDDEN_WORKSPACE_NODE_MODULES_PATH}`,
);
}
}
if (rootPackage.packageManager !== REQUIRED_PACKAGE_MANAGER) {
errors.push(
`package.json: packageManager must be ${REQUIRED_PACKAGE_MANAGER}, received ${String(rootPackage.packageManager)}`,
);
}
if (
JSON.stringify(rootPackage.workspaces) !==
JSON.stringify(REQUIRED_WORKSPACES)
) {
errors.push(
`package.json: workspaces must be the explicit ordered list ${JSON.stringify(REQUIRED_WORKSPACES)}`,
);
}
const manifests = new Map([['package.json', rootPackage]]);
for (const workspacePath of REQUIRED_WORKSPACES) {
const manifestPath = `${workspacePath}/package.json`;
const manifest = readJson(rootDir, manifestPath, errors);
if (!manifest) {
continue;
}
manifests.set(manifestPath, manifest);
if (manifest.name !== WORKSPACE_NAMES[workspacePath]) {
errors.push(
`${manifestPath}: name must be ${WORKSPACE_NAMES[workspacePath]}, received ${String(manifest.name)}`,
);
}
if (workspacePath === 'apps/ai-game-creator-shell') {
if (!/^\d+\.\d+\.\d+$/u.test(manifest.version ?? '')) {
errors.push(
`${manifestPath}: workspace version must be a three-part semver`,
);
}
} else if (manifest.version !== '0.1.0') {
errors.push(`${manifestPath}: workspace version must be 0.1.0`);
}
for (const nestedLockfile of findNestedLockfiles(rootDir, workspacePath)) {
errors.push(
`${nestedLockfile}: nested npm lockfiles are forbidden inside workspaces`,
);
}
}
const localPackageNames = new Set(Object.values(WORKSPACE_NAMES));
for (const [manifestPath, manifest] of manifests) {
for (const dependencyField of DEPENDENCY_FIELDS) {
for (const [dependencyName, dependencySpec] of Object.entries(
manifest[dependencyField] ?? {},
)) {
if (
typeof dependencySpec === 'string' &&
dependencySpec.startsWith('workspace:')
) {
errors.push(
`${manifestPath}: ${dependencyField}.${dependencyName} must not use the unsupported workspace: protocol`,
);
}
if (
localPackageNames.has(dependencyName) &&
dependencySpec !== '0.1.0'
) {
errors.push(
`${manifestPath}: ${dependencyField}.${dependencyName} must use exact version 0.1.0`,
);
}
}
}
}
for (const [manifestPath, requiredDependencies] of Object.entries(
REQUIRED_LOCAL_DEPENDENCIES,
)) {
const manifest = manifests.get(manifestPath);
if (!manifest) {
continue;
}
for (const dependencyName of requiredDependencies) {
if (manifest.dependencies?.[dependencyName] !== '0.1.0') {
errors.push(
`${manifestPath}: dependencies.${dependencyName} must be declared as exact version 0.1.0`,
);
}
}
}
const lockPackages = lockfile.packages;
if (!lockPackages || typeof lockPackages !== 'object') {
errors.push('package-lock.json: packages map is required');
return errors;
}
if (
JSON.stringify(lockPackages['']?.workspaces) !==
JSON.stringify(REQUIRED_WORKSPACES)
) {
errors.push(
'package-lock.json: root package entry must contain the explicit workspace list',
);
}
for (const workspacePath of REQUIRED_WORKSPACES) {
const expectedName = WORKSPACE_NAMES[workspacePath];
const workspaceEntry = lockPackages[workspacePath];
if (!workspaceEntry || workspaceEntry.name !== expectedName) {
errors.push(
`package-lock.json: packages[${JSON.stringify(workspacePath)}] must describe ${expectedName}`,
);
}
const linkKey = `node_modules/${expectedName}`;
const linkEntry = lockPackages[linkKey];
if (
!linkEntry ||
linkEntry.link !== true ||
normalizeWorkspaceLink(linkEntry.resolved) !== workspacePath
) {
errors.push(
`package-lock.json: packages[${JSON.stringify(linkKey)}] must link to ${workspacePath}`,
);
}
}
return errors;
}
export function checkNpmWorkspaces(rootDir) {
const errors = collectNpmWorkspaceErrors(rootDir);
if (errors.length > 0) {
throw new Error(
`npm workspace validation failed:\n- ${errors.join('\n- ')}`,
);
}
}
const isMainModule =
process.argv[1] &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (isMainModule) {
const rootDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
try {
checkNpmWorkspaces(rootDir);
console.log('[check:npm-workspaces] OK');
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}