diff --git a/lang/de.json b/lang/de.json
index 63b6000..7a02f8a 100644
--- a/lang/de.json
+++ b/lang/de.json
@@ -40,6 +40,10 @@
"Failed": "Kosmos Storage Audit fehlgeschlagen: {message}",
"OpenSourceFailed": "Die Quelle konnte nicht geöffnet werden: {uuid}"
},
+ "Notice": {
+ "Title": "Hinweise",
+ "InactiveModuleReferences": "Die aktive Welt referenziert Dateien aus inaktiven Modulen. Diese Modulziele wurden nicht als unverankert gewertet: {modules}"
+ },
"Summary": {
"NoAnalysis": "Noch keine Analyse ausgeführt.",
"Files": "Dateien",
diff --git a/lang/en.json b/lang/en.json
index 83e5285..0f9566a 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -40,6 +40,10 @@
"Failed": "Kosmos Storage Audit failed: {message}",
"OpenSourceFailed": "Could not open source: {uuid}"
},
+ "Notice": {
+ "Title": "Notes",
+ "InactiveModuleReferences": "The active world references files from inactive modules. These module targets were not treated as unanchored: {modules}"
+ },
"Summary": {
"NoAnalysis": "No analysis has been run yet.",
"Files": "Files",
diff --git a/module.json b/module.json
index d455ada..c17420b 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.23",
+ "version": "0.0.24",
"compatibility": {
"minimum": "13",
"verified": "13"
diff --git a/scripts/adapters/foundry-runtime.js b/scripts/adapters/foundry-runtime.js
index 79e0b3b..ebcadd1 100644
--- a/scripts/adapters/foundry-runtime.js
+++ b/scripts/adapters/foundry-runtime.js
@@ -122,7 +122,8 @@ export async function runRuntimeAnalysis({ onProgress }={}) {
listFiles: () => listFoundryFiles(onProgress),
listSources: () => listFoundrySources(onProgress),
onProgress,
- i18n: { format }
+ i18n: { format },
+ packageActivity: collectPackageActivity()
});
}
@@ -221,3 +222,13 @@ function findEmbeddedIndex(path, segment) {
const candidate = path[index + 1];
return Number.isInteger(candidate) ? candidate : null;
}
+
+function collectPackageActivity() {
+ const modules = new Map();
+ const moduleLabels = new Map();
+ for (const module of game.modules.values()) {
+ modules.set(module.id, !!module.active);
+ moduleLabels.set(module.id, module.title ?? module.id);
+ }
+ return { modules, moduleLabels };
+}
diff --git a/scripts/apps/audit-report-app.js b/scripts/apps/audit-report-app.js
index 9e751e8..7415797 100644
--- a/scripts/apps/audit-report-app.js
+++ b/scripts/apps/audit-report-app.js
@@ -38,6 +38,7 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV
hasAnalysis: !!this.#analysis,
showAll: this.#showAll,
progress: this.#progress,
+ notices: this.#analysis?.notices ?? [],
summary: this.#summarize(this.#analysis),
groupedFindings
};
@@ -89,6 +90,7 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV
${renderProgress(context.progress, context.loading)}
+ ${renderNotices(context.notices, context.loading)}
${renderSummary(context.summary, context.loading)}
${renderGroupedFindingList(context.groupedFindings, context.hasAnalysis, context.loading, context.showAll)}
`;
@@ -141,6 +143,7 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV
if (!this.#analysis) return;
const payload = {
exportedAt: new Date().toISOString(),
+ notices: this.#analysis.notices ?? [],
summary: this.#summarize(this.#analysis),
groupedFindings: groupFindings(this.#analysis.findings),
findings: this.#analysis.findings.map(serializeFinding)
@@ -232,6 +235,17 @@ function renderProgress(progress, loading) {
`;
}
+function renderNotices(notices, loading) {
+ if (loading || !notices?.length) return "";
+ const items = notices.map(notice => `
${escapeHtml(notice.message ?? "")}`).join("");
+ return `
+
+ ${localize("KSA.Notice.Title")}
+
+
+ `;
+}
+
function renderSummary(summary, loading) {
if (loading) return "";
if (!summary) {
diff --git a/scripts/core/analyzer.js b/scripts/core/analyzer.js
index 180ba87..09c752f 100644
--- a/scripts/core/analyzer.js
+++ b/scripts/core/analyzer.js
@@ -2,7 +2,7 @@ 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, i18n }={}) {
+export async function analyzeStorage({ listFiles, listSources, onProgress, i18n, packageActivity }={}) {
const files = [];
let fileCount = 0;
let sourceCount = 0;
@@ -46,16 +46,16 @@ export async function analyzeStorage({ listFiles, listSources, onProgress, i18n
}
onProgress?.({ phase: "findings", label: format(i18n, "KSA.Progress.ClassifyFindings"), files: fileCount, sources: sourceCount, references: referenceCount });
- const findings = buildFindings({ files, references, i18n });
+ const result = buildFindings({ files, references, i18n, packageActivity });
onProgress?.({
phase: "done",
label: format(i18n, "KSA.Progress.Completed"),
files: fileCount,
sources: sourceCount,
references: referenceCount,
- findings: findings.length
+ findings: result.findings.length
});
- return { files, references, findings };
+ return { files, references, findings: result.findings, notices: result.notices ?? [] };
}
async function yieldToUI() {
diff --git a/scripts/core/finding-engine.js b/scripts/core/finding-engine.js
index a7fdae7..bff930c 100644
--- a/scripts/core/finding-engine.js
+++ b/scripts/core/finding-engine.js
@@ -32,7 +32,7 @@ function compareOwner(sourceScope, targetOwner) {
return "allowed";
}
-export function buildFindings({ files, references, i18n }={}) {
+export function buildFindings({ files, references, i18n, packageActivity }={}) {
const fileByLocator = new Map(files.map(file => [createCanonicalLocator(file.storage, file.path), file]));
const fileLocators = files.map(file => ({
storage: file.storage,
@@ -43,6 +43,7 @@ export function buildFindings({ files, references, i18n }={}) {
const refsByLocator = new Map();
const wildcardReferences = [];
const findings = [];
+ const inactiveModuleReferenceIds = new Set();
for (const reference of resolvedReferences) {
const normalized = reference.normalized;
@@ -84,6 +85,10 @@ export function buildFindings({ files, references, i18n }={}) {
const ownerRelation = compareOwner(reference.sourceScope, file.ownerHint);
if (ownerRelation === "non-package-to-package") {
+ if (isInactiveModuleTarget(file, packageActivity)) {
+ inactiveModuleReferenceIds.add(file.ownerHint.ownerId);
+ continue;
+ }
if (isAnchoredInOwningPackage(file, matchingReferencesForFile(file, refsByLocator, wildcardReferences))) continue;
findings.push({
kind: "non-package-to-package-reference",
@@ -137,7 +142,10 @@ export function buildFindings({ files, references, i18n }={}) {
});
}
- return findings;
+ return {
+ findings,
+ notices: createNotices({ inactiveModuleReferenceIds, packageActivity, i18n })
+ };
}
function resolveReferenceTarget(reference, fileByLocator, fileLocators) {
@@ -225,6 +233,27 @@ function shouldReportOrphan(file, references) {
return false;
}
+function isInactiveModuleTarget(file, packageActivity) {
+ if (file.ownerHint?.ownerType !== "module") return false;
+ const active = packageActivity?.modules?.get?.(file.ownerHint.ownerId);
+ return active === false;
+}
+
+function createNotices({ inactiveModuleReferenceIds, packageActivity, i18n }) {
+ const moduleIds = [...inactiveModuleReferenceIds].sort((a, b) => a.localeCompare(b));
+ if (!moduleIds.length) return [];
+ const moduleLabels = moduleIds.map(id => packageActivity?.moduleLabels?.get?.(id) ?? id);
+ return [{
+ kind: "inactive-module-references",
+ severity: "info",
+ moduleIds,
+ moduleLabels,
+ message: format(i18n, "KSA.Notice.InactiveModuleReferences", {
+ modules: moduleLabels.join(", ")
+ })
+ }];
+}
+
function isDerivedSceneThumbnail(file) {
const path = String(file.path ?? "");
return /^worlds\/[^/]+\/assets\/scenes\/[^/]+-thumb\.(?:png|webp)$/u.test(path);
diff --git a/styles/audit.css b/styles/audit.css
index 97bcc84..1b99e5e 100644
--- a/styles/audit.css
+++ b/styles/audit.css
@@ -30,6 +30,11 @@
padding: 1.1rem 1.2rem;
}
+.storage-audit__summary--notices {
+ padding-top: 0.95rem;
+ padding-bottom: 0.95rem;
+}
+
.storage-audit__grouped {
display: grid;
gap: 1rem;
@@ -221,6 +226,13 @@
border-bottom: 0;
}
+.storage-audit__notice-list {
+ margin: 0;
+ padding-left: 1.2rem;
+ display: grid;
+ gap: 0.3rem;
+}
+
.storage-audit__list {
padding: 1rem;
}
diff --git a/tests/inactive-module-reference-test.mjs b/tests/inactive-module-reference-test.mjs
new file mode 100644
index 0000000..18edc09
--- /dev/null
+++ b/tests/inactive-module-reference-test.mjs
@@ -0,0 +1,40 @@
+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-module/icons/token.webp"), 1234)
+];
+
+const references = [
+ {
+ sourceType: "world-document",
+ sourceScope: { ownerType: "world", ownerId: "demo-world", systemId: "demo-system", subtype: "actors" },
+ sourceLabel: "Actor demo",
+ normalized: {
+ ...createFileLocator("data", "modules/example-module/icons/token.webp"),
+ targetKind: "local-file"
+ }
+ }
+];
+
+const packageActivity = {
+ modules: new Map([["example-module", false]]),
+ moduleLabels: new Map([["example-module", "Example Module"]])
+};
+
+const result = buildFindings({ files, references, packageActivity, i18n: { format: (key, data={}) => `${key}:${data.modules ?? ""}` } });
+
+assert.equal(
+ result.findings.some(finding => finding.kind === "non-package-to-package-reference"),
+ false,
+ "world references into inactive modules should not be reported as unanchored package targets"
+);
+
+assert.equal(
+ result.notices.some(notice => notice.kind === "inactive-module-references" && notice.moduleIds.includes("example-module")),
+ true,
+ "inactive module references should create a report notice"
+);
+
+console.log("inactive-module-reference-test: ok");
diff --git a/tests/scene-thumbnail-orphan-test.mjs b/tests/scene-thumbnail-orphan-test.mjs
index cfc6c67..bc2789a 100644
--- a/tests/scene-thumbnail-orphan-test.mjs
+++ b/tests/scene-thumbnail-orphan-test.mjs
@@ -19,7 +19,7 @@ const references = [
}
];
-const findings = buildFindings({ files, references });
+const { findings } = buildFindings({ files, references });
assert.equal(
findings.some(finding => finding.kind === "orphan-file" && finding.target.locator === "data:worlds/demo-world/assets/scenes/exampleScene-thumb.webp"),
diff --git a/tests/storage-root-resolution-test.mjs b/tests/storage-root-resolution-test.mjs
index 3fcd86f..9c57d9c 100644
--- a/tests/storage-root-resolution-test.mjs
+++ b/tests/storage-root-resolution-test.mjs
@@ -19,7 +19,7 @@ const references = [
}
];
-const findings = buildFindings({ files, references });
+const { findings } = buildFindings({ files, references });
assert.equal(
findings.some(finding => finding.kind === "broken-reference" && finding.target.locator === "public:canvas/background_paper_16x9_4k.webp"),
diff --git a/tests/wildcard-reference-test.mjs b/tests/wildcard-reference-test.mjs
index 6828a93..9f0e556 100644
--- a/tests/wildcard-reference-test.mjs
+++ b/tests/wildcard-reference-test.mjs
@@ -20,7 +20,7 @@ const references = [
}
];
-const findings = buildFindings({ files, references });
+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"),