diff --git a/README.md b/README.md index 5e4cb02..6646a37 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,15 @@ Dieses Verzeichnis enthaelt einen ersten Modul-Prototypen fuer die Analyse von M ## Erste Kernbausteine - `path-utils.js` - - Normalisierung auf `data:` und `public:` + - Normalisierung auf `data:` und `public:` inklusive URL-Decoding und Unicode-Normalisierung - `reference-extractor.js` - - rekursive String- und Attribut-Extraktion aus JSON/Objekten + - rekursive String- und Attribut-Extraktion aus JSON/Objekten inklusive Wildcard-Erkennung - `finding-engine.js` - - Klassifizierung von Broken References, Orphans und Fremdverweisen + - Klassifizierung von Broken References, Orphans und Fremdverweisen mit Wildcard-Matching - `analyzer.js` - Orchestrierung von Dateien, Quellen und Findings +- `audit-report-app.js` + - Foundry-UI mit Fortschrittsanzeige, gruppierter Arbeitsansicht und JSON-Export ## Wichtige Heuristik @@ -36,10 +38,12 @@ Dieses Verzeichnis enthaelt einen ersten Modul-Prototypen fuer die Analyse von M - nicht offensichtliche `public`-Bereiche - Weltverweise auf Modul- oder Systemassets gelten nicht pauschal als Problem. - Sie werden erst dann hochgezogen, wenn das Zielasset im owning Paket selbst nicht sichtbar referenziert ist. +- Wildcard-Pfade wie `Ork*_Token.webp` werden nicht als kaputt markiert, solange mindestens eine passende Datei im gescannten Root existiert. +- Dieselben Wildcard-Referenzen zaehlen auch als eingehende Referenz fuer passende Dateien und verhindern damit false-positive `orphan-file`-Treffer. +- URL-encodierte oder unterschiedlich normalisierte Umlaute sollen auf dieselbe kanonische Pfadform zusammengefuehrt werden. ## Noch offen -- bessere Laufzeitabdeckung fuer Welteinstellungen und Compendien -- robustere Erkennung von Directory- und Wildcard-Referenzen -- UI in Foundry +- bessere Laufzeitabdeckung fuer Welteinstellungen und weitere Referenzquellen +- optional zusaetzliche Exportformate neben JSON - Aktionen zum sicheren Verschieben oder Migrieren diff --git a/module.json b/module.json index 0b2f3ee..f79ad31 100644 --- a/module.json +++ b/module.json @@ -2,7 +2,7 @@ "id": "kosmos-storage-audit", "title": "Kosmos Storage Audit", "description": "Analyzes media references and risky storage locations across Foundry data and public roots.", - "version": "0.0.1", + "version": "0.0.2", "compatibility": { "minimum": "13", "verified": "13" diff --git a/scripts/apps/audit-report-app.js b/scripts/apps/audit-report-app.js index 4d270a3..e047609 100644 --- a/scripts/apps/audit-report-app.js +++ b/scripts/apps/audit-report-app.js @@ -5,7 +5,9 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV tag: "div", actions: { runAnalysis: StorageAuditReportApp.#onRunAnalysis, - toggleShowAll: StorageAuditReportApp.#onToggleShowAll + toggleShowAll: StorageAuditReportApp.#onToggleShowAll, + toggleRaw: StorageAuditReportApp.#onToggleRaw, + exportReport: StorageAuditReportApp.#onExportReport }, window: { title: "Kosmos Storage Audit", @@ -22,6 +24,7 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV #loading = false; #showAll = false; #progress = null; + #showRaw = false; async _prepareContext() { const findings = this.#analysis?.findings ?? []; @@ -31,10 +34,11 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV loading: this.#loading, hasAnalysis: !!this.#analysis, showAll: this.#showAll, + showRaw: this.#showRaw, progress: this.#progress, summary: this.#summarize(this.#analysis), groupedFindings, - findings: visibleFindings.slice(0, 100) + findings: visibleFindings.slice(0, 50) }; } @@ -54,16 +58,24 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV ${context.loading ? "Analysiere..." : "Analyse Starten"} + + ${renderProgress(context.progress, context.loading)} ${renderSummary(context.summary)} ${renderGroupedFindingList(context.groupedFindings, context.hasAnalysis, context.loading, context.showAll)} - ${renderFindingList(context.findings, context.hasAnalysis, context.loading, context.showAll)} + ${renderFindingList(context.findings, context.hasAnalysis, context.loading, context.showAll, context.showRaw)} `; return container; } @@ -105,6 +117,28 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV return this.render({ force: true }); } + toggleRaw() { + this.#showRaw = !this.#showRaw; + return this.render({ force: true }); + } + + exportReport() { + if (!this.#analysis) return; + const payload = { + exportedAt: new Date().toISOString(), + summary: this.#summarize(this.#analysis), + groupedFindings: groupFindings(this.#analysis.findings), + findings: this.#analysis.findings.map(serializeFinding) + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `kosmos-storage-audit-${Date.now()}.json`; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); + } + #summarize(analysis) { if (!analysis) return null; const bySeverity = { high: 0, warning: 0, info: 0 }; @@ -150,6 +184,14 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV static #onToggleShowAll(_event, _button) { return this.toggleShowAll(); } + + static #onToggleRaw(_event, _button) { + return this.toggleRaw(); + } + + static #onExportReport(_event, _button) { + return this.exportReport(); + } } function renderProgress(progress, loading) { @@ -182,7 +224,7 @@ function renderSummary(summary) { const kindLines = Object.entries(summary.byKind) .sort((a, b) => b[1] - a[1]) - .map(([kind, count]) => `
  • ${kind}${count}
  • `) + .map(([kind, count]) => `
  • ${humanizeKind(kind)}${count}
  • `) .join(""); return ` @@ -223,11 +265,10 @@ function renderGroupedFindingList(groupedFindings, hasAnalysis, loading, showAll
    ${group.severity} - ${group.kind} ${group.count} Referenzen

    ${escapeHtml(group.target)}

    -

    ${escapeHtml(group.reason)}

    +

    ${escapeHtml(group.shortReason)}

    Owner-Paket
    ${escapeHtml(group.ownerLabel)}
    Bewertung
    ${escapeHtml(group.explanation)}
    @@ -248,11 +289,11 @@ function renderGroupedFindingList(groupedFindings, hasAnalysis, loading, showAll
    ${group.severity} - ${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)}
    @@ -306,13 +347,16 @@ function renderGroupedSection(title, description, groups, renderGroup) { `; } -function renderFindingList(findings, hasAnalysis, loading, showAll) { +function renderFindingList(findings, hasAnalysis, loading, showAll, showRaw) { if (loading) { return `

    Analyse laeuft...

    `; } if (!hasAnalysis) { return `

    Die Analyse kann direkt aus dieser Ansicht gestartet werden.

    `; } + if (!showRaw) { + return ""; + } if (!findings.length) { return `

    Keine ${showAll ? "" : "warnenden "}Findings gefunden.

    `; } @@ -321,7 +365,7 @@ function renderFindingList(findings, hasAnalysis, loading, showAll) {
    ${finding.severity} - ${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 }; } }