From 0c32fd513184159cd0a926b280b10f4e1bc597d2 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 1 Oct 2026 12:46:04 +0200 Subject: [PATCH 1/5] fix(kit): read module kind through kindOfMeta in push and the stdlib boundary --- packages/kit/src/push.ts | 6 ++- packages/kit/src/stdlib-boundary.ts | 4 +- packages/kit/src/vault-metadata.ts | 9 ++++ packages/kit/test/kind-cases.ts | 29 +++++++++++++ packages/kit/test/push.test.mts | 49 ++++++++++++++++++++++ packages/kit/test/stdlib-boundary.test.mts | 8 ++++ 6 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 packages/kit/test/kind-cases.ts diff --git a/packages/kit/src/push.ts b/packages/kit/src/push.ts index 87fa697..ac7b0db 100644 --- a/packages/kit/src/push.ts +++ b/packages/kit/src/push.ts @@ -15,6 +15,8 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; +import { KIND_OF_META_SOURCE } from "./vault-metadata.ts"; + export interface LocalModuleRecord { metadata: Record; files: Record; @@ -299,8 +301,8 @@ export function pushExpression(rec: LocalModuleRecord, id: string, nonce: string if (reenabled) await (M.reload ?? M.enable)(id); // Re-enabling a theme the loader just unloaded would fight the // single-active-theme invariant and knock the pushed theme back off. - // metadata.json says "kind"; modules published before it carry "tags". - const themed = (meta) => meta?.kind === "theme" || (meta?.tags ?? []).includes("theme"); + const kindOf = ${KIND_OF_META_SOURCE}; + const themed = (meta) => kindOf(meta) === "theme"; const pushedIsTheme = themed(rec.metadata); const isTheme = (mid) => themed(M.manifest?.modules?.find((m) => m.identifier === mid)); for (const other of before) { diff --git a/packages/kit/src/stdlib-boundary.ts b/packages/kit/src/stdlib-boundary.ts index e5f22da..4f7a98a 100644 --- a/packages/kit/src/stdlib-boundary.ts +++ b/packages/kit/src/stdlib-boundary.ts @@ -6,6 +6,8 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; +import { kindOfMeta } from "./vault-metadata.ts"; + export const STDLIB_BOUNDARY_WARNING_RULES = ["ambient-client", "client-dom", "direct-map"] as const; export type StdlibBoundaryWarningRule = (typeof STDLIB_BOUNDARY_WARNING_RULES)[number]; export type StdlibBoundarySourceRule = StdlibBoundaryWarningRule | "private-stdlib-import"; @@ -237,7 +239,7 @@ export function checkExternalStdlibBoundary(root: string, metadata: unknown): Ex const out = [...parsed.findings]; const entries = meta.entries as { js?: unknown } | undefined; if (typeof entries?.js !== "string" || meta.name === "stdlib") return out; - const isTheme = meta.kind === "theme" || (Array.isArray(meta.tags) && meta.tags.some((tag) => tag === "theme")); + const isTheme = kindOfMeta(meta) === "theme"; const dependencies = meta.dependencies; const missingDependency = diff --git a/packages/kit/src/vault-metadata.ts b/packages/kit/src/vault-metadata.ts index d40e07a..d6b5c96 100644 --- a/packages/kit/src/vault-metadata.ts +++ b/packages/kit/src/vault-metadata.ts @@ -44,6 +44,15 @@ export const kindOfMeta = (meta: Record): VaultKind | undefined return KINDS.find((kind) => tags.includes(kind)); }; +// kindOfMeta as JavaScript source, for expressions evaluated inside the +// client where kit code cannot be imported. It also accepts a missing meta. +export const KIND_OF_META_SOURCE = `((meta) => { + const kinds = ${JSON.stringify(KINDS)}; + if (typeof meta?.kind === "string" && kinds.includes(meta.kind)) return meta.kind; + const tags = Array.isArray(meta?.tags) ? meta.tags : []; + return kinds.find((kind) => tags.includes(kind)); +})`; + // metadata.json authors are plain names; author objects (with a github) // pass through, so an artifact may declare either. const normalizeAuthors = (authors: unknown[]): VaultAuthor[] => diff --git a/packages/kit/test/kind-cases.ts b/packages/kit/test/kind-cases.ts new file mode 100644 index 0000000..c43cbd1 --- /dev/null +++ b/packages/kit/test/kind-cases.ts @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 Afonso Jorge Ramos + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +// metadata.json shapes every copy of the kind rule must read the same way: +// `kind` wins, `tags` is the fallback, unknown kinds are ignored. +export const KIND_CASES: Record[] = [ + {}, + { kind: "theme" }, + { kind: "extension" }, + { kind: "snippet" }, + { kind: "app" }, + { kind: "lib" }, + { kind: "nonsense" }, + { kind: 5, tags: ["theme"] }, + { tags: ["theme"] }, + { tags: ["retro", "theme", "dark"] }, + { tags: ["snippet"] }, + { tags: ["retro", "dark"] }, + { tags: ["theme", "extension"] }, + { tags: [] }, + { tags: "theme" }, + { tags: [1, "theme"] }, + { kind: "extension", tags: ["theme"] }, + { kind: "theme", tags: ["extension"] }, + { kind: "nonsense", tags: ["theme"] }, + { kind: "Theme" }, +]; diff --git a/packages/kit/test/push.test.mts b/packages/kit/test/push.test.mts index a4a5540..3343457 100644 --- a/packages/kit/test/push.test.mts +++ b/packages/kit/test/push.test.mts @@ -14,6 +14,8 @@ import { type LocalModuleRecord, pushExpression, } from "../src/push.ts"; +import { KIND_OF_META_SOURCE, kindOfMeta } from "../src/vault-metadata.ts"; +import { KIND_CASES } from "./kind-cases.ts"; // Build a record whose serialized size (code units) is ~bytes via a filler. function recOfSize(bytes: number): LocalModuleRecord { @@ -106,3 +108,50 @@ for (const declared of [{ kind: "theme" }, { tags: ["theme"] }]) { assert.deepEqual(enabled, [], "the old theme stays unloaded"); }); } + +test("the in-page kind rule matches kindOfMeta", () => { + const inPage = runInNewContext(KIND_OF_META_SOURCE) as (meta: unknown) => unknown; + for (const meta of KIND_CASES) assert.equal(inPage(meta), kindOfMeta(meta), JSON.stringify(meta)); + assert.equal(inPage(undefined), undefined); +}); + +// Installing unloads every other module, so what push re-enables shows which +// modules it judged to be themes. +async function reenabledAfterPush(pushed: Record, other: Record) { + const modules = [ + { identifier: "other", ...other }, + { identifier: "pushed", ...pushed }, + ]; + const state = new Set(["other"]); + const enabled: string[] = []; + const Modules = { + manifest: { modules }, + report: { failed: {} }, + list: () => modules.map((m) => ({ identifier: m.identifier, loaded: state.has(m.identifier) })), + installLocal: async (id: string) => { + state.clear(); + state.add(id); + return {}; + }, + enable: async (id: string) => { + enabled.push(id); + state.add(id); + }, + }; + const rec = { metadata: { identifier: "pushed", version: "0.1.0", ...pushed }, files: {} } as never; + await runInNewContext(pushExpression(rec, "pushed", "n", false), { + Spicetify: { Modules }, + setTimeout, + JSON, + globalThis: { Spicetify: { Modules } }, + }); + return enabled.includes("other"); +} + +for (const meta of KIND_CASES) { + test(`push judges ${JSON.stringify(meta)} a theme exactly when kindOfMeta does`, async () => { + const theme = kindOfMeta(meta) === "theme"; + assert.equal(await reenabledAfterPush(meta, { kind: "theme" }), !theme, "as the pushed module"); + assert.equal(await reenabledAfterPush({ kind: "theme" }, meta), !theme, "as an installed module"); + }); +} diff --git a/packages/kit/test/stdlib-boundary.test.mts b/packages/kit/test/stdlib-boundary.test.mts index cfcfc56..9179a78 100644 --- a/packages/kit/test/stdlib-boundary.test.mts +++ b/packages/kit/test/stdlib-boundary.test.mts @@ -130,4 +130,12 @@ describe("external stdlib boundary", () => { assert.ok(!findings.includes("warn:stdlib-boundary.ambient-client")); assert.ok(!findings.includes("warn:stdlib-boundary.client-dom")); }); + + it("reads the kind the way kindOfMeta does", () => { + const files = { "index.ts": "export function load() {}" }; + const legacyTheme = moduleFixture(files, { kind: undefined, tags: ["theme"], dependencies: {} }); + assert.ok(!rules(legacyTheme).includes("error:stdlib-boundary.dependency"), "tags is the fallback"); + const declared = moduleFixture(files, { kind: "extension", tags: ["theme"], dependencies: {} }); + assert.ok(rules(declared).includes("error:stdlib-boundary.dependency"), "kind wins over tags"); + }); }); From 208b9a3633fb4e25a79acc35bfaa9e2ff0195992 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 1 Oct 2026 12:46:05 +0200 Subject: [PATCH 2/5] refactor(scripts): read module kind through kindOfMeta --- scripts/client-boundary.test.mts | 3 ++- scripts/preview.ts | 7 +++---- scripts/settings-ownership.test.mts | 6 ++++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/scripts/client-boundary.test.mts b/scripts/client-boundary.test.mts index 537c89e..887a669 100644 --- a/scripts/client-boundary.test.mts +++ b/scripts/client-boundary.test.mts @@ -4,6 +4,7 @@ import path from "node:path"; import { test } from "node:test"; import { checkSource } from "../packages/kit/src/check.ts"; +import { kindOfMeta } from "../packages/kit/src/vault-metadata.ts"; const modulesRoot = path.resolve("modules"); @@ -28,7 +29,7 @@ test("hosted extensions use the stdlib client capability boundary", () => { const directory = path.join(modulesRoot, entry); if (!statSync(directory).isDirectory()) continue; const metadata = JSON.parse(readFileSync(path.join(directory, "metadata.json"), "utf8")); - if (metadata.kind !== "extension") continue; + if (kindOfMeta(metadata) !== "extension") continue; for (const file of sourceFiles(directory)) { const relative = path.relative(directory, file); diff --git a/scripts/preview.ts b/scripts/preview.ts index 699bb4c..e57ccd8 100644 --- a/scripts/preview.ts +++ b/scripts/preview.ts @@ -20,6 +20,8 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { kindOfMeta } from "../packages/kit/src/vault-metadata.ts"; + const REPO_RAW = "https://raw.githubusercontent.com/spicetify/modules/main/previews"; // 24x24 path markup, drawn in currentColor. @@ -56,8 +58,6 @@ const prettify = (id: string) => .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)) .join(" "); -const KINDS = ["extension", "theme", "snippet", "app", "lib"]; - function render(id: string, title: string, category: string, icon: string, accent: string): string { const paths = ICONS[icon] ?? ICONS.bolt; return ` @@ -97,8 +97,7 @@ function main(): void { accent: FALLBACK_ACCENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % FALLBACK_ACCENTS.length], }; const title = style.title ?? prettify(meta.name ?? id); - const category = - (KINDS.includes(meta.kind) ? meta.kind : KINDS.find((k) => (meta.tags ?? []).includes(k))) ?? "module"; + const category = kindOfMeta(meta) ?? "module"; const out = path.join("previews", `${id}.svg`); writeFileSync(out, render(id, title, category, style.icon, style.accent)); meta.preview = `${REPO_RAW}/${id}.svg`; diff --git a/scripts/settings-ownership.test.mts b/scripts/settings-ownership.test.mts index 9414a3d..1358da0 100644 --- a/scripts/settings-ownership.test.mts +++ b/scripts/settings-ownership.test.mts @@ -7,6 +7,8 @@ import assert from "node:assert/strict"; import { readdirSync, readFileSync } from "node:fs"; import { describe, it } from "node:test"; +import { kindOfMeta } from "../packages/kit/src/vault-metadata.ts"; + const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); const settingsPageModules = [ @@ -48,8 +50,8 @@ describe("first-party settings ownership", () => { const discovered = readdirSync(new URL("../modules/", import.meta.url)) .filter((id) => { try { - const metadata = JSON.parse(read(`modules/${id}/metadata.json`)) as { kind?: string }; - return metadata.kind === "app" || metadata.kind === "extension"; + const kind = kindOfMeta(JSON.parse(read(`modules/${id}/metadata.json`))); + return kind === "app" || kind === "extension"; } catch { return false; } From 3c58bc424ae6bd422a9cb3f840f596aeffb1896b Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 1 Oct 2026 12:46:05 +0200 Subject: [PATCH 3/5] test: fail on tags reads outside kindOfMeta --- scripts/kind-of-meta.test.mts | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 scripts/kind-of-meta.test.mts diff --git a/scripts/kind-of-meta.test.mts b/scripts/kind-of-meta.test.mts new file mode 100644 index 0000000..7c58445 --- /dev/null +++ b/scripts/kind-of-meta.test.mts @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2026 Afonso Jorge Ramos + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import assert from "node:assert/strict"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; + +const root = path.resolve(import.meta.dirname, ".."); +const SCANNED = ["packages/kit/src", "scripts"]; +const SOURCE = /\.[cm]?[jt]sx?$/; +// Legacy `tags` is read only by kindOfMeta and its in-page twin, which +// packages/kit/test/push.test.mts holds equal to it. +const ALLOWED = new Set(["packages/kit/src/vault-metadata.ts", "scripts/kind-of-meta.test.mts"]); +const TAGS_READ = /\.tags\b|\[\s*["'`]tags["'`]\s*\]/; + +function sources(directory: string): string[] { + return readdirSync(directory).flatMap((entry) => { + const full = path.join(directory, entry); + if (statSync(full).isDirectory()) return entry === "node_modules" ? [] : sources(full); + return SOURCE.test(entry) ? [full] : []; + }); +} + +test("kit and scripts read a module's kind only through kindOfMeta", () => { + const reads: string[] = []; + for (const directory of SCANNED) { + for (const file of sources(path.join(root, directory))) { + const relative = path.relative(root, file).split(path.sep).join("/"); + if (ALLOWED.has(relative)) continue; + readFileSync(file, "utf8") + .split("\n") + .forEach((line, index) => { + if (TAGS_READ.test(line)) reads.push(`${relative}:${index + 1}: ${line.trim()}`); + }); + } + } + assert.deepEqual(reads, [], "use kindOfMeta from packages/kit/src/vault-metadata.ts"); +}); From 5ffb64686721519a2900c186b6965a13c9a36b2c Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 1 Oct 2026 12:46:12 +0200 Subject: [PATCH 4/5] fix(store): ignore a non-array tags field when deriving kind --- modules/store/catalog.test.mts | 8 ++++++++ modules/store/catalog.ts | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/modules/store/catalog.test.mts b/modules/store/catalog.test.mts index b404d3c..3561712 100644 --- a/modules/store/catalog.test.mts +++ b/modules/store/catalog.test.mts @@ -15,6 +15,8 @@ import { searchHaystack, type VaultModule, } from "./catalog.ts"; +import { kindOfMeta } from "../../packages/kit/src/vault-metadata.ts"; +import { KIND_CASES } from "../../packages/kit/test/kind-cases.ts"; const mod = (over: Partial = {}): VaultModule => ({ id: "sample", @@ -106,6 +108,12 @@ describe("card derivations", () => { assert.equal(kindOf({ tags: ["retro", "dark"] }), "extension"); }); + it("kindOf follows the kit's kindOfMeta, defaulting to extension", () => { + for (const meta of KIND_CASES) { + assert.equal(kindOf(meta as never), kindOfMeta(meta) ?? "extension", JSON.stringify(meta)); + } + }); + it("displayVersion strips the classmap build-metadata suffix", () => { assert.equal(displayVersion("1.2.0+cm-1020094-19f856aefd5"), "1.2.0"); assert.equal(displayVersion("1.2.0"), "1.2.0"); diff --git a/modules/store/catalog.ts b/modules/store/catalog.ts index ae6417c..087e879 100644 --- a/modules/store/catalog.ts +++ b/modules/store/catalog.ts @@ -282,7 +282,8 @@ const KINDS: ModuleKind[] = ["extension", "theme", "snippet", "app", "lib"]; // is inert: it never joins the single-theme contest. export const kindOf = (meta: { kind?: string; tags?: string[] } | undefined): ModuleKind => { if (meta?.kind && (KINDS as string[]).includes(meta.kind)) return meta.kind as ModuleKind; - return KINDS.find((kind) => meta?.tags?.includes(kind)) ?? "extension"; + const tags = Array.isArray(meta?.tags) ? meta.tags : []; + return KINDS.find((kind) => tags.includes(kind)) ?? "extension"; }; // Vault version keys carry a "+cm--" build-metadata suffix From 9701d3726f6ec249206a43cb6da0a6e78679d84c Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 1 Oct 2026 12:46:25 +0200 Subject: [PATCH 5/5] chore(store): bump to 1.8.1 --- modules/store/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/store/metadata.json b/modules/store/metadata.json index d93ae34..44ac7b7 100644 --- a/modules/store/metadata.json +++ b/modules/store/metadata.json @@ -1,7 +1,7 @@ { "name": "store", "kind": "app", - "version": "1.8.0", + "version": "1.8.1", "authors": ["spicetify"], "description": "Browse, install, and manage v3 modules from vaults", "entries": {