Release 0.0.2
This commit is contained in:
16
README.md
16
README.md
@@ -19,13 +19,15 @@ Dieses Verzeichnis enthaelt einen ersten Modul-Prototypen fuer die Analyse von M
|
|||||||
## Erste Kernbausteine
|
## Erste Kernbausteine
|
||||||
|
|
||||||
- `path-utils.js`
|
- `path-utils.js`
|
||||||
- Normalisierung auf `data:` und `public:`
|
- Normalisierung auf `data:` und `public:` inklusive URL-Decoding und Unicode-Normalisierung
|
||||||
- `reference-extractor.js`
|
- `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`
|
- `finding-engine.js`
|
||||||
- Klassifizierung von Broken References, Orphans und Fremdverweisen
|
- Klassifizierung von Broken References, Orphans und Fremdverweisen mit Wildcard-Matching
|
||||||
- `analyzer.js`
|
- `analyzer.js`
|
||||||
- Orchestrierung von Dateien, Quellen und Findings
|
- Orchestrierung von Dateien, Quellen und Findings
|
||||||
|
- `audit-report-app.js`
|
||||||
|
- Foundry-UI mit Fortschrittsanzeige, gruppierter Arbeitsansicht und JSON-Export
|
||||||
|
|
||||||
## Wichtige Heuristik
|
## Wichtige Heuristik
|
||||||
|
|
||||||
@@ -36,10 +38,12 @@ Dieses Verzeichnis enthaelt einen ersten Modul-Prototypen fuer die Analyse von M
|
|||||||
- nicht offensichtliche `public`-Bereiche
|
- nicht offensichtliche `public`-Bereiche
|
||||||
- Weltverweise auf Modul- oder Systemassets gelten nicht pauschal als Problem.
|
- 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.
|
- 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
|
## Noch offen
|
||||||
|
|
||||||
- bessere Laufzeitabdeckung fuer Welteinstellungen und Compendien
|
- bessere Laufzeitabdeckung fuer Welteinstellungen und weitere Referenzquellen
|
||||||
- robustere Erkennung von Directory- und Wildcard-Referenzen
|
- optional zusaetzliche Exportformate neben JSON
|
||||||
- UI in Foundry
|
|
||||||
- Aktionen zum sicheren Verschieben oder Migrieren
|
- Aktionen zum sicheren Verschieben oder Migrieren
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "kosmos-storage-audit",
|
"id": "kosmos-storage-audit",
|
||||||
"title": "Kosmos Storage Audit",
|
"title": "Kosmos Storage Audit",
|
||||||
"description": "Analyzes media references and risky storage locations across Foundry data and public roots.",
|
"description": "Analyzes media references and risky storage locations across Foundry data and public roots.",
|
||||||
"version": "0.0.1",
|
"version": "0.0.2",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"minimum": "13",
|
"minimum": "13",
|
||||||
"verified": "13"
|
"verified": "13"
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV
|
|||||||
tag: "div",
|
tag: "div",
|
||||||
actions: {
|
actions: {
|
||||||
runAnalysis: StorageAuditReportApp.#onRunAnalysis,
|
runAnalysis: StorageAuditReportApp.#onRunAnalysis,
|
||||||
toggleShowAll: StorageAuditReportApp.#onToggleShowAll
|
toggleShowAll: StorageAuditReportApp.#onToggleShowAll,
|
||||||
|
toggleRaw: StorageAuditReportApp.#onToggleRaw,
|
||||||
|
exportReport: StorageAuditReportApp.#onExportReport
|
||||||
},
|
},
|
||||||
window: {
|
window: {
|
||||||
title: "Kosmos Storage Audit",
|
title: "Kosmos Storage Audit",
|
||||||
@@ -22,6 +24,7 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV
|
|||||||
#loading = false;
|
#loading = false;
|
||||||
#showAll = false;
|
#showAll = false;
|
||||||
#progress = null;
|
#progress = null;
|
||||||
|
#showRaw = false;
|
||||||
|
|
||||||
async _prepareContext() {
|
async _prepareContext() {
|
||||||
const findings = this.#analysis?.findings ?? [];
|
const findings = this.#analysis?.findings ?? [];
|
||||||
@@ -31,10 +34,11 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV
|
|||||||
loading: this.#loading,
|
loading: this.#loading,
|
||||||
hasAnalysis: !!this.#analysis,
|
hasAnalysis: !!this.#analysis,
|
||||||
showAll: this.#showAll,
|
showAll: this.#showAll,
|
||||||
|
showRaw: this.#showRaw,
|
||||||
progress: this.#progress,
|
progress: this.#progress,
|
||||||
summary: this.#summarize(this.#analysis),
|
summary: this.#summarize(this.#analysis),
|
||||||
groupedFindings,
|
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>
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
<span>${context.loading ? "Analysiere..." : "Analyse Starten"}</span>
|
<span>${context.loading ? "Analysiere..." : "Analyse Starten"}</span>
|
||||||
</button>
|
</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"}>
|
<button type="button" class="button" data-action="toggleShowAll" ${context.hasAnalysis ? "" : "disabled"}>
|
||||||
<i class="fa-solid fa-filter"></i>
|
<i class="fa-solid fa-filter"></i>
|
||||||
<span>${context.showAll ? "Nur Warnungen" : "Alle Findings"}</span>
|
<span>${context.showAll ? "Nur Warnungen" : "Alle Findings"}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
${renderProgress(context.progress, context.loading)}
|
${renderProgress(context.progress, context.loading)}
|
||||||
${renderSummary(context.summary)}
|
${renderSummary(context.summary)}
|
||||||
${renderGroupedFindingList(context.groupedFindings, context.hasAnalysis, context.loading, context.showAll)}
|
${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;
|
return container;
|
||||||
}
|
}
|
||||||
@@ -105,6 +117,28 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV
|
|||||||
return this.render({ force: true });
|
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) {
|
#summarize(analysis) {
|
||||||
if (!analysis) return null;
|
if (!analysis) return null;
|
||||||
const bySeverity = { high: 0, warning: 0, info: 0 };
|
const bySeverity = { high: 0, warning: 0, info: 0 };
|
||||||
@@ -150,6 +184,14 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV
|
|||||||
static #onToggleShowAll(_event, _button) {
|
static #onToggleShowAll(_event, _button) {
|
||||||
return this.toggleShowAll();
|
return this.toggleShowAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static #onToggleRaw(_event, _button) {
|
||||||
|
return this.toggleRaw();
|
||||||
|
}
|
||||||
|
|
||||||
|
static #onExportReport(_event, _button) {
|
||||||
|
return this.exportReport();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProgress(progress, loading) {
|
function renderProgress(progress, loading) {
|
||||||
@@ -182,7 +224,7 @@ function renderSummary(summary) {
|
|||||||
|
|
||||||
const kindLines = Object.entries(summary.byKind)
|
const kindLines = Object.entries(summary.byKind)
|
||||||
.sort((a, b) => b[1] - a[1])
|
.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("");
|
.join("");
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -223,11 +265,10 @@ function renderGroupedFindingList(groupedFindings, hasAnalysis, loading, showAll
|
|||||||
<article class="storage-audit__finding severity-${group.severity}">
|
<article class="storage-audit__finding severity-${group.severity}">
|
||||||
<header>
|
<header>
|
||||||
<span class="storage-audit__severity">${group.severity}</span>
|
<span class="storage-audit__severity">${group.severity}</span>
|
||||||
<code>${group.kind}</code>
|
|
||||||
<strong>${group.count} Referenzen</strong>
|
<strong>${group.count} Referenzen</strong>
|
||||||
</header>
|
</header>
|
||||||
<p><code>${escapeHtml(group.target)}</code></p>
|
<p><code>${escapeHtml(group.target)}</code></p>
|
||||||
<p>${escapeHtml(group.reason)}</p>
|
<p>${escapeHtml(group.shortReason)}</p>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>Owner-Paket</dt><dd><code>${escapeHtml(group.ownerLabel)}</code></dd></div>
|
<div><dt>Owner-Paket</dt><dd><code>${escapeHtml(group.ownerLabel)}</code></dd></div>
|
||||||
<div><dt>Bewertung</dt><dd>${escapeHtml(group.explanation)}</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}">
|
<article class="storage-audit__finding severity-${group.severity}">
|
||||||
<header>
|
<header>
|
||||||
<span class="storage-audit__severity">${group.severity}</span>
|
<span class="storage-audit__severity">${group.severity}</span>
|
||||||
<code>${group.kind}</code>
|
|
||||||
<strong>${group.count} Referenzen</strong>
|
<strong>${group.count} Referenzen</strong>
|
||||||
</header>
|
</header>
|
||||||
<p><code>${escapeHtml(group.target)}</code></p>
|
<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>
|
<p class="storage-audit__recommendation">${escapeHtml(group.recommendation)}</p>
|
||||||
${renderSampleSources(group.sources)}
|
${renderSampleSources(group.sources)}
|
||||||
</article>
|
</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) {
|
if (loading) {
|
||||||
return `<section class="storage-audit__list"><p>Analyse laeuft...</p></section>`;
|
return `<section class="storage-audit__list"><p>Analyse laeuft...</p></section>`;
|
||||||
}
|
}
|
||||||
if (!hasAnalysis) {
|
if (!hasAnalysis) {
|
||||||
return `<section class="storage-audit__list"><p>Die Analyse kann direkt aus dieser Ansicht gestartet werden.</p></section>`;
|
return `<section class="storage-audit__list"><p>Die Analyse kann direkt aus dieser Ansicht gestartet werden.</p></section>`;
|
||||||
}
|
}
|
||||||
|
if (!showRaw) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
if (!findings.length) {
|
if (!findings.length) {
|
||||||
return `<section class="storage-audit__list"><p>Keine ${showAll ? "" : "warnenden "}Findings gefunden.</p></section>`;
|
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}">
|
<article class="storage-audit__finding severity-${finding.severity}">
|
||||||
<header>
|
<header>
|
||||||
<span class="storage-audit__severity">${finding.severity}</span>
|
<span class="storage-audit__severity">${finding.severity}</span>
|
||||||
<code>${finding.kind}</code>
|
<span>${humanizeKind(finding.kind)}</span>
|
||||||
</header>
|
</header>
|
||||||
<p>${escapeHtml(finding.reason)}</p>
|
<p>${escapeHtml(finding.reason)}</p>
|
||||||
<dl>
|
<dl>
|
||||||
@@ -375,8 +419,10 @@ function groupByTarget(findings) {
|
|||||||
count: 0,
|
count: 0,
|
||||||
ownerLabel,
|
ownerLabel,
|
||||||
explanation: "Weltdaten zeigen auf ein Paketasset, fuer das in Manifesten und Paket-Packs keine sichtbare Eigenreferenz gefunden wurde.",
|
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,
|
reason: finding.reason,
|
||||||
recommendation: finding.recommendation,
|
recommendation: finding.recommendation,
|
||||||
|
targetKind: finding.target.targetKind ?? "local-file",
|
||||||
sources: new Set()
|
sources: new Set()
|
||||||
};
|
};
|
||||||
current.count += 1;
|
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));
|
.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) {
|
function deriveOwnerLabel(target) {
|
||||||
const [, path] = String(target).split(":", 2);
|
const [, path] = String(target).split(":", 2);
|
||||||
const parts = (path ?? "").split("/");
|
const parts = (path ?? "").split("/");
|
||||||
@@ -412,6 +465,35 @@ function formatTarget(target) {
|
|||||||
return target.locator ?? `${target.storage}:${target.path}`;
|
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) {
|
function escapeHtml(value) {
|
||||||
return String(value ?? "")
|
return String(value ?? "")
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
|
|||||||
@@ -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) {
|
function compareOwner(sourceScope, targetOwner) {
|
||||||
if (!sourceScope) return "unknown";
|
if (!sourceScope) return "unknown";
|
||||||
@@ -33,32 +33,49 @@ function compareOwner(sourceScope, targetOwner) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildFindings({ files, references }) {
|
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 refsByLocator = new Map();
|
||||||
|
const wildcardReferences = [];
|
||||||
const findings = [];
|
const findings = [];
|
||||||
|
|
||||||
for (const reference of references) {
|
for (const reference of references) {
|
||||||
const normalized = reference.normalized;
|
const normalized = reference.normalized;
|
||||||
if (!normalized) continue;
|
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);
|
bucket.push(reference);
|
||||||
refsByLocator.set(normalized.locator, bucket);
|
refsByLocator.set(normalizedLocator, bucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const reference of references) {
|
for (const reference of references) {
|
||||||
const normalized = reference.normalized;
|
const normalized = reference.normalized;
|
||||||
if (!normalized) continue;
|
if (!normalized) continue;
|
||||||
|
const normalizedLocator = createCanonicalLocator(normalized.storage, normalized.path);
|
||||||
|
|
||||||
const file = fileByLocator.get(normalized.locator);
|
const file = fileByLocator.get(normalizedLocator);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
if ((normalized.targetKind === "wildcard") && wildcardMatchesAny(normalized, fileLocators)) continue;
|
||||||
if (reference.sourceScope?.ownerType !== "world") continue;
|
if (reference.sourceScope?.ownerType !== "world") continue;
|
||||||
findings.push({
|
findings.push({
|
||||||
kind: "broken-reference",
|
kind: "broken-reference",
|
||||||
severity: reference.sourceScope?.ownerType === "world" ? "high" : "warning",
|
severity: reference.sourceScope?.ownerType === "world" ? "high" : "warning",
|
||||||
target: normalized,
|
target: { ...normalized, locator: normalizedLocator },
|
||||||
source: reference,
|
source: reference,
|
||||||
reason: `Referenced file ${normalized.locator} does not exist in the scanned roots.`,
|
reason: normalized.targetKind === "wildcard"
|
||||||
recommendation: "Check whether the file was moved, deleted, or should be copied into a stable location.",
|
? `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"
|
confidence: "high"
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -66,11 +83,11 @@ export function buildFindings({ files, references }) {
|
|||||||
|
|
||||||
const ownerRelation = compareOwner(reference.sourceScope, file.ownerHint);
|
const ownerRelation = compareOwner(reference.sourceScope, file.ownerHint);
|
||||||
if (ownerRelation === "non-package-to-package") {
|
if (ownerRelation === "non-package-to-package") {
|
||||||
if (isAnchoredInOwningPackage(file, refsByLocator.get(normalized.locator) ?? [])) continue;
|
if (isAnchoredInOwningPackage(file, matchingReferencesForFile(file, refsByLocator, wildcardReferences))) continue;
|
||||||
findings.push({
|
findings.push({
|
||||||
kind: "non-package-to-package-reference",
|
kind: "non-package-to-package-reference",
|
||||||
severity: "high",
|
severity: "high",
|
||||||
target: normalized,
|
target: { ...normalized, locator: normalizedLocator },
|
||||||
source: reference,
|
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.`,
|
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.",
|
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({
|
findings.push({
|
||||||
kind: "risky-public-reference",
|
kind: "risky-public-reference",
|
||||||
severity: "high",
|
severity: "high",
|
||||||
target: normalized,
|
target: { ...normalized, locator: normalizedLocator },
|
||||||
source: reference,
|
source: reference,
|
||||||
reason: `${reference.sourceScope.ownerType}:${reference.sourceScope.ownerId} references release-public storage ${normalized.locator}.`,
|
reason: `${reference.sourceScope.ownerType}:${reference.sourceScope.ownerId} references release-public storage ${normalized.locator}.`,
|
||||||
recommendation: "Prefer copying the asset into world or user-controlled storage.",
|
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) {
|
for (const file of files) {
|
||||||
if (detectMediaKind(file.path) === "other") continue;
|
if (detectMediaKind(file.path) === "other") continue;
|
||||||
const refs = refsByLocator.get(file.locator) ?? [];
|
const refs = matchingReferencesForFile(file, refsByLocator, wildcardReferences);
|
||||||
if (refs.length) continue;
|
if (refs.length) continue;
|
||||||
if (!shouldReportOrphan(file, references)) 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) {
|
function shouldReportOrphan(file, references) {
|
||||||
if (file.riskClass === "stable-world") {
|
if (file.riskClass === "stable-world") {
|
||||||
const worldId = file.ownerHint.ownerId;
|
const worldId = file.ownerHint.ownerId;
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ export function normalizePath(path) {
|
|||||||
.replace(/^[./]+/, "")
|
.replace(/^[./]+/, "")
|
||||||
.replace(/^\/+/, "")
|
.replace(/^\/+/, "")
|
||||||
.replace(/\/{2,}/g, "/")
|
.replace(/\/{2,}/g, "/")
|
||||||
.trim();
|
.trim()
|
||||||
|
.normalize("NFC");
|
||||||
try {
|
try {
|
||||||
return decodeURI(normalized);
|
return decodeURI(normalized).normalize("NFC");
|
||||||
} catch {
|
} catch {
|
||||||
return normalized;
|
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) {
|
export function getExtension(path) {
|
||||||
const clean = normalizePath(path);
|
const clean = normalizePath(path);
|
||||||
const index = clean.lastIndexOf(".");
|
const index = clean.lastIndexOf(".");
|
||||||
@@ -115,6 +125,6 @@ export function createFileLocator(storage, path) {
|
|||||||
return {
|
return {
|
||||||
storage,
|
storage,
|
||||||
path: cleanPath,
|
path: cleanPath,
|
||||||
locator: `${storage}:${cleanPath}`
|
locator: createCanonicalLocator(storage, cleanPath)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [
|
const ATTRIBUTE_PATTERNS = [
|
||||||
/\b(?:src|href|poster)\s*=\s*["']([^"'<>]+)["']/gi,
|
/\b(?:src|href|poster)\s*=\s*["']([^"'<>]+)["']/gi,
|
||||||
@@ -31,7 +31,7 @@ export function extractReferencesFromValue(value, source) {
|
|||||||
rawValue: candidate,
|
rawValue: candidate,
|
||||||
normalized: {
|
normalized: {
|
||||||
...direct,
|
...direct,
|
||||||
targetKind: "local-file"
|
targetKind: hasWildcard(direct.path) ? "wildcard" : "local-file"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ export function extractReferencesFromValue(value, source) {
|
|||||||
rawValue: match[1],
|
rawValue: match[1],
|
||||||
normalized: {
|
normalized: {
|
||||||
...nested,
|
...nested,
|
||||||
targetKind: "local-file"
|
targetKind: hasWildcard(nested.path) ? "wildcard" : "local-file"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
43
tests/wildcard-reference-test.mjs
Normal file
43
tests/wildcard-reference-test.mjs
Normal file
@@ -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");
|
||||||
@@ -14,7 +14,13 @@ const worldSystemId = worldManifest.system ?? null;
|
|||||||
|
|
||||||
async function* walkDirectory(storage, baseDir, relativeDir = "") {
|
async function* walkDirectory(storage, baseDir, relativeDir = "") {
|
||||||
const absoluteDir = path.join(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) {
|
for (const entry of entries) {
|
||||||
const relativePath = normalizePath(path.posix.join(relativeDir, entry.name));
|
const relativePath = normalizePath(path.posix.join(relativeDir, entry.name));
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
@@ -22,7 +28,13 @@ async function* walkDirectory(storage, baseDir, relativeDir = "") {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isMediaPath(relativePath)) 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 };
|
yield { storage, path: relativePath, size: stat.size };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user