296 lines
11 KiB
JavaScript
296 lines
11 KiB
JavaScript
import { classifyRisk, createCanonicalLocator, detectMediaKind, inferOwnerHint } from "./path-utils.js";
|
|
|
|
function compareOwner(sourceScope, targetOwner) {
|
|
if (!sourceScope) return "unknown";
|
|
if ((sourceScope.ownerType === "world") && (targetOwner.ownerType === "system") && (sourceScope.systemId === targetOwner.ownerId)) {
|
|
return "same-system";
|
|
}
|
|
if ((sourceScope.ownerType === "world") && (targetOwner.ownerType === "public") &&
|
|
["cards", "icons", "nue", "sounds", "ui"].includes(targetOwner.ownerId)) {
|
|
return "core-public";
|
|
}
|
|
if ((sourceScope.ownerType === "module") && (targetOwner.ownerType === "module")) {
|
|
return sourceScope.ownerId === targetOwner.ownerId ? "same-package" : "foreign-module";
|
|
}
|
|
if ((sourceScope.ownerType === "system") && (targetOwner.ownerType === "system")) {
|
|
return sourceScope.ownerId === targetOwner.ownerId ? "same-package" : "cross-package";
|
|
}
|
|
if ((sourceScope.ownerType === "world") && (targetOwner.ownerType === "world")) {
|
|
return sourceScope.ownerId === targetOwner.ownerId ? "same-world" : "cross-world";
|
|
}
|
|
if ((sourceScope.ownerType === "world") && ((targetOwner.ownerType === "module") || (targetOwner.ownerType === "system"))) {
|
|
return "non-package-to-package";
|
|
}
|
|
if ((sourceScope.ownerType === "world") && (targetOwner.ownerType === "public")) {
|
|
return "risky-public";
|
|
}
|
|
if (((sourceScope.ownerType === "module") || (sourceScope.ownerType === "system")) &&
|
|
((targetOwner.ownerType === "module") || (targetOwner.ownerType === "system")) &&
|
|
((sourceScope.ownerType !== targetOwner.ownerType) || (sourceScope.ownerId !== targetOwner.ownerId))) {
|
|
return "cross-package";
|
|
}
|
|
return "allowed";
|
|
}
|
|
|
|
export function buildFindings({ files, references, i18n, packageActivity }={}) {
|
|
const fileByLocator = new Map(files.map(file => [createCanonicalLocator(file.storage, file.path), file]));
|
|
const fileLocators = files.map(file => ({
|
|
storage: file.storage,
|
|
path: file.path,
|
|
locator: createCanonicalLocator(file.storage, file.path)
|
|
}));
|
|
const resolvedReferences = references.map(reference => resolveReferenceTarget(reference, fileByLocator, fileLocators));
|
|
const refsByLocator = new Map();
|
|
const wildcardReferences = [];
|
|
const findings = [];
|
|
const inactiveModuleReferenceIds = new Set();
|
|
const missingModuleReferenceIds = new Set();
|
|
|
|
for (const reference of resolvedReferences) {
|
|
const normalized = reference.normalized;
|
|
if (!normalized) continue;
|
|
if (normalized.targetKind === "wildcard") {
|
|
wildcardReferences.push(reference);
|
|
continue;
|
|
}
|
|
const normalizedLocator = createCanonicalLocator(normalized.storage, normalized.path);
|
|
const bucket = refsByLocator.get(normalizedLocator) ?? [];
|
|
bucket.push(reference);
|
|
refsByLocator.set(normalizedLocator, bucket);
|
|
}
|
|
|
|
for (const reference of resolvedReferences) {
|
|
const normalized = reference.normalized;
|
|
if (!normalized) continue;
|
|
const normalizedLocator = createCanonicalLocator(normalized.storage, normalized.path);
|
|
|
|
const file = fileByLocator.get(normalizedLocator);
|
|
if (!file) {
|
|
if ((normalized.targetKind === "wildcard") && wildcardMatchesAny(normalized, fileLocators)) continue;
|
|
if (reference.sourceScope?.ownerType !== "world") continue;
|
|
findings.push({
|
|
kind: "broken-reference",
|
|
severity: reference.sourceScope?.ownerType === "world" ? "high" : "warning",
|
|
target: { ...normalized, locator: normalizedLocator },
|
|
source: reference,
|
|
reason: normalized.targetKind === "wildcard"
|
|
? format(i18n, "KSA.FindingReason.BrokenWildcard", { locator: normalized.locator })
|
|
: format(i18n, "KSA.FindingReason.BrokenReference", { locator: normalized.locator }),
|
|
recommendation: normalized.targetKind === "wildcard"
|
|
? format(i18n, "KSA.FindingRecommendation.CheckWildcard")
|
|
: format(i18n, "KSA.FindingRecommendation.CheckMissingFile"),
|
|
confidence: "high"
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const ownerRelation = compareOwner(reference.sourceScope, file.ownerHint);
|
|
if (ownerRelation === "non-package-to-package") {
|
|
if (isMissingModuleTarget(file, packageActivity)) {
|
|
missingModuleReferenceIds.add(file.ownerHint.ownerId);
|
|
continue;
|
|
}
|
|
if (isInactiveModuleTarget(file, packageActivity)) {
|
|
inactiveModuleReferenceIds.add(file.ownerHint.ownerId);
|
|
continue;
|
|
}
|
|
if (isAnchoredInOwningPackage(file, matchingReferencesForFile(file, refsByLocator, wildcardReferences))) continue;
|
|
findings.push({
|
|
kind: "non-package-to-package-reference",
|
|
severity: "high",
|
|
target: { ...normalized, locator: normalizedLocator },
|
|
source: reference,
|
|
reason: format(i18n, "KSA.FindingReason.UnanchoredPackageTarget", {
|
|
sourceOwner: `${reference.sourceScope.ownerType}:${reference.sourceScope.ownerId}`,
|
|
locator: normalized.locator
|
|
}),
|
|
recommendation: format(i18n, "KSA.FindingRecommendation.MoveToStableStorage"),
|
|
confidence: "high"
|
|
});
|
|
} else if (ownerRelation === "risky-public") {
|
|
if (reference.sourceScope?.ownerType !== "world") continue;
|
|
findings.push({
|
|
kind: "risky-public-reference",
|
|
severity: "high",
|
|
target: { ...normalized, locator: normalizedLocator },
|
|
source: reference,
|
|
reason: format(i18n, "KSA.FindingReason.RiskyPublicTarget", {
|
|
sourceOwner: `${reference.sourceScope.ownerType}:${reference.sourceScope.ownerId}`,
|
|
locator: normalized.locator
|
|
}),
|
|
recommendation: format(i18n, "KSA.FindingRecommendation.CopyToStableStorage"),
|
|
confidence: "high"
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const file of files) {
|
|
if (detectMediaKind(file.path) === "other") continue;
|
|
const refs = matchingReferencesForFile(file, refsByLocator, wildcardReferences);
|
|
if (refs.length) continue;
|
|
if (!shouldReportOrphan(file)) continue;
|
|
|
|
const severity = (file.riskClass === "package-module") || (file.riskClass === "package-system") || (file.riskClass === "release-public")
|
|
? "warning"
|
|
: "info";
|
|
|
|
findings.push({
|
|
kind: "orphan-file",
|
|
severity,
|
|
target: file,
|
|
source: null,
|
|
reason: format(i18n, "KSA.FindingReason.OrphanFile", { locator: file.locator }),
|
|
recommendation: severity === "warning"
|
|
? format(i18n, "KSA.FindingRecommendation.ReviewOrMoveOrphan")
|
|
: format(i18n, "KSA.FindingRecommendation.KeepAsReserve"),
|
|
confidence: "medium"
|
|
});
|
|
}
|
|
|
|
return {
|
|
findings,
|
|
notices: createNotices({ inactiveModuleReferenceIds, missingModuleReferenceIds, packageActivity, i18n })
|
|
};
|
|
}
|
|
|
|
function resolveReferenceTarget(reference, fileByLocator, fileLocators) {
|
|
const normalized = reference.normalized;
|
|
if (!normalized) return reference;
|
|
|
|
const currentLocator = createCanonicalLocator(normalized.storage, normalized.path);
|
|
if (fileByLocator.has(currentLocator)) return reference;
|
|
|
|
const alternateStorage = normalized.storage === "data"
|
|
? "public"
|
|
: normalized.storage === "public"
|
|
? "data"
|
|
: null;
|
|
if (!alternateStorage) return reference;
|
|
|
|
const alternateLocator = createCanonicalLocator(alternateStorage, normalized.path);
|
|
if (normalized.targetKind === "wildcard") {
|
|
const alternateTarget = { ...normalized, storage: alternateStorage, locator: alternateLocator };
|
|
if (!wildcardMatchesAny(alternateTarget, fileLocators)) return reference;
|
|
return {
|
|
...reference,
|
|
normalized: alternateTarget
|
|
};
|
|
}
|
|
|
|
if (!fileByLocator.has(alternateLocator)) return reference;
|
|
return {
|
|
...reference,
|
|
normalized: {
|
|
...normalized,
|
|
storage: alternateStorage,
|
|
locator: alternateLocator
|
|
}
|
|
};
|
|
}
|
|
|
|
function isAnchoredInOwningPackage(file, references) {
|
|
const ownerType = file.ownerHint.ownerType;
|
|
const ownerId = file.ownerHint.ownerId;
|
|
if (!["module", "system"].includes(ownerType) || !ownerId) return false;
|
|
return references.some(reference =>
|
|
(reference.sourceScope?.ownerType === ownerType) &&
|
|
(reference.sourceScope?.ownerId === ownerId)
|
|
);
|
|
}
|
|
|
|
function matchingReferencesForFile(file, refsByLocator, wildcardReferences) {
|
|
const exactRefs = refsByLocator.get(createCanonicalLocator(file.storage, file.path)) ?? [];
|
|
const matchingWildcardRefs = wildcardReferences.filter(reference => wildcardMatchesFile(reference.normalized, file));
|
|
return [...exactRefs, ...matchingWildcardRefs];
|
|
}
|
|
|
|
function wildcardMatchesAny(target, fileLocators) {
|
|
const matcher = wildcardToRegExp(target.path);
|
|
return fileLocators.some(file => file.storage === target.storage && matcher.test(file.path));
|
|
}
|
|
|
|
function wildcardMatchesFile(target, file) {
|
|
if (!target || (target.targetKind !== "wildcard")) return false;
|
|
if (target.storage !== file.storage) return false;
|
|
return wildcardToRegExp(target.path).test(file.path);
|
|
}
|
|
|
|
function wildcardToRegExp(pattern) {
|
|
const escaped = String(pattern)
|
|
.replace(/[.+^${}()|\\]/g, "\\$&")
|
|
.replace(/\*/g, ".*")
|
|
.replace(/\?/g, ".");
|
|
return new RegExp(`^${escaped}$`, "u");
|
|
}
|
|
|
|
function shouldReportOrphan(file) {
|
|
if (isDerivedSceneThumbnail(file)) return false;
|
|
return true;
|
|
}
|
|
|
|
function isInactiveModuleTarget(file, packageActivity) {
|
|
if (file.ownerHint?.ownerType !== "module") return false;
|
|
const active = packageActivity?.modules?.get?.(file.ownerHint.ownerId);
|
|
return active === false;
|
|
}
|
|
|
|
function isMissingModuleTarget(file, packageActivity) {
|
|
if (file.ownerHint?.ownerType !== "module") return false;
|
|
return packageActivity?.modules?.has?.(file.ownerHint.ownerId) === false;
|
|
}
|
|
|
|
function createNotices({ inactiveModuleReferenceIds, missingModuleReferenceIds, packageActivity, i18n }) {
|
|
const notices = [];
|
|
|
|
const inactiveModuleIds = [...inactiveModuleReferenceIds].sort((a, b) => a.localeCompare(b));
|
|
if (inactiveModuleIds.length) {
|
|
const moduleLabels = inactiveModuleIds.map(id => packageActivity?.moduleLabels?.get?.(id) ?? id);
|
|
notices.push({
|
|
kind: "inactive-module-references",
|
|
severity: "info",
|
|
moduleIds: inactiveModuleIds,
|
|
moduleLabels,
|
|
message: format(i18n, "KSA.Notice.InactiveModuleReferences", {
|
|
modules: moduleLabels.join(", ")
|
|
})
|
|
});
|
|
}
|
|
|
|
const missingModuleIds = [...missingModuleReferenceIds].sort((a, b) => a.localeCompare(b));
|
|
if (missingModuleIds.length) {
|
|
notices.push({
|
|
kind: "missing-module-references",
|
|
severity: "info",
|
|
moduleIds: missingModuleIds,
|
|
moduleLabels: missingModuleIds,
|
|
message: format(i18n, "KSA.Notice.MissingModuleReferences", {
|
|
modules: missingModuleIds.join(", ")
|
|
})
|
|
});
|
|
}
|
|
|
|
return notices;
|
|
}
|
|
|
|
function isDerivedSceneThumbnail(file) {
|
|
const path = String(file.path ?? "");
|
|
return /^worlds\/[^/]+\/assets\/scenes\/[^/]+-thumb\.(?:png|webp)$/u.test(path);
|
|
}
|
|
|
|
export function createFileRecord(locator, size = null) {
|
|
return {
|
|
...locator,
|
|
basename: locator.path.split("/").pop() ?? locator.path,
|
|
extension: locator.path.includes(".") ? locator.path.split(".").pop().toLowerCase() : "",
|
|
size,
|
|
mediaKind: detectMediaKind(locator.path),
|
|
ownerHint: inferOwnerHint(locator),
|
|
riskClass: classifyRisk(locator),
|
|
exists: true
|
|
};
|
|
}
|
|
|
|
function format(i18n, key, data = {}) {
|
|
return i18n?.format?.(key, data) ?? key;
|
|
}
|