'use strict'; const fs = require('fs'); const path = require('path'); const { listAssets, queryAssetInfo } = require('../assets'); const { resolveProjectPath } = require('../path-safety'); const UUID_KEY_PATTERN = /uuid|assetUuid|prefabUuid|sceneUuid|__uuid__/i; const UUID_LITERAL_PATTERN = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z0-9+/=-]{20,32}/g; function assetUrlToPath(projectPath, url) { if (!url || !String(url).startsWith('db://assets/')) { return ''; } return path.join(projectPath, String(url).slice('db://'.length)); } function assetFilePath(projectPath, info) { const candidates = [ info && info.file, info && info.path, info && info.source, info && info.url ? assetUrlToPath(projectPath, info.url) : '', ].filter(Boolean); for (const candidate of candidates) { const fullPath = resolveProjectPath(projectPath, candidate); if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) { return fullPath; } } return ''; } function collectStructuredUuidReferences(value, refs = [], pointer = '') { if (value == null) { return refs; } if (Array.isArray(value)) { value.forEach((item, index) => collectStructuredUuidReferences(item, refs, `${pointer}/${index}`)); return refs; } if (typeof value !== 'object') { return refs; } for (const [key, child] of Object.entries(value)) { const childPointer = `${pointer}/${key}`; if (typeof child === 'string' && UUID_KEY_PATTERN.test(key)) { refs.push({ uuid: child, path: childPointer, key, source: 'structured' }); continue; } collectStructuredUuidReferences(child, refs, childPointer); } return refs; } function collectTextUuidReferences(text) { const refs = []; const seen = new Set(); let match; while ((match = UUID_LITERAL_PATTERN.exec(String(text || '')))) { const uuid = match[0]; if (seen.has(uuid)) { continue; } seen.add(uuid); refs.push({ uuid, path: `@${match.index}`, key: '', source: 'text' }); } return refs; } function dedupeReferences(refs) { const seen = new Set(); const result = []; for (const ref of refs) { const key = `${ref.uuid}:${ref.path}`; if (seen.has(key)) { continue; } seen.add(key); result.push(ref); } return result; } function collectUuidReferences(content) { const refs = []; try { refs.push(...collectStructuredUuidReferences(JSON.parse(content))); } catch (error) { // Non-JSON assets still get a literal reference scan below. } refs.push(...collectTextUuidReferences(content)); return dedupeReferences(refs); } async function inspectAssetDependencies(projectPath, target, options = {}) { const info = await queryAssetInfo(target); const isDirectory = info && (info.isDirectory || info.type === 'folder' || (info.url && !path.extname(info.url))); const filePath = assetFilePath(projectPath, info); if (!filePath) { if (isDirectory) { throw new Error(`Cannot inspect dependencies of a directory asset: ${target}. Please specify a file asset (e.g. a .prefab, .scene, or .mat file).`); } throw new Error(`Asset file was not found: ${target}`); } const content = fs.readFileSync(filePath, 'utf8'); const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(500, options.limit)) : 200; const references = collectUuidReferences(content).slice(0, limit); const dependencies = []; const missing = []; for (const ref of references) { try { const asset = await queryAssetInfo(ref.uuid); dependencies.push({ ...ref, exists: true, asset: { uuid: asset.uuid, url: asset.url, type: asset.type, importer: asset.importer, }, }); } catch (error) { missing.push({ ...ref, exists: false, error: error.message }); } } return { ok: missing.length === 0, target, asset: { uuid: info.uuid, url: info.url, type: info.type, }, filePath: path.relative(projectPath, filePath).replace(/\\/g, '/'), referenceCount: references.length, dependencyCount: dependencies.length, missingCount: missing.length, dependencies, missing, }; } async function validateAssetDependencies(projectPath, options = {}) { const targets = options.target ? [options.target] : (await listAssets({ pattern: options.pattern || 'db://assets/**', ccType: options.ccType })) .slice(0, Number.isFinite(options.limit) ? Math.max(1, Math.min(200, options.limit)) : 50) .map((asset) => asset.uuid || asset.url) .filter(Boolean); const assets = []; for (const target of targets) { try { assets.push(await inspectAssetDependencies(projectPath, target, options)); } catch (error) { assets.push({ ok: false, target, error: error.message, missingCount: 1, }); } } const missingCount = assets.reduce((sum, asset) => sum + (Number(asset.missingCount) || 0), 0); return { ok: missingCount === 0, assetCount: assets.length, missingCount, assets, }; } function createAssetsAdvancedTools({ createSchema, getRuntimeContext }) { return [ { name: 'inspect_asset_dependencies', profile: 'core', description: '[specialist] Inspect UUID-style dependencies referenced by a serialized Cocos asset.', inputSchema: createSchema( { target: { type: 'string', description: 'Asset uuid, db url, or project path.' }, limit: { type: 'number', description: 'Maximum dependency references to inspect.' }, }, ['target'] ), handler: async (args) => { const { projectPath } = getRuntimeContext(); return await inspectAssetDependencies(projectPath, args.target, args); }, }, { name: 'validate_asset_dependencies', profile: 'core', description: '[specialist] Validate UUID-style dependencies for one asset or a project asset query.', inputSchema: createSchema( { target: { type: 'string', description: 'Optional asset uuid, db url, or project path.' }, pattern: { type: 'string', description: 'Asset-db pattern used when target is omitted.' }, ccType: { type: 'string', description: 'Optional Cocos asset type filter.' }, limit: { type: 'number', description: 'Maximum assets to scan when target is omitted.' }, }, [] ), handler: async (args) => { const { projectPath } = getRuntimeContext(); return await validateAssetDependencies(projectPath, args); }, }, { name: 'batch_asset_ops', profile: 'full', description: '[core] Batch import or delete assets in the Cocos asset database.', inputSchema: createSchema( { action: { type: 'string', enum: ['import', 'delete'], description: 'Batch operation.', }, sourceDirectory: { type: 'string', description: 'Local filesystem source path (import only).', }, targetDirectory: { type: 'string', description: 'Asset-db target URL, e.g. db://assets/textures (import only).', }, urls: { type: 'array', items: { type: 'string' }, description: 'Asset URLs to delete (delete only).', }, }, ['action'] ), handler: async (args) => { const action = String(args.action || '').trim(); if (action === 'import') { const sourceDirectory = String(args.sourceDirectory || '').trim(); const targetDirectory = String(args.targetDirectory || '').trim(); if (!sourceDirectory) { throw new Error('sourceDirectory is required for action: import.'); } if (!targetDirectory) { throw new Error('targetDirectory is required for action: import.'); } if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') { throw new Error('Editor.Message.request is unavailable in the Cocos extension host.'); } const result = await Editor.Message.request('asset-db', 'import-asset', sourceDirectory, targetDirectory); return { action, sourceDirectory, targetDirectory, result }; } if (action === 'delete') { const urls = Array.isArray(args.urls) ? args.urls.filter(Boolean) : []; if (urls.length === 0) { throw new Error('urls is required for action: delete.'); } if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') { throw new Error('Editor.Message.request is unavailable in the Cocos extension host.'); } const results = []; for (const url of urls) { try { await Editor.Message.request('asset-db', 'delete-asset', url); results.push({ url, deleted: true }); } catch (error) { results.push({ url, deleted: false, error: error.message }); } } return { action, urls, results }; } throw new Error(`Unknown action: ${action}. Must be one of: import, delete.`); }, }, { name: 'find_unused_assets', profile: 'full', description: '[core] Find assets not referenced by any scene, prefab, or animation in the project.', inputSchema: createSchema( { directory: { type: 'string', description: 'Asset-db directory to scan. Default: db://assets.', }, excludeDirectories: { type: 'array', items: { type: 'string' }, description: 'Directories to exclude from the reference scan.', }, assetTypes: { type: 'array', items: { type: 'string' }, description: 'Filter by Cocos asset type, e.g. ["cc.Texture2D", "cc.AudioClip"].', }, limit: { type: 'number', description: 'Max results. Default 200.' }, }, [] ), handler: async (args) => { const { projectPath } = getRuntimeContext(); return await findUnusedAssets(projectPath, args); }, }, ]; } const SERIALIZED_EXTENSIONS = ['.scene', '.prefab', '.anim', '.mat']; const SKIP_UNUSED_EXTENSIONS = ['.ts', '.scene']; async function findUnusedAssets(projectPath, options = {}, listAssetsFn) { const listAssetsRef = typeof listAssetsFn === 'function' ? listAssetsFn : listAssets; const directory = options.directory || 'db://assets'; const excludeDirs = Array.isArray(options.excludeDirectories) ? options.excludeDirectories : []; const assetTypes = Array.isArray(options.assetTypes) ? options.assetTypes : undefined; const limit = Number.isFinite(options.limit) ? Math.max(1, options.limit) : 200; const allAssets = await listAssetsRef({ pattern: directory + '/**', ccType: assetTypes, }); const referencedUuids = new Set(); const assetsDir = path.join(projectPath, 'assets'); function scanDir(dir) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === 'node_modules' || entry.name === 'temp' || entry.name === 'library') { continue; } const relativePath = path.relative(assetsDir, fullPath).replace(/\\/g, '/'); if (excludeDirs.some((excl) => relativePath.startsWith(String(excl)))) { continue; } scanDir(fullPath); } else { const ext = path.extname(entry.name).toLowerCase(); if (!SERIALIZED_EXTENSIONS.includes(ext)) { continue; } try { const content = fs.readFileSync(fullPath, 'utf8'); const refs = collectUuidReferences(content); for (const ref of refs) { referencedUuids.add(ref.uuid); } } catch (_) { // Skip unreadable files } } } } scanDir(assetsDir); const unused = allAssets.filter((asset) => { if (!asset || !asset.uuid) { return false; } if (asset.isDirectory) { return false; } const url = asset.url || asset.source || ''; const ext = path.extname(url).toLowerCase(); if (SKIP_UNUSED_EXTENSIONS.includes(ext)) { return false; } return !referencedUuids.has(asset.uuid); }); return { totalScanned: allAssets.length, unusedCount: unused.length, referencedCount: referencedUuids.size, unused: unused.slice(0, limit), }; } module.exports = { collectUuidReferences, createAssetsAdvancedTools, findUnusedAssets, inspectAssetDependencies, validateAssetDependencies, };