Files
Genarrative/scripts/check-npm-workspaces.mjs
T
k88936 10e63ecb6b 合并 origin/master 到 feat/tribo3d-integeration:ts-rs 一律常开、Cargo 清单去重
- server-rs/Cargo.toml:删除与 workspace git 固定重复的 registry `ts-rs = "12.0.1"`,保留固定 commit 并写明「ts-rs 一律常开、绑定命令固定为 cargo test -p shared-contracts export_bindings」
- server-rs/crates/shared-contracts/Cargo.toml:删除 master 侧新增的 `ts-bindings` feature,`ts-rs` 保持常开依赖
- server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs:去掉 `cfg_attr(feature = "ts-bindings", …)` 门控,derive 与字段级 `ts(...)` 无条件展开
- apps/ai-game-creator-shell/src-tauri/Cargo.toml:shared-contracts 依赖去掉 `features = ["ts-bindings"]`,继续保留本分支的 ts-rs git 固定
- apps/ai-game-creator-shell/src-tauri/Cargo.lock:按上述依赖变更同步(shared-contracts 指向 git 源 ts-rs)
- docs:同步 5 处绑定生成命令(去掉 `--features ts-bindings`),并在 decision-log 记本次合并的决策、代价与验证
- 其余暂存改动为 origin/master 带入的内容,冲突按两侧目的逐一合并

验证:cargo metadata --locked(server-rs 与 AGC 两个 workspace)、cargo test -p shared-contracts(119 + 5 + 5 + 2 + 2 全绿且无告警)、npm run contracts:model3d:generate 后 packages/shared 零 diff、cargo check -p spacetime-module --target wasm32-unknown-unknown、npm run check:encoding、npm run check:rustfmt、npm run check:spacetime-schema、git diff --check、暂存集 eslint / prettier 全过
2026-09-22 13:12:28 +08:00

313 lines
9.3 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/model3d-viewer',
'packages/shared',
'plugins/agc-cocos-editor',
'plugins/agc-unity-editor',
'plugins/agc-godot-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/model3d-viewer': '@genarrative/model3d-viewer',
'packages/shared': '@genarrative/shared',
'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor',
'plugins/agc-unity-editor': '@genarrative/agc-plugin-unity-editor',
'plugins/agc-godot-editor': '@genarrative/agc-plugin-godot-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'],
'plugins/agc-godot-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;
}
}