Initial module scaffold
This commit is contained in:
60
scripts/core/analyzer.js
Normal file
60
scripts/core/analyzer.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { buildFindings, createFileRecord } from "./finding-engine.js";
|
||||
import { extractReferencesFromValue } from "./reference-extractor.js";
|
||||
import { createFileLocator } from "./path-utils.js";
|
||||
|
||||
export async function analyzeStorage({ listFiles, listSources, onProgress }={}) {
|
||||
const files = [];
|
||||
let fileCount = 0;
|
||||
let sourceCount = 0;
|
||||
let referenceCount = 0;
|
||||
|
||||
onProgress?.({ phase: "files", label: "Scanne Dateien", files: 0, sources: 0, references: 0 });
|
||||
for await (const entry of listFiles()) {
|
||||
files.push(createFileRecord(createFileLocator(entry.storage, entry.path), entry.size ?? null));
|
||||
fileCount += 1;
|
||||
if ((fileCount % 100) === 0) {
|
||||
onProgress?.({ phase: "files", label: "Scanne Dateien", files: fileCount, sources: sourceCount, references: referenceCount });
|
||||
await yieldToUI();
|
||||
}
|
||||
}
|
||||
|
||||
const references = [];
|
||||
onProgress?.({ phase: "sources", label: "Lese Referenzen", files: fileCount, sources: 0, references: 0 });
|
||||
for await (const source of listSources()) {
|
||||
sourceCount += 1;
|
||||
const extracted = extractReferencesFromValue(source.value, {
|
||||
sourceType: source.sourceType,
|
||||
sourceScope: source.sourceScope,
|
||||
sourceLabel: source.sourceLabel
|
||||
});
|
||||
references.push(...extracted);
|
||||
referenceCount += extracted.length;
|
||||
if ((sourceCount % 50) === 0) {
|
||||
onProgress?.({
|
||||
phase: "sources",
|
||||
label: "Lese Referenzen",
|
||||
files: fileCount,
|
||||
sources: sourceCount,
|
||||
references: referenceCount,
|
||||
currentSource: source.sourceLabel
|
||||
});
|
||||
await yieldToUI();
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.({ phase: "findings", label: "Klassifiziere Findings", files: fileCount, sources: sourceCount, references: referenceCount });
|
||||
const findings = buildFindings({ files, references });
|
||||
onProgress?.({
|
||||
phase: "done",
|
||||
label: "Analyse abgeschlossen",
|
||||
files: fileCount,
|
||||
sources: sourceCount,
|
||||
references: referenceCount,
|
||||
findings: findings.length
|
||||
});
|
||||
return { files, references, findings };
|
||||
}
|
||||
|
||||
async function yieldToUI() {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
154
scripts/core/finding-engine.js
Normal file
154
scripts/core/finding-engine.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import { classifyRisk, 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 }) {
|
||||
const fileByLocator = new Map(files.map(file => [file.locator, file]));
|
||||
const refsByLocator = new Map();
|
||||
const findings = [];
|
||||
|
||||
for (const reference of references) {
|
||||
const normalized = reference.normalized;
|
||||
if (!normalized) continue;
|
||||
const bucket = refsByLocator.get(normalized.locator) ?? [];
|
||||
bucket.push(reference);
|
||||
refsByLocator.set(normalized.locator, bucket);
|
||||
}
|
||||
|
||||
for (const reference of references) {
|
||||
const normalized = reference.normalized;
|
||||
if (!normalized) continue;
|
||||
|
||||
const file = fileByLocator.get(normalized.locator);
|
||||
if (!file) {
|
||||
if (reference.sourceScope?.ownerType !== "world") continue;
|
||||
findings.push({
|
||||
kind: "broken-reference",
|
||||
severity: reference.sourceScope?.ownerType === "world" ? "high" : "warning",
|
||||
target: normalized,
|
||||
source: reference,
|
||||
reason: `Referenced file ${normalized.locator} does not exist in the scanned roots.`,
|
||||
recommendation: "Check whether the file was moved, deleted, or should be copied into a stable location.",
|
||||
confidence: "high"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const ownerRelation = compareOwner(reference.sourceScope, file.ownerHint);
|
||||
if (ownerRelation === "non-package-to-package") {
|
||||
if (isAnchoredInOwningPackage(file, refsByLocator.get(normalized.locator) ?? [])) continue;
|
||||
findings.push({
|
||||
kind: "non-package-to-package-reference",
|
||||
severity: "high",
|
||||
target: normalized,
|
||||
source: reference,
|
||||
reason: `${reference.sourceScope.ownerType}:${reference.sourceScope.ownerId} references package-owned storage ${normalized.locator}, but the asset is not visibly referenced by its owning package.`,
|
||||
recommendation: "Review whether the file was manually placed into the package folder. If the package does not use it itself, prefer moving it into user-controlled storage.",
|
||||
confidence: "high"
|
||||
});
|
||||
} else if (ownerRelation === "risky-public") {
|
||||
if (reference.sourceScope?.ownerType !== "world") continue;
|
||||
findings.push({
|
||||
kind: "risky-public-reference",
|
||||
severity: "high",
|
||||
target: normalized,
|
||||
source: reference,
|
||||
reason: `${reference.sourceScope.ownerType}:${reference.sourceScope.ownerId} references release-public storage ${normalized.locator}.`,
|
||||
recommendation: "Prefer copying the asset into world or user-controlled storage.",
|
||||
confidence: "high"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (detectMediaKind(file.path) === "other") continue;
|
||||
const refs = refsByLocator.get(file.locator) ?? [];
|
||||
if (refs.length) continue;
|
||||
if (!shouldReportOrphan(file, references)) 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: `No incoming media reference was found for ${file.locator}.`,
|
||||
recommendation: severity === "warning"
|
||||
? "Review whether the file is safe to remove or should be moved into a stable storage location."
|
||||
: "Review whether the file is intentionally kept as reserve content.",
|
||||
confidence: "medium"
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
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 shouldReportOrphan(file, references) {
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
120
scripts/core/path-utils.js
Normal file
120
scripts/core/path-utils.js
Normal file
@@ -0,0 +1,120 @@
|
||||
export const RELEASE_PREFIXES = new Set(["canvas", "cards", "docs", "fonts", "icons", "lang", "nue", "scripts", "sounds", "toolclips", "tours", "ui"]);
|
||||
const DATA_PREFIXES = new Set(["assets", "modules", "systems", "worlds"]);
|
||||
const MEDIA_EXTENSIONS = {
|
||||
image: new Set(["avif", "gif", "jpeg", "jpg", "png", "svg", "webp"]),
|
||||
video: new Set(["m4v", "mkv", "mov", "mp4", "mpeg", "mpg", "webm"]),
|
||||
audio: new Set(["flac", "m4a", "mid", "midi", "mp3", "ogg", "opus", "wav", "webm"])
|
||||
};
|
||||
|
||||
export function normalizePath(path) {
|
||||
const normalized = String(path ?? "")
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^[./]+/, "")
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/\/{2,}/g, "/")
|
||||
.trim();
|
||||
try {
|
||||
return decodeURI(normalized);
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
export function splitResourceSuffix(path) {
|
||||
const [withoutHash] = String(path ?? "").split("#", 1);
|
||||
const [cleanPath] = withoutHash.split("?", 1);
|
||||
return cleanPath;
|
||||
}
|
||||
|
||||
export function parseStoragePath(rawValue) {
|
||||
if (typeof rawValue !== "string") return null;
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^(?:https?:|data:|blob:)/i.test(trimmed)) return null;
|
||||
|
||||
const clean = normalizePath(splitResourceSuffix(trimmed));
|
||||
if (!clean) return null;
|
||||
|
||||
const first = clean.split("/", 1)[0];
|
||||
let storage = null;
|
||||
if (DATA_PREFIXES.has(first)) storage = "data";
|
||||
else if (RELEASE_PREFIXES.has(first)) storage = "public";
|
||||
if (!storage) return null;
|
||||
|
||||
return {
|
||||
storage,
|
||||
path: clean,
|
||||
locator: `${storage}:${clean}`
|
||||
};
|
||||
}
|
||||
|
||||
export function getExtension(path) {
|
||||
const clean = normalizePath(path);
|
||||
const index = clean.lastIndexOf(".");
|
||||
return index === -1 ? "" : clean.slice(index + 1).toLowerCase();
|
||||
}
|
||||
|
||||
export function detectMediaKind(path) {
|
||||
const ext = getExtension(path);
|
||||
for (const [kind, extensions] of Object.entries(MEDIA_EXTENSIONS)) {
|
||||
if (extensions.has(ext)) return kind;
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function isMediaPath(path) {
|
||||
return detectMediaKind(path) !== "other";
|
||||
}
|
||||
|
||||
export function classifyRisk(locator) {
|
||||
const parts = locator.path.split("/");
|
||||
if (locator.storage === "public") return "release-public";
|
||||
if (parts[0] === "modules") return "package-module";
|
||||
if (parts[0] === "systems") return "package-system";
|
||||
if (parts[0] === "assets") return "stable-user";
|
||||
if (parts[0] === "worlds") return "stable-world";
|
||||
return locator.storage === "data" ? "stable-user" : "unknown";
|
||||
}
|
||||
|
||||
export function inferOwnerHint(locator) {
|
||||
const parts = locator.path.split("/");
|
||||
if (locator.storage === "public") {
|
||||
return { ownerType: "public", ownerId: parts[0] ?? null };
|
||||
}
|
||||
if (parts[0] === "assets") {
|
||||
return { ownerType: "user", ownerId: null };
|
||||
}
|
||||
if ((parts[0] === "worlds") && parts[1]) {
|
||||
return { ownerType: "world", ownerId: parts[1] };
|
||||
}
|
||||
if ((parts[0] === "modules") && parts[1]) {
|
||||
return { ownerType: "module", ownerId: parts[1] };
|
||||
}
|
||||
if ((parts[0] === "systems") && parts[1]) {
|
||||
return { ownerType: "system", ownerId: parts[1] };
|
||||
}
|
||||
if (locator.storage === "data") {
|
||||
return { ownerType: "user", ownerId: parts[0] ?? null };
|
||||
}
|
||||
return { ownerType: "unknown", ownerId: null };
|
||||
}
|
||||
|
||||
export function isStorageAreaPath(locator) {
|
||||
const parts = locator.path.split("/");
|
||||
return ((parts[0] === "modules") || (parts[0] === "systems")) && (parts[2] === "storage");
|
||||
}
|
||||
|
||||
export function isCorePublicPath(locator) {
|
||||
if (locator.storage !== "public") return false;
|
||||
const parts = locator.path.split("/");
|
||||
return RELEASE_PREFIXES.has(parts[0] ?? "");
|
||||
}
|
||||
|
||||
export function createFileLocator(storage, path) {
|
||||
const cleanPath = normalizePath(path);
|
||||
return {
|
||||
storage,
|
||||
path: cleanPath,
|
||||
locator: `${storage}:${cleanPath}`
|
||||
};
|
||||
}
|
||||
102
scripts/core/reference-extractor.js
Normal file
102
scripts/core/reference-extractor.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { createFileLocator, isMediaPath, normalizePath, parseStoragePath } from "./path-utils.js";
|
||||
|
||||
const ATTRIBUTE_PATTERNS = [
|
||||
/\b(?:src|href|poster)\s*=\s*["']([^"'<>]+)["']/gi,
|
||||
/url\(\s*["']?([^"')]+)["']?\s*\)/gi
|
||||
];
|
||||
|
||||
export function collectStringCandidates(value, visit) {
|
||||
if (typeof value === "string") {
|
||||
visit(value);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) collectStringCandidates(entry, visit);
|
||||
return;
|
||||
}
|
||||
if ((value !== null) && (typeof value === "object")) {
|
||||
for (const entry of Object.values(value)) collectStringCandidates(entry, visit);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractReferencesFromValue(value, source) {
|
||||
const references = [];
|
||||
collectStringCandidates(value, candidate => {
|
||||
const direct = resolveReference(candidate, source);
|
||||
if (direct && isMediaPath(direct.path)) {
|
||||
references.push({
|
||||
sourceType: source.sourceType,
|
||||
sourceScope: source.sourceScope,
|
||||
sourceLabel: source.sourceLabel,
|
||||
rawValue: candidate,
|
||||
normalized: {
|
||||
...direct,
|
||||
targetKind: "local-file"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const pattern of ATTRIBUTE_PATTERNS) {
|
||||
pattern.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = pattern.exec(candidate))) {
|
||||
const nested = resolveReference(match[1], source);
|
||||
if (!nested || !isMediaPath(nested.path)) continue;
|
||||
references.push({
|
||||
sourceType: source.sourceType,
|
||||
sourceScope: source.sourceScope,
|
||||
sourceLabel: source.sourceLabel,
|
||||
rawValue: match[1],
|
||||
normalized: {
|
||||
...nested,
|
||||
targetKind: "local-file"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return dedupeReferences(references);
|
||||
}
|
||||
|
||||
function resolveReference(rawValue, source) {
|
||||
const direct = parseStoragePath(rawValue);
|
||||
if (direct) return direct;
|
||||
return resolvePackageRelativeReference(rawValue, source);
|
||||
}
|
||||
|
||||
function resolvePackageRelativeReference(rawValue, source) {
|
||||
if (typeof rawValue !== "string") return null;
|
||||
if (/^(?:https?:|data:|blob:)/i.test(rawValue.trim())) return null;
|
||||
const path = normalizePath(rawValue);
|
||||
if (!path || !path.includes("/")) return null;
|
||||
|
||||
const ownerType = source.sourceScope?.ownerType;
|
||||
const ownerId = source.sourceScope?.ownerId;
|
||||
if (!ownerType || !ownerId) return null;
|
||||
|
||||
if (ownerType === "module") {
|
||||
return createFileLocator("data", `modules/${ownerId}/${path}`);
|
||||
}
|
||||
if (ownerType === "system") {
|
||||
return createFileLocator("data", `systems/${ownerId}/${path}`);
|
||||
}
|
||||
if (ownerType === "world") {
|
||||
return createFileLocator("data", path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dedupeReferences(references) {
|
||||
const seen = new Set();
|
||||
return references.filter(reference => {
|
||||
const key = [
|
||||
reference.sourceType,
|
||||
reference.sourceLabel,
|
||||
reference.normalized?.locator ?? "",
|
||||
reference.rawValue
|
||||
].join("|");
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user