249 lines
9.7 KiB
JavaScript
249 lines
9.7 KiB
JavaScript
import { classifyRisk, createCanonicalLocator, detectMediaKind, inferOwnerHint, isCorePublicPath, isStorageAreaPath } 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 }={}) {
|
|
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 = [];
|
|
|
|
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 (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, resolvedReferences)) 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;
|
|
}
|
|
|
|
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, references) {
|
|
if (isDerivedSceneThumbnail(file)) return false;
|
|
if (file.riskClass === "stable-world") {
|
|
const worldId = file.ownerHint.ownerId;
|
|
return references.some(reference => reference.sourceScope?.ownerType === "world" && reference.sourceScope.ownerId === worldId);
|
|
}
|
|
if ((file.riskClass === "package-module") || (file.riskClass === "package-system")) {
|
|
return isStorageAreaPath(file);
|
|
}
|
|
if (file.riskClass === "release-public") {
|
|
return !isCorePublicPath(file);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
}
|