Files
Genarrative/scripts/check-npm-workspaces.mjs
T
kdletters 33f5ad68bf
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 10m54s
Project CI / Backend tests (push) Successful in 12m42s
Project CI / Frontend tests (push) Successful in 13m4s
Project CI / AI game creator shell web tests (push) Successful in 5m1s
Project CI / Repository checks (push) Successful in 12m48s
接入 DotCraft Unity 编辑器插件与受控执行链路 (#423)
AGC 原有插件系统无法直接操作已打开的 Unity Editor。本变更增加内置 `agc-unity-editor`,在 Windows x64 / Unity Mono 上支持当前项目探测、连接与 C# 执行,不向 Unity 工程安装 UPM 桥接包。

## 主要变更

- 固定复用 DotCraft.Unity 0.4.3 的 Attach 核心,提供自包含 .NET helper,保留上游许可证、来源及修改记录。
- GUI、Runtime、DirectProject 共用 Runner 执行服务;补齐项目身份、并发、总期限、回执确认与持久不确定状态阻断。
- 现有打开项目入口支持 Unity,按项目类型及开关暴露插件和 Agent 工具。
- Windows 构建准备 helper 并随包分发;插件 JS/Rust 测试接入现有 CI 组,Jenkins 增加 .NET 10 工具链预检。

## 验证

- .NET helper 27 项测试、自包含发布及最小环境协议 smoke 通过。
- Unity 6000.3.7f1 实机验证通过:连接、C# 执行、编译错误修复、断连重连、Domain Reload 后重新握手;真实 Runner 的 ACK、并发拒绝和跨重启阻断通过。
- 宿主 Unity、PluginHost、Cocos、MCP、工具目录与引擎识别定向回归通过;前端类型检查、插件 JS/Rust、CI 配置、格式、编码和文档门禁通过。

Linux CI 不代替 Windows helper/实机验证;发行安装包 UI smoke、其它 Unity 版本和 Unity CoreCLR 未验证。Unity 演示工程中的场景和组件已撤销,不在此 PR 范围内。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/423
2026-09-19 12:29:27 +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;
}
}