Release 0.0.2

This commit is contained in:
2026-04-20 20:33:13 +00:00
parent c3e590e782
commit a8846756a3
8 changed files with 230 additions and 37 deletions

View File

@@ -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
<i class="fa-solid fa-magnifying-glass"></i>
<span>${context.loading ? "Analysiere..." : "Analyse Starten"}</span>
</button>
<button type="button" class="button" data-action="exportReport" ${context.hasAnalysis ? "" : "disabled"}>
<i class="fa-solid fa-file-export"></i>
<span>Report Export</span>
</button>
<button type="button" class="button" data-action="toggleShowAll" ${context.hasAnalysis ? "" : "disabled"}>
<i class="fa-solid fa-filter"></i>
<span>${context.showAll ? "Nur Warnungen" : "Alle Findings"}</span>
</button>
<button type="button" class="button" data-action="toggleRaw" ${context.hasAnalysis ? "" : "disabled"}>
<i class="fa-solid fa-list"></i>
<span>${context.showRaw ? "Einzelfaelle Ausblenden" : "Einzelfaelle Anzeigen"}</span>
</button>
</div>
</section>
${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]) => `<li><code>${kind}</code><span>${count}</span></li>`)
.map(([kind, count]) => `<li><span>${humanizeKind(kind)}</span><span>${count}</span></li>`)
.join("");
return `
@@ -223,11 +265,10 @@ function renderGroupedFindingList(groupedFindings, hasAnalysis, loading, showAll
<article class="storage-audit__finding severity-${group.severity}">
<header>
<span class="storage-audit__severity">${group.severity}</span>
<code>${group.kind}</code>
<strong>${group.count} Referenzen</strong>
</header>
<p><code>${escapeHtml(group.target)}</code></p>
<p>${escapeHtml(group.reason)}</p>
<p>${escapeHtml(group.shortReason)}</p>
<dl>
<div><dt>Owner-Paket</dt><dd><code>${escapeHtml(group.ownerLabel)}</code></dd></div>
<div><dt>Bewertung</dt><dd>${escapeHtml(group.explanation)}</dd></div>
@@ -248,11 +289,11 @@ function renderGroupedFindingList(groupedFindings, hasAnalysis, loading, showAll
<article class="storage-audit__finding severity-${group.severity}">
<header>
<span class="storage-audit__severity">${group.severity}</span>
<code>${group.kind}</code>
<strong>${group.count} Referenzen</strong>
</header>
<p><code>${escapeHtml(group.target)}</code></p>
<p>${escapeHtml(group.reason)}</p>
<p>${escapeHtml(group.shortReason)}</p>
${group.targetKind === "wildcard" ? `<p>Musterreferenz: Wildcard wird von Foundry unterstuetzt, derzeit aber ohne passenden Treffer im Dateisystem.</p>` : ""}
<p class="storage-audit__recommendation">${escapeHtml(group.recommendation)}</p>
${renderSampleSources(group.sources)}
</article>
@@ -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 `<section class="storage-audit__list"><p>Analyse laeuft...</p></section>`;
}
if (!hasAnalysis) {
return `<section class="storage-audit__list"><p>Die Analyse kann direkt aus dieser Ansicht gestartet werden.</p></section>`;
}
if (!showRaw) {
return "";
}
if (!findings.length) {
return `<section class="storage-audit__list"><p>Keine ${showAll ? "" : "warnenden "}Findings gefunden.</p></section>`;
}
@@ -321,7 +365,7 @@ function renderFindingList(findings, hasAnalysis, loading, showAll) {
<article class="storage-audit__finding severity-${finding.severity}">
<header>
<span class="storage-audit__severity">${finding.severity}</span>
<code>${finding.kind}</code>
<span>${humanizeKind(finding.kind)}</span>
</header>
<p>${escapeHtml(finding.reason)}</p>
<dl>
@@ -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, "&amp;")

View File

@@ -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;

View File

@@ -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)
};
}

View File

@@ -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"
}
});
}