${group.kind}
${group.count} Referenzen
${escapeHtml(group.target)}
${escapeHtml(group.reason)}
+${escapeHtml(group.shortReason)}
- Owner-Paket
${escapeHtml(group.ownerLabel)}- Bewertung
- ${escapeHtml(group.explanation)}
${group.kind}
${group.count} Referenzen
${escapeHtml(group.target)}
${escapeHtml(group.reason)}
+${escapeHtml(group.shortReason)}
+ ${group.targetKind === "wildcard" ? `Musterreferenz: Wildcard wird von Foundry unterstuetzt, derzeit aber ohne passenden Treffer im Dateisystem.
` : ""}${escapeHtml(group.recommendation)}
${renderSampleSources(group.sources)}Analyse laeuft...
Die Analyse kann direkt aus dieser Ansicht gestartet werden.
Keine ${showAll ? "" : "warnenden "}Findings gefunden.
${finding.kind}
+ ${humanizeKind(finding.kind)}
${escapeHtml(finding.reason)}
-
@@ -375,8 +419,10 @@ function groupByTarget(findings) {
count: 0,
ownerLabel,
explanation: "Weltdaten zeigen auf ein Paketasset, fuer das in Manifesten und Paket-Packs keine sichtbare Eigenreferenz gefunden wurde.",
+ shortReason: shortenReason(finding.reason),
reason: finding.reason,
recommendation: finding.recommendation,
+ targetKind: finding.target.targetKind ?? "local-file",
sources: new Set()
};
current.count += 1;
@@ -389,6 +435,13 @@ function groupByTarget(findings) {
.sort((a, b) => compareSeverity(a.severity, b.severity) || (b.count - a.count) || a.target.localeCompare(b.target));
}
+function shortenReason(reason) {
+ return String(reason ?? "")
+ .replace(/^world:[^ ]+ references /, "")
+ .replace(/^Referenced file /, "")
+ .replace(/^Wildcard reference /, "");
+}
+
function deriveOwnerLabel(target) {
const [, path] = String(target).split(":", 2);
const parts = (path ?? "").split("/");
@@ -412,6 +465,35 @@ function formatTarget(target) {
return target.locator ?? `${target.storage}:${target.path}`;
}
+function humanizeKind(kind) {
+ return {
+ "non-package-to-package-reference": "Unverankerte Paketziele",
+ "broken-reference": "Kaputte Ziele",
+ "orphan-file": "Orphan-Kandidaten",
+ "risky-public-reference": "Riskante Public-Ziele"
+ }[kind] ?? kind;
+}
+
+function serializeFinding(finding) {
+ return {
+ kind: finding.kind,
+ severity: finding.severity,
+ reason: finding.reason,
+ recommendation: finding.recommendation,
+ confidence: finding.confidence,
+ target: finding.target,
+ source: finding.source
+ ? {
+ sourceType: finding.source.sourceType,
+ sourceLabel: finding.source.sourceLabel,
+ sourceScope: finding.source.sourceScope,
+ rawValue: finding.source.rawValue,
+ normalized: finding.source.normalized
+ }
+ : null
+ };
+}
+
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&")
diff --git a/scripts/core/finding-engine.js b/scripts/core/finding-engine.js
index aab9c62..08fdbd4 100644
--- a/scripts/core/finding-engine.js
+++ b/scripts/core/finding-engine.js
@@ -1,4 +1,4 @@
-import { classifyRisk, detectMediaKind, inferOwnerHint, isCorePublicPath, isStorageAreaPath } from "./path-utils.js";
+import { classifyRisk, createCanonicalLocator, detectMediaKind, inferOwnerHint, isCorePublicPath, isStorageAreaPath } from "./path-utils.js";
function compareOwner(sourceScope, targetOwner) {
if (!sourceScope) return "unknown";
@@ -33,32 +33,49 @@ function compareOwner(sourceScope, targetOwner) {
}
export function buildFindings({ files, references }) {
- const fileByLocator = new Map(files.map(file => [file.locator, file]));
+ 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 refsByLocator = new Map();
+ const wildcardReferences = [];
const findings = [];
for (const reference of references) {
const normalized = reference.normalized;
if (!normalized) continue;
- const bucket = refsByLocator.get(normalized.locator) ?? [];
+ 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(normalized.locator, bucket);
+ refsByLocator.set(normalizedLocator, bucket);
}
for (const reference of references) {
const normalized = reference.normalized;
if (!normalized) continue;
+ const normalizedLocator = createCanonicalLocator(normalized.storage, normalized.path);
- const file = fileByLocator.get(normalized.locator);
+ 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,
+ target: { ...normalized, locator: normalizedLocator },
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.",
+ reason: normalized.targetKind === "wildcard"
+ ? `Wildcard reference ${normalized.locator} did not match any files in the scanned roots.`
+ : `Referenced file ${normalized.locator} does not exist in the scanned roots.`,
+ recommendation: normalized.targetKind === "wildcard"
+ ? "Check whether the wildcard pattern is still correct and whether matching files still exist."
+ : "Check whether the file was moved, deleted, or should be copied into a stable location.",
confidence: "high"
});
continue;
@@ -66,11 +83,11 @@ export function buildFindings({ files, references }) {
const ownerRelation = compareOwner(reference.sourceScope, file.ownerHint);
if (ownerRelation === "non-package-to-package") {
- if (isAnchoredInOwningPackage(file, refsByLocator.get(normalized.locator) ?? [])) continue;
+ if (isAnchoredInOwningPackage(file, matchingReferencesForFile(file, refsByLocator, wildcardReferences))) continue;
findings.push({
kind: "non-package-to-package-reference",
severity: "high",
- target: normalized,
+ target: { ...normalized, locator: normalizedLocator },
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.",
@@ -81,7 +98,7 @@ export function buildFindings({ files, references }) {
findings.push({
kind: "risky-public-reference",
severity: "high",
- target: normalized,
+ target: { ...normalized, locator: normalizedLocator },
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.",
@@ -92,7 +109,7 @@ export function buildFindings({ files, references }) {
for (const file of files) {
if (detectMediaKind(file.path) === "other") continue;
- const refs = refsByLocator.get(file.locator) ?? [];
+ const refs = matchingReferencesForFile(file, refsByLocator, wildcardReferences);
if (refs.length) continue;
if (!shouldReportOrphan(file, references)) continue;
@@ -126,6 +143,31 @@ function isAnchoredInOwningPackage(file, references) {
);
}
+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 (file.riskClass === "stable-world") {
const worldId = file.ownerHint.ownerId;
diff --git a/scripts/core/path-utils.js b/scripts/core/path-utils.js
index abd401c..168f1c4 100644
--- a/scripts/core/path-utils.js
+++ b/scripts/core/path-utils.js
@@ -12,9 +12,10 @@ export function normalizePath(path) {
.replace(/^[./]+/, "")
.replace(/^\/+/, "")
.replace(/\/{2,}/g, "/")
- .trim();
+ .trim()
+ .normalize("NFC");
try {
- return decodeURI(normalized);
+ return decodeURI(normalized).normalize("NFC");
} catch {
return normalized;
}
@@ -48,6 +49,15 @@ export function parseStoragePath(rawValue) {
};
}
+export function hasWildcard(path) {
+ return /[*?[\]{}]/.test(String(path ?? ""));
+}
+
+export function createCanonicalLocator(storage, path) {
+ const cleanPath = normalizePath(path);
+ return `${storage}:${cleanPath}`.normalize("NFC");
+}
+
export function getExtension(path) {
const clean = normalizePath(path);
const index = clean.lastIndexOf(".");
@@ -115,6 +125,6 @@ export function createFileLocator(storage, path) {
return {
storage,
path: cleanPath,
- locator: `${storage}:${cleanPath}`
+ locator: createCanonicalLocator(storage, cleanPath)
};
}
diff --git a/scripts/core/reference-extractor.js b/scripts/core/reference-extractor.js
index 9a63d13..c68c11d 100644
--- a/scripts/core/reference-extractor.js
+++ b/scripts/core/reference-extractor.js
@@ -1,4 +1,4 @@
-import { createFileLocator, isMediaPath, normalizePath, parseStoragePath } from "./path-utils.js";
+import { createFileLocator, hasWildcard, isMediaPath, normalizePath, parseStoragePath } from "./path-utils.js";
const ATTRIBUTE_PATTERNS = [
/\b(?:src|href|poster)\s*=\s*["']([^"'<>]+)["']/gi,
@@ -31,7 +31,7 @@ export function extractReferencesFromValue(value, source) {
rawValue: candidate,
normalized: {
...direct,
- targetKind: "local-file"
+ targetKind: hasWildcard(direct.path) ? "wildcard" : "local-file"
}
});
}
@@ -49,7 +49,7 @@ export function extractReferencesFromValue(value, source) {
rawValue: match[1],
normalized: {
...nested,
- targetKind: "local-file"
+ targetKind: hasWildcard(nested.path) ? "wildcard" : "local-file"
}
});
}
diff --git a/tests/wildcard-reference-test.mjs b/tests/wildcard-reference-test.mjs
new file mode 100644
index 0000000..6828a93
--- /dev/null
+++ b/tests/wildcard-reference-test.mjs
@@ -0,0 +1,43 @@
+import assert from "node:assert/strict";
+import { buildFindings, createFileRecord } from "../scripts/core/finding-engine.js";
+import { createFileLocator } from "../scripts/core/path-utils.js";
+
+const files = [
+ createFileRecord(createFileLocator("data", "modules/example/icons/token/Ork1_Token.webp"), 1234),
+ createFileRecord(createFileLocator("data", "modules/example/storage/unused.webp"), 3456)
+];
+
+const references = [
+ {
+ sourceType: "world-document",
+ sourceScope: { ownerType: "world", ownerId: "demo-world", systemId: "demo-system", subtype: "actors" },
+ sourceLabel: "Actor demo",
+ rawValue: "modules/example/icons/token/Ork*_Token.webp",
+ normalized: {
+ ...createFileLocator("data", "modules/example/icons/token/Ork*_Token.webp"),
+ targetKind: "wildcard"
+ }
+ }
+];
+
+const findings = buildFindings({ files, references });
+
+assert.equal(
+ findings.some(finding => finding.kind === "broken-reference" && finding.target.locator === "data:modules/example/icons/token/Ork*_Token.webp"),
+ false,
+ "matching wildcard references must not be reported as broken"
+);
+
+assert.equal(
+ findings.some(finding => finding.kind === "orphan-file" && finding.target.locator === "data:modules/example/icons/token/Ork1_Token.webp"),
+ false,
+ "files matched by a wildcard reference must not be reported as orphaned"
+);
+
+assert.equal(
+ findings.some(finding => finding.kind === "orphan-file" && finding.target.locator === "data:modules/example/storage/unused.webp"),
+ true,
+ "unmatched package storage files should still be reported as orphaned"
+);
+
+console.log("wildcard-reference-test: ok");
diff --git a/tools/offline-analyze.mjs b/tools/offline-analyze.mjs
index 8a6ab70..e643e81 100644
--- a/tools/offline-analyze.mjs
+++ b/tools/offline-analyze.mjs
@@ -14,7 +14,13 @@ const worldSystemId = worldManifest.system ?? null;
async function* walkDirectory(storage, baseDir, relativeDir = "") {
const absoluteDir = path.join(baseDir, relativeDir);
- const entries = await fs.readdir(absoluteDir, { withFileTypes: true });
+ let entries;
+ try {
+ entries = await fs.readdir(absoluteDir, { withFileTypes: true });
+ } catch (error) {
+ if (error?.code === "ENOENT") return;
+ throw error;
+ }
for (const entry of entries) {
const relativePath = normalizePath(path.posix.join(relativeDir, entry.name));
if (entry.isDirectory()) {
@@ -22,7 +28,13 @@ async function* walkDirectory(storage, baseDir, relativeDir = "") {
continue;
}
if (!isMediaPath(relativePath)) continue;
- const stat = await fs.stat(path.join(baseDir, relativePath));
+ let stat;
+ try {
+ stat = await fs.stat(path.join(baseDir, relativePath));
+ } catch (error) {
+ if (error?.code === "ENOENT") continue;
+ throw error;
+ }
yield { storage, path: relativePath, size: stat.size };
}
}