From 3891ce4ea4bf7c6b9e88e0d813fc5309c6905ff9 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:05:52 +0200 Subject: [PATCH 01/20] fix(classmap): store stock classes in the 1.2.99 and 1.3.0 leaves --- 1020099/classmap-19f856aefd5.json | 34 +++++++++++++++---------------- 1030000/classmap-19f856aefd5.json | 20 +++++++++--------- index.json | 4 ++-- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/1020099/classmap-19f856aefd5.json b/1020099/classmap-19f856aefd5.json index ec63a41..997be31 100644 --- a/1020099/classmap-19f856aefd5.json +++ b/1020099/classmap-19f856aefd5.json @@ -38,9 +38,9 @@ }, "navbar": { "link": { - "wrapper": "main-globalNav-navLink", - "container": "main-globalNav-navLink", - "container__active": "main-globalNav-navLinkActive" + "wrapper": "kUHE42xvQVzWqabl", + "container": "kUHE42xvQVzWqabl", + "container__active": "kxv3By32Og8yDEXy" } }, "global_navbar": {}, @@ -58,32 +58,32 @@ }, "modal": { "widget_generator": { - "container": "main-embedWidgetGenerator-container", + "container": "gx9qsprOAO4ubEFc", "header": { - "container": "main-embedWidgetGenerator-header", - "close": "main-embedWidgetGenerator-closeBtn" + "container": "g9sFYudWkUldbuNa", + "close": "Pk4GI8WfBMjS5R2J" }, "content": { - "container": "main-embedWidgetGenerator-content" + "container": "ywtSYUL2xRwVlvSo" } }, "track_credits": { - "container": "main-trackCreditsModalV2-container", + "container": "p5LJBmlswjcRT458", "header": { - "container": "main-trackCreditsModalV2-header", - "close": "main-trackCreditsModalV2-closeBtn" + "container": "idsrX6VAUY49Eczw", + "close": "_18qFNPzv7Eyyck8" }, "content": { - "container": "main-trackCreditsModalV2-mainSection" + "container": "yKG9lWaUWIBUyVCu" } } }, "settings": { "header": { - "container": "x-settings-outputSectionHeader" + "container": "KsnD09q3oqAjJX3e" }, "section": { - "container": "x-settings-section" + "container": "fNaaQ0Cp8Yzy19j8" }, "button": { "wrapper": "e-10810-legacy-button e-10810-legacy-button--small e-10810-legacy-button-secondary" @@ -96,14 +96,14 @@ }, "sort_box": { "list": { - "button": "x-sortBox-sortDropdown" + "button": "LQ6y2jhZZtMS7xCr" } }, "search_box": { - "container": "main-globalNav-searchContainer", - "expand_button": "e-10810-legacy-button e-10810-legacy-button-tertiary e-10810-button-tertiary--icon-only main-globalNav-link-icon" + "container": "YsP3Gj4aJDYsaOC5", + "expand_button": "e-10810-legacy-button e-10810-legacy-button-tertiary e-10810-button-tertiary--icon-only _Bg_zSvFrEutyacG" }, "context_menu": { - "menu_item": "main-contextMenu-menuItemButton" + "menu_item": "KzLH25pAEr43wpSc" } } diff --git a/1030000/classmap-19f856aefd5.json b/1030000/classmap-19f856aefd5.json index 0d5982b..f6d2cea 100644 --- a/1030000/classmap-19f856aefd5.json +++ b/1030000/classmap-19f856aefd5.json @@ -58,23 +58,23 @@ }, "modal": { "widget_generator": { - "container": "main-embedWidgetGenerator-container", + "container": "_XeAt3IgtZQepOYqU2g4", "header": { - "container": "main-embedWidgetGenerator-header", - "close": "main-embedWidgetGenerator-closeBtn" + "container": "mnU1hqlaF5JIcrTxLnZF", + "close": "AslPcD0TRZBBGZofe58g" }, "content": { - "container": "main-embedWidgetGenerator-content" + "container": "Xu_Z4l6TIOF1f5kDB9dt" } }, "track_credits": { - "container": "main-trackCreditsModalV2-container", + "container": "gtXQasDkhtg93wGk6yNG", "header": { - "container": "main-trackCreditsModalV2-header", - "close": "main-trackCreditsModalV2-closeBtn" + "container": "nQ_c7yoZ2XOD6fWtmv18", + "close": "CaHvWv36mAu4x5QTKlb1" }, "content": { - "container": "main-trackCreditsModalV2-mainSection" + "container": "wYQNBdH2rNMGe_B4oumf" } } }, @@ -96,7 +96,7 @@ }, "sort_box": { "list": { - "button": "x-sortBox-sortDropdown" + "button": "MYWr4UY4pKAAscUXvqrB" } }, "search_box": { @@ -104,6 +104,6 @@ "expand_button": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium aslJk0YypwKvyKXaBJcG" }, "context_menu": { - "menu_item": "main-contextMenu-menuItemButton" + "menu_item": "fmmygDM45MTS7I35OyLh" } } diff --git a/index.json b/index.json index 3e5912d..2a8d039 100644 --- a/index.json +++ b/index.json @@ -133,7 +133,7 @@ "1020099": { "classmap": { "file": "classmap-19f856aefd5.json", - "sha256": "d015b23c52199fe287ad60f1da8c34c168349d6dd3b9f6a66e874bda1740c2e6" + "sha256": "7a520fd3af3e3da2e79d0b6660fd9cafd16de9fb74041c434251f062de0db64a" }, "cssMapOverlay": { "file": "css-map.json", @@ -149,7 +149,7 @@ "1030000": { "classmap": { "file": "classmap-19f856aefd5.json", - "sha256": "f0a79a96c949b016686a4b4f2feaefee6540652135799133bfa3d1b1411f18d5" + "sha256": "17d9575c38a5a7cce9d6bb28665aff0c6e88b4ce4d94955bad297e19c97b33dc" }, "cssMapOverlay": { "file": "css-map.json", From 61fe4e95c0d3515399d0696a46bff37f4f2e6825 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:06:06 +0200 Subject: [PATCH 02/20] refactor(classmap): name every key's map classmap.json The hex suffix was a 1.2.94 build timestamp reused by maps with different contents; index.json already records each file and its digest. Drops the unindexed 1020040 map, a strict subset of the indexed one. --- ...ssmap-1718192021222.json => classmap.json} | 0 1020040/classmap-1906ea8d2e9.json | 103 ------------------ ...lassmap-190747c4b8f.json => classmap.json} | 0 ...lassmap-191b119b48e.json => classmap.json} | 0 ...lassmap-1a05f280501.json => classmap.json} | 0 ...lassmap-19f8522e902.json => classmap.json} | 0 ...lassmap-19f856aefd5.json => classmap.json} | 0 .../{classmap-1020095.json => classmap.json} | 0 ...lassmap-19f856aefd5.json => classmap.json} | 0 ...lassmap-19f856aefd5.json => classmap.json} | 0 ...lassmap-19f856aefd5.json => classmap.json} | 0 ...lassmap-19f856aefd5.json => classmap.json} | 0 ...lassmap-19f856aefd5.json => classmap.json} | 0 ...lassmap-19f856aefd5.json => classmap.json} | 0 index.json | 26 ++--- 15 files changed, 13 insertions(+), 116 deletions(-) rename 1020038/{classmap-1718192021222.json => classmap.json} (100%) delete mode 100644 1020040/classmap-1906ea8d2e9.json rename 1020040/{classmap-190747c4b8f.json => classmap.json} (100%) rename 1020045/{classmap-191b119b48e.json => classmap.json} (100%) rename 1020084/{classmap-1a05f280501.json => classmap.json} (100%) rename 1020092/{classmap-19f8522e902.json => classmap.json} (100%) rename 1020094/{classmap-19f856aefd5.json => classmap.json} (100%) rename 1020095/{classmap-1020095.json => classmap.json} (100%) rename 1020096/{classmap-19f856aefd5.json => classmap.json} (100%) rename 1020097/{classmap-19f856aefd5.json => classmap.json} (100%) rename 1020098/{classmap-19f856aefd5.json => classmap.json} (100%) rename 1020099/{classmap-19f856aefd5.json => classmap.json} (100%) rename 1030000/{classmap-19f856aefd5.json => classmap.json} (100%) rename 1030001/{classmap-19f856aefd5.json => classmap.json} (100%) diff --git a/1020038/classmap-1718192021222.json b/1020038/classmap.json similarity index 100% rename from 1020038/classmap-1718192021222.json rename to 1020038/classmap.json diff --git a/1020040/classmap-1906ea8d2e9.json b/1020040/classmap-1906ea8d2e9.json deleted file mode 100644 index acfd310..0000000 --- a/1020040/classmap-1906ea8d2e9.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "main": { - "topbar": { - "wrapper": "qHWqOt_TYlFxiF0Dm2fD", - "left": { - "button": { - "wrapper": "ql0zZd7giPXSnPg75NR0", - "icon": { - "wrapper": "IYDlXmBmmUKHveMzIPCF" - } - }, - "button_t": { - "wrapper": "rBX1EWVZ2EaPwP4y1Gkd" - } - }, - "right": { - "button": { - "wrapper": "WtC1lGbmQRplD6JBhNFU" - }, - "button_t": { - "wrapper": "OomFKn3bsxs5JfNUoWhz" - } - } - }, - "playbar": { - "widget": {}, - "controls": {}, - "buttons": { - "button": { - "wrapper": "KAZD28usA1vPz5GVpm63", - "wrapper__active": "RK45o6dbvO1mb0wQtSwq", - "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" - } - } - }, - "navbar": { - "link": { - "wrapper": "KAcp7QFuEYSouAsuC5i_", - "container": "hNvCMxbfz7HwgzLjt3IZ", - "container__active": "DzWw3g4E_66wu9ktqn36" - } - }, - "global_navbar": {}, - "sidebar": {}, - "panel": {} - }, - "tracklist": { - "column_header": "rGujAXjCLKEd_N6yTwds" - }, - "search_chips": { - "wrapper_wrapper": "qG4q41T8PJl0SkVgUeJc", - "wrapper": "XAwhJzXeTM5Iv2jLMCEj", - "container": "IDNDdMa6ACThrEsGWsXX", - "chip": "UnwG2v9ISmcUhnjKj22Y" - }, - "modal": { - "widget_generator": { - "container": "uUYNnjSt8m3EqVjsnHgh", - "header": { - "container": "bOIRpQiHUAEfp8ntStTo", - "close": "oBoIIlKrwQjxXpvOiOa0" - }, - "content": { - "container": "IJHNf0vxPSbPE1egoG4N" - } - }, - "track_credits": { - "container": "uV8q95GGAb2VDtL3gpYa", - "header": { - "container": "pGU_qEtNT1qWKjrRbvan", - "close": "VKCcyYujazVPj6VkksPM" - }, - "content": { - "container": "Nw1INlIyra3LT1JjvoqH" - } - } - }, - "settings": { - "header": { - "container": "O2vRz41OsAH0O7xVA4La" - }, - "section": { - "container": "k6GFKyOKVR5Ruofj3aTQ" - }, - "button": { - "wrapper": "rFFJg1UIumqUUFDgo6n7" - }, - "text_input": "SkbGMKYv49KtJNB5XxdX" - }, - "scrollable_text": { - "container": "bMurPtRDRv5LuN78MTVG", - "wrapper": "KjPUGV8uMbl_0bvk9ePv" - }, - "sort_box": { - "list": { - "button": "w6j_vX6SF5IxSXrrkYw5" - } - }, - "search_box": { - "container": "JzZyf6OGCGtdscOZGt8Y", - "expand_button": "wCl7pMTEE68v1xuZeZiB" - } -} diff --git a/1020040/classmap-190747c4b8f.json b/1020040/classmap.json similarity index 100% rename from 1020040/classmap-190747c4b8f.json rename to 1020040/classmap.json diff --git a/1020045/classmap-191b119b48e.json b/1020045/classmap.json similarity index 100% rename from 1020045/classmap-191b119b48e.json rename to 1020045/classmap.json diff --git a/1020084/classmap-1a05f280501.json b/1020084/classmap.json similarity index 100% rename from 1020084/classmap-1a05f280501.json rename to 1020084/classmap.json diff --git a/1020092/classmap-19f8522e902.json b/1020092/classmap.json similarity index 100% rename from 1020092/classmap-19f8522e902.json rename to 1020092/classmap.json diff --git a/1020094/classmap-19f856aefd5.json b/1020094/classmap.json similarity index 100% rename from 1020094/classmap-19f856aefd5.json rename to 1020094/classmap.json diff --git a/1020095/classmap-1020095.json b/1020095/classmap.json similarity index 100% rename from 1020095/classmap-1020095.json rename to 1020095/classmap.json diff --git a/1020096/classmap-19f856aefd5.json b/1020096/classmap.json similarity index 100% rename from 1020096/classmap-19f856aefd5.json rename to 1020096/classmap.json diff --git a/1020097/classmap-19f856aefd5.json b/1020097/classmap.json similarity index 100% rename from 1020097/classmap-19f856aefd5.json rename to 1020097/classmap.json diff --git a/1020098/classmap-19f856aefd5.json b/1020098/classmap.json similarity index 100% rename from 1020098/classmap-19f856aefd5.json rename to 1020098/classmap.json diff --git a/1020099/classmap-19f856aefd5.json b/1020099/classmap.json similarity index 100% rename from 1020099/classmap-19f856aefd5.json rename to 1020099/classmap.json diff --git a/1030000/classmap-19f856aefd5.json b/1030000/classmap.json similarity index 100% rename from 1030000/classmap-19f856aefd5.json rename to 1030000/classmap.json diff --git a/1030001/classmap-19f856aefd5.json b/1030001/classmap.json similarity index 100% rename from 1030001/classmap-19f856aefd5.json rename to 1030001/classmap.json diff --git a/index.json b/index.json index 2a8d039..f3fc392 100644 --- a/index.json +++ b/index.json @@ -6,25 +6,25 @@ "keys": { "1020038": { "classmap": { - "file": "classmap-1718192021222.json", + "file": "classmap.json", "sha256": "29522d09a1de092d6ba4c68f249d8a0bcfa1539d0c0f74f9347328443ec09d73" } }, "1020040": { "classmap": { - "file": "classmap-190747c4b8f.json", + "file": "classmap.json", "sha256": "d281c6c73b4ae40d9bddc5ade971b818a69325b0626e5347a3a47615df2fcde3" } }, "1020045": { "classmap": { - "file": "classmap-191b119b48e.json", + "file": "classmap.json", "sha256": "97dd9b8882fa3e1eb39b63d6d52303a6550147e8553276ddf7639dcfde9b29ef" } }, "1020084": { "classmap": { - "file": "classmap-1a05f280501.json", + "file": "classmap.json", "sha256": "e5800bf048dfe57043d5e3611da043d44f3480e1fa6dba4781085f9ec37c784e" }, "meta": { @@ -36,7 +36,7 @@ }, "1020092": { "classmap": { - "file": "classmap-19f8522e902.json", + "file": "classmap.json", "sha256": "817cf36500c70d90d6c77944e86095b084f0c56482c4ef9e345fc3c211ffaa53" }, "cssMapOverlay": { @@ -52,7 +52,7 @@ }, "1020094": { "classmap": { - "file": "classmap-19f856aefd5.json", + "file": "classmap.json", "sha256": "d69d189567b5ddb7fcafffa9f4e7d84ee8b2ed27160ae168ae7bd8c8d03d7956" }, "cssMapOverlay": { @@ -68,7 +68,7 @@ }, "1020095": { "classmap": { - "file": "classmap-1020095.json", + "file": "classmap.json", "sha256": "57cbc252b22f7fd914be7c8d7fc1e932168b28ebde8a4ecaa2129297fe6010a2" }, "cssMapOverlay": { @@ -84,7 +84,7 @@ }, "1020096": { "classmap": { - "file": "classmap-19f856aefd5.json", + "file": "classmap.json", "sha256": "d69d189567b5ddb7fcafffa9f4e7d84ee8b2ed27160ae168ae7bd8c8d03d7956" }, "cssMapOverlay": { @@ -100,7 +100,7 @@ }, "1020097": { "classmap": { - "file": "classmap-19f856aefd5.json", + "file": "classmap.json", "sha256": "d69d189567b5ddb7fcafffa9f4e7d84ee8b2ed27160ae168ae7bd8c8d03d7956" }, "cssMapOverlay": { @@ -116,7 +116,7 @@ }, "1020098": { "classmap": { - "file": "classmap-19f856aefd5.json", + "file": "classmap.json", "sha256": "d69d189567b5ddb7fcafffa9f4e7d84ee8b2ed27160ae168ae7bd8c8d03d7956" }, "cssMapOverlay": { @@ -132,7 +132,7 @@ }, "1020099": { "classmap": { - "file": "classmap-19f856aefd5.json", + "file": "classmap.json", "sha256": "7a520fd3af3e3da2e79d0b6660fd9cafd16de9fb74041c434251f062de0db64a" }, "cssMapOverlay": { @@ -148,7 +148,7 @@ }, "1030000": { "classmap": { - "file": "classmap-19f856aefd5.json", + "file": "classmap.json", "sha256": "17d9575c38a5a7cce9d6bb28665aff0c6e88b4ce4d94955bad297e19c97b33dc" }, "cssMapOverlay": { @@ -164,7 +164,7 @@ }, "1030001": { "classmap": { - "file": "classmap-19f856aefd5.json", + "file": "classmap.json", "sha256": "17d9575c38a5a7cce9d6bb28665aff0c6e88b4ce4d94955bad297e19c97b33dc" }, "cssMapOverlay": { From dd24e859060b147f57676891ad39eb4cabf78053 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:06:21 +0200 Subject: [PATCH 03/20] style(classmap): sort keys, indent with two spaces, drop empty groups --- 1020038/classmap.json | 66 +++-- 1020040/classmap.json | 213 +++++++------- 1020045/classmap.json | 619 ++++++++++++++++++++--------------------- 1020084/classmap.json | 2 +- 1020092/classmap.json | 117 ++++---- 1020094/classmap.json | 117 ++++---- 1020095/classmap.json | 117 ++++---- 1020096/classmap.json | 117 ++++---- 1020097/classmap.json | 117 ++++---- 1020098/classmap.json | 117 ++++---- 1020099/classmap.json | 117 ++++---- 1030000/classmap.json | 117 ++++---- 1030001/classmap.json | 117 ++++---- index.json | 362 ++++++++++++------------ scripts/build_index.py | 2 +- 15 files changed, 1132 insertions(+), 1185 deletions(-) diff --git a/1020038/classmap.json b/1020038/classmap.json index acc7879..14b7560 100644 --- a/1020038/classmap.json +++ b/1020038/classmap.json @@ -1,36 +1,34 @@ { - "main": { - "topbar": { - "left": { - "button": { - "wrapper": "ql0zZd7giPXSnPg75NR0", - "icon": { - "wrapper": "IYDlXmBmmUKHveMzIPCF" - } - }, - "button_t": { - "wrapper": "rBX1EWVZ2EaPwP4y1Gkd" - } - }, - "right": { - "button": { - "wrapper": "WtC1lGbmQRplD6JBhNFU" - }, - "button_t": { - "wrapper": "OomFKn3bsxs5JfNUoWhz" - } - } - }, - "playbar": { - "widget": {}, - "controls": {}, - "buttons": { - "button": { - "wrapper": "KAZD28usA1vPz5GVpm63", - "wrapper__active": "RK45o6dbvO1mb0wQtSwq", - "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" - } - } - } - } + "main": { + "playbar": { + "buttons": { + "button": { + "wrapper": "KAZD28usA1vPz5GVpm63", + "wrapper__active": "RK45o6dbvO1mb0wQtSwq", + "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" + } + } + }, + "topbar": { + "left": { + "button": { + "icon": { + "wrapper": "IYDlXmBmmUKHveMzIPCF" + }, + "wrapper": "ql0zZd7giPXSnPg75NR0" + }, + "button_t": { + "wrapper": "rBX1EWVZ2EaPwP4y1Gkd" + } + }, + "right": { + "button": { + "wrapper": "WtC1lGbmQRplD6JBhNFU" + }, + "button_t": { + "wrapper": "OomFKn3bsxs5JfNUoWhz" + } + } + } + } } diff --git a/1020040/classmap.json b/1020040/classmap.json index de002b9..cdde094 100644 --- a/1020040/classmap.json +++ b/1020040/classmap.json @@ -1,109 +1,104 @@ -{ - "main": { - "topbar": { - "wrapper": "qHWqOt_TYlFxiF0Dm2fD", - "left": { - "button": { - "wrapper": "ql0zZd7giPXSnPg75NR0", - "icon": { - "wrapper": "IYDlXmBmmUKHveMzIPCF" - } - }, - "button_t": { - "wrapper": "rBX1EWVZ2EaPwP4y1Gkd" - } - }, - "right": { - "button": { - "wrapper": "WtC1lGbmQRplD6JBhNFU" - }, - "button_t": { - "wrapper": "OomFKn3bsxs5JfNUoWhz" - }, - "upgrade_button": { - "wrapper": "Upqw01TOXETOmR5Td7Dj" - } - } - }, - "playbar": { - "widget": {}, - "controls": {}, - "buttons": { - "button": { - "wrapper": "KAZD28usA1vPz5GVpm63", - "wrapper__active": "RK45o6dbvO1mb0wQtSwq", - "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" - } - } - }, - "navbar": { - "link": { - "wrapper": "KAcp7QFuEYSouAsuC5i_", - "container": "hNvCMxbfz7HwgzLjt3IZ", - "container__active": "DzWw3g4E_66wu9ktqn36" - } - }, - "global_navbar": {}, - "sidebar": {}, - "panel": {} - }, - "tracklist": { - "column_header": "rGujAXjCLKEd_N6yTwds" - }, - "search_chips": { - "wrapper_wrapper": "qG4q41T8PJl0SkVgUeJc", - "wrapper": "XAwhJzXeTM5Iv2jLMCEj", - "container": "IDNDdMa6ACThrEsGWsXX", - "chip": "UnwG2v9ISmcUhnjKj22Y" - }, - "modal": { - "widget_generator": { - "container": "uUYNnjSt8m3EqVjsnHgh", - "header": { - "container": "bOIRpQiHUAEfp8ntStTo", - "close": "oBoIIlKrwQjxXpvOiOa0" - }, - "content": { - "container": "IJHNf0vxPSbPE1egoG4N" - } - }, - "track_credits": { - "container": "uV8q95GGAb2VDtL3gpYa", - "header": { - "container": "pGU_qEtNT1qWKjrRbvan", - "close": "VKCcyYujazVPj6VkksPM" - }, - "content": { - "container": "Nw1INlIyra3LT1JjvoqH" - } - } - }, - "settings": { - "header": { - "container": "O2vRz41OsAH0O7xVA4La" - }, - "section": { - "container": "k6GFKyOKVR5Ruofj3aTQ" - }, - "button": { - "wrapper": "rFFJg1UIumqUUFDgo6n7" - }, - "text_input": "SkbGMKYv49KtJNB5XxdX" - }, - "scrollable_text": { - "container": "bMurPtRDRv5LuN78MTVG", - "wrapper": "KjPUGV8uMbl_0bvk9ePv" - }, - "sort_box": { - "list": { - "button": "w6j_vX6SF5IxSXrrkYw5" - } - }, - "search_box": { - "container": "JzZyf6OGCGtdscOZGt8Y", - "expand_button": "wCl7pMTEE68v1xuZeZiB" - }, - "context_menu": { - "menu_item": "rQ6LXqVlEOGZdGIG0LgP" - } -} +{ + "context_menu": { + "menu_item": "rQ6LXqVlEOGZdGIG0LgP" + }, + "main": { + "navbar": { + "link": { + "container": "hNvCMxbfz7HwgzLjt3IZ", + "container__active": "DzWw3g4E_66wu9ktqn36", + "wrapper": "KAcp7QFuEYSouAsuC5i_" + } + }, + "playbar": { + "buttons": { + "button": { + "wrapper": "KAZD28usA1vPz5GVpm63", + "wrapper__active": "RK45o6dbvO1mb0wQtSwq", + "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" + } + } + }, + "topbar": { + "left": { + "button": { + "icon": { + "wrapper": "IYDlXmBmmUKHveMzIPCF" + }, + "wrapper": "ql0zZd7giPXSnPg75NR0" + }, + "button_t": { + "wrapper": "rBX1EWVZ2EaPwP4y1Gkd" + } + }, + "right": { + "button": { + "wrapper": "WtC1lGbmQRplD6JBhNFU" + }, + "button_t": { + "wrapper": "OomFKn3bsxs5JfNUoWhz" + }, + "upgrade_button": { + "wrapper": "Upqw01TOXETOmR5Td7Dj" + } + }, + "wrapper": "qHWqOt_TYlFxiF0Dm2fD" + } + }, + "modal": { + "track_credits": { + "container": "uV8q95GGAb2VDtL3gpYa", + "content": { + "container": "Nw1INlIyra3LT1JjvoqH" + }, + "header": { + "close": "VKCcyYujazVPj6VkksPM", + "container": "pGU_qEtNT1qWKjrRbvan" + } + }, + "widget_generator": { + "container": "uUYNnjSt8m3EqVjsnHgh", + "content": { + "container": "IJHNf0vxPSbPE1egoG4N" + }, + "header": { + "close": "oBoIIlKrwQjxXpvOiOa0", + "container": "bOIRpQiHUAEfp8ntStTo" + } + } + }, + "scrollable_text": { + "container": "bMurPtRDRv5LuN78MTVG", + "wrapper": "KjPUGV8uMbl_0bvk9ePv" + }, + "search_box": { + "container": "JzZyf6OGCGtdscOZGt8Y", + "expand_button": "wCl7pMTEE68v1xuZeZiB" + }, + "search_chips": { + "chip": "UnwG2v9ISmcUhnjKj22Y", + "container": "IDNDdMa6ACThrEsGWsXX", + "wrapper": "XAwhJzXeTM5Iv2jLMCEj", + "wrapper_wrapper": "qG4q41T8PJl0SkVgUeJc" + }, + "settings": { + "button": { + "wrapper": "rFFJg1UIumqUUFDgo6n7" + }, + "header": { + "container": "O2vRz41OsAH0O7xVA4La" + }, + "section": { + "container": "k6GFKyOKVR5Ruofj3aTQ" + }, + "text_input": "SkbGMKYv49KtJNB5XxdX" + }, + "sort_box": { + "list": { + "button": "w6j_vX6SF5IxSXrrkYw5" + } + }, + "tracklist": { + "column_header": "rGujAXjCLKEd_N6yTwds" + } +} diff --git a/1020045/classmap.json b/1020045/classmap.json index efc3acf..7cf387d 100644 --- a/1020045/classmap.json +++ b/1020045/classmap.json @@ -1,312 +1,311 @@ { - "main": { - "wrapper": "ZQftYELq0aOsg6tPbVbV", - "topbar": { - "wrapper": "qHWqOt_TYlFxiF0Dm2fD", - "left": { - "button": { - "wrapper": "ql0zZd7giPXSnPg75NR0", - "icon": { - "wrapper": "IYDlXmBmmUKHveMzIPCF" - } - }, - "button_t": { - "wrapper": "rBX1EWVZ2EaPwP4y1Gkd" - } - }, - "right": { - "button": { - "wrapper": "WtC1lGbmQRplD6JBhNFU" - }, - "button_t": { - "wrapper": "OomFKn3bsxs5JfNUoWhz" - } - } - }, - "playbar": { - "wrapper": "JG5J9NWJkaUO9fiKECMA", - "footer": "yglmI5m3fCc8baD1Kwdw", - "container": "udArIAqnfUQPQew2VAns", - "widget": { - "wrapper": "snFK6_ei0caqvFI6As9Q", - "container": "deomraqfhIAoSB3SgXpu", - "cover": { - "wrapper": "GQ5_gIWzIqAfBdmQm8yJ", - "button": "HD9s7U5E1RLSWKpXmrqx", - "art": { - "wrapper": "BFR9Zt3zpL8BATBMiwQB", - "container": "H0HbpIM3UrcupWIAjLWu", - "image": "FqmFsMhuF4D0s35Z62Js" - } - }, - "info": { - "container": "j96cpCtZAIdqxcDrYHPI", - "title_text": "PcH6VnzkkDqD36P93i9Q", - "artist_text": "w_TTPh4y9H1YD6UrTMHa", - "text_content": { - "wrapper__wrapper": "eSMjmiD29Ox35O95waw6", - "wrapper": "W5cB_o0XkkU7Q8tlTGxq", - "content": "PGSe59fD1Hwc9yUM2d3U" - } - } - }, - "controls": { - "wrapper": "sVv2OQORCQ4kf6iKfUTF", - "container": "gItY2hnfCB4TsDJCkPiO", - "buttons": { - "wrapper": "XrZ1iHVHAPMya3jkB2sa", - "left": "NKUrT1GciYXAEEUtagN1", - "previous": "fn72ari9aEmKo4JcwteT", - "right": "Qt226Z4rBQs53aedRQBQ", - "next": "mnipjT4SLDMgwiDCEnRC", - "play": "cLkUmr", - "loop": "Vz6yjzttS0YlLcwrkoUR", - "loop_active": "tP0mccyU1WAa7I9PevC1" - } - }, - "progress": { - "wrapper": "w6j_vX6SF5IxSXrrkYw5", - "container": "pn5V0OzovI9p6b8nWq8p", - "time-elapsed": "IPbBrI6yF4zhaizFmrg6", - "time-total": "kQqIrFPM5PjMWb5qUS56", - "bar": "B1vgcMXBqOxgMxXh5j1f" - }, - "buttons": { - "wrapper": "pLifNBuHRY8cZkZyEqwL", - "container": "mwpJrmCgLlVkJVtWjlI1", - "volume": "rT09bwCEwXECMbLbX_7A", - "button": { - "wrapper": "KAZD28usA1vPz5GVpm63", - "wrapper__active": "RK45o6dbvO1mb0wQtSwq", - "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" - } - } - }, - "global_navbar": {}, - "main_view": { - "wrapper": "jEMA2gVoLgPQqAFrPhFw", - "topbar": { - "wrapper": "facDIsOQo9q7kiWc4jSg", - "background": "T1xI1RTSFU7Wu94UuvE6", - "background_overlay": "EvIR4O7jOSbNmxtMdIQ0", - "container": "hV9v6y_uYwdAsoiOHpzk" - }, - "content_container": "T0fKO6B7LQSCE_VaSM1P", - "gradient": "S4OmZ_IZexmZ5dasPqW5", - "header": { - "container": "NXiYChVp4Oydfxd7rT5r", - "background": "gHImFiUWOg93pvTefeAD", - "background_overlay": "xYgjMpAjE5XT05aRIezb", - "contents": { - "container": "iWTIFTzhRZT0rCD0_gOK", - "image": { - "container": "_gLjHpwOxHFwo5nLM8hb", - "wrapper": "Ws8Ec3GREpT5PAUesr9b", - "content": "CmkY1Ag0tJDfnFXbGgju" - }, - "info": { - "container": "RP2rRchy4i8TIp1CTmb7", - "pretitle": "lp9Tfm4rsM9_pfbIE0zd", - "title": "rEN7ncpaUeSGL9z0NGQR", - "description": { - "wrapper": "jpVuvMOCbpaRr_6FLf3W", - "content": "fUYMR7LuRXv0KJWFvRZA" - }, - "metadata": { - "container": "Fb61sprjhh75aOITDnsJ", - "text": "RANLXG3qKB61Bh33I0r2", - "profile": "NO_VO3MRVl9z3z56d8Lg", - "duration": "UyzJidwrGk3awngSGIwv" - } - } - } - }, - "carousel": { - "wrapper": "BagEpNps_J8R2QuHRZMa", - "container": "bjB8WZd0Ev30ZFbiScDm", - "title": { - "wrapper": "q8AZzDc_1BumBHZg0tZb" - } - }, - "entity": { - "background": "CoLO4pdSl8LGWyVZA00t", - "actions": { - "wrapper": "E4q8ogfdWtye7YgotBlN", - "container": "eSg4ntPU2KQLfpLGXAww" - } - }, - "playlist": { - "section": "dZ3U5sTGUTdanNamXe1z" - }, - "artist": { - "section": "D6IunHuzuULf_Sr88GOA" - }, - "album": { - "section": "m2grVeuHNwfGLVpaSBle" - }, - "home": { - "section": "ZLSAuA1tn0bSdQRwhhj6", - "wrapper": "UqLpBiryzmdVyUucfSTQ", - "chips": { - "container": "cj6vRk3nFAi80HSVqX91", - "background": "hIFR8WDm_54EEIa1gwpC" - }, - "shortcuts": { - "wrapper": "rPV8BmHZXaGIGT2HwvBB", - "container": "nlOU1unbFAd7ZHyeSMTH", - "chip": { - "wrapper": "Z35BWOA10YGn5uc9YgAp", - "container": "g3f_cI5usQX7ZOQyDtA9" - } - } - } - }, - "panel": { - "root": "OTfMDdomT5S7B5dbYTT8", - "wrapper": "XOawmCGZcQx4cesyNfVO", - "aside": "ffFwfKcPDbmAPLXzxzKq", - "container": "AAdBM1nhG73supMfnYX7", - "header": { - "wrapper": "W3E0IT3_STcazjTeyOJa", - "container": "fNXmHtlrj4UVWmhQrJ_5", - "title": { - "wrapper": "eTT681T6oUKXsIMiP8pT", - "link": "gwmgu53xszgwAXxqLT0h" - }, - "controls": { - "container": "icNf66tgG3uxDMbCCIaN" - } - }, - "content": { - "wrapper_wrapper": "cZCuJDjrGA2QMXja_Sua", - "wrapper": "zduvaX0Ioxqd5ypeWoAf" - } - }, - "sidebar": { - "root": "BdcvqBAid96FaHAmPYw_", - "container": "lYpiKR_qEjl1jGGyEvsA", - "section": "lHJd4oSttKLxkxuoZ0Lr", - "navlinks": { - "container": "fFvRIGtMIgsOLVSq_JNS", - "link": { - "wrapper": "KAcp7QFuEYSouAsuC5i_", - "container": "hNvCMxbfz7HwgzLjt3IZ", - "container__active": "DzWw3g4E_66wu9ktqn36" - }, - "links": { - "library": "i_FRcsaqNCEJmoyObIP0" - } - }, - "ylx": { - "wrapper": "wM72343CksOCaL3bZvKK", - "container": "hgJel0bLlS_1Uf0EIfSA", - "header": "HjPqU_UW2egr14mRSom9", - "items": { - "container": "WxM1eb7qnneSkMiT4dvw", - "wrapper": "_W_0W9Uld1vxrRfsgdQR" - }, - "rootlist": { - "container": "JUa6JJNj7R_Y3i4P8YUX", - "top_sentinel": "lyVkg68L7ycnwyOcO3vj", - "bottom_sentinel": "qnYVzttodnzg9WdrVQ1p", - "item": "vSC5QuwmzUhqUNWdMTJ5" - }, - "image": { - "container": "vreceNX3ABcxyddeS83B" - } - } - } - }, - "play_button": { - "wrapper": "ix_8kg3iUb9VS5SmTnBY" - }, - "profile": { - "figure": "tp8rO9vtqBGPLOhwcdYv", - "image": "Xz3tlahv16UpqKBW5HdK" - }, - "shuffle": { - "wrapper": "ecHWOS", - "button": "lihLVK", - "active": "E1ikFpkPVaoLplbQdrTP" - }, - "slider": { - "wrapper": "p1ULRzPc4bD8eQ4T_wyp", - "bar": "TywOcKZEqNynWecCiATc", - "background": "NoOAOv6U6vtqj_ybS1Cd", - "sliderail": "w699O0LgQRghXyl3bs9u", - "interactive": "DFtdzavKSbEhwKYkPTa6", - "slider": "epWhU7hHGktzlO_dop6z", - "handle": "Vis45PPawTyED7Lt2_LI" - }, - "tracklist": { - "container": "oIeuP60w1eYpFaXESRSg", - "header": { - "wrapper": "IpXjqI9ouS_N5zi0WM88", - "container": "ePPpO_NuGDUxVRTw7y6W", - "column": "rGujAXjCLKEd_N6yTwds", - "index": "fS0C4IgbHviZxIVGC736", - "title": "w46g_LQVSLE9xK399VYf", - "album": "_TH6YAXEzJtzSxhkGSqu", - "duration": "PAqIqZXvse_3h6sDVxU0" - }, - "row" : { - "container": "IjYxRc5luMiDPhKhZVUH", - "play": "j2s64Lz8y6VzBLB_V9Gm", - "more": "ObVor_8sQq5whKbtWs8a", - "container_active": "JgERXNoqNav5zOHiZGfG" - } - }, - "search_chips": { - "wrapper_wrapper": "qG4q41T8PJl0SkVgUeJc", - "wrapper": "XAwhJzXeTM5Iv2jLMCEj", - "container": "IDNDdMa6ACThrEsGWsXX", - "chip": "UnwG2v9ISmcUhnjKj22Y" - }, - "modal": { - "widget_generator": { - "container": "uUYNnjSt8m3EqVjsnHgh", - "header": { - "container": "bOIRpQiHUAEfp8ntStTo", - "close": "oBoIIlKrwQjxXpvOiOa0" - }, - "content": { - "container": "IJHNf0vxPSbPE1egoG4N" - } - }, - "track_credits": { - "container": "uV8q95GGAb2VDtL3gpYa", - "header": { - "container": "pGU_qEtNT1qWKjrRbvan", - "close": "VKCcyYujazVPj6VkksPM" - }, - "content": { - "container": "Nw1INlIyra3LT1JjvoqH" - } - } - }, - "settings": { - "header": { - "container": "O2vRz41OsAH0O7xVA4La" - }, - "section": { - "container": "k6GFKyOKVR5Ruofj3aTQ" - }, - "button": { - "wrapper": "rFFJg1UIumqUUFDgo6n7" - }, - "text_input": "SkbGMKYv49KtJNB5XxdX" - }, - "scrollable_text": { - "container": "bMurPtRDRv5LuN78MTVG", - "wrapper": "KjPUGV8uMbl_0bvk9ePv" - }, - "sort_box": { - "list": { - "button": "w6j_vX6SF5IxSXrrkYw5" - } - }, - "search_box": { - "container": "JzZyf6OGCGtdscOZGt8Y", - "expand_button": "wCl7pMTEE68v1xuZeZiB" - } + "main": { + "main_view": { + "album": { + "section": "m2grVeuHNwfGLVpaSBle" + }, + "artist": { + "section": "D6IunHuzuULf_Sr88GOA" + }, + "carousel": { + "container": "bjB8WZd0Ev30ZFbiScDm", + "title": { + "wrapper": "q8AZzDc_1BumBHZg0tZb" + }, + "wrapper": "BagEpNps_J8R2QuHRZMa" + }, + "content_container": "T0fKO6B7LQSCE_VaSM1P", + "entity": { + "actions": { + "container": "eSg4ntPU2KQLfpLGXAww", + "wrapper": "E4q8ogfdWtye7YgotBlN" + }, + "background": "CoLO4pdSl8LGWyVZA00t" + }, + "gradient": "S4OmZ_IZexmZ5dasPqW5", + "header": { + "background": "gHImFiUWOg93pvTefeAD", + "background_overlay": "xYgjMpAjE5XT05aRIezb", + "container": "NXiYChVp4Oydfxd7rT5r", + "contents": { + "container": "iWTIFTzhRZT0rCD0_gOK", + "image": { + "container": "_gLjHpwOxHFwo5nLM8hb", + "content": "CmkY1Ag0tJDfnFXbGgju", + "wrapper": "Ws8Ec3GREpT5PAUesr9b" + }, + "info": { + "container": "RP2rRchy4i8TIp1CTmb7", + "description": { + "content": "fUYMR7LuRXv0KJWFvRZA", + "wrapper": "jpVuvMOCbpaRr_6FLf3W" + }, + "metadata": { + "container": "Fb61sprjhh75aOITDnsJ", + "duration": "UyzJidwrGk3awngSGIwv", + "profile": "NO_VO3MRVl9z3z56d8Lg", + "text": "RANLXG3qKB61Bh33I0r2" + }, + "pretitle": "lp9Tfm4rsM9_pfbIE0zd", + "title": "rEN7ncpaUeSGL9z0NGQR" + } + } + }, + "home": { + "chips": { + "background": "hIFR8WDm_54EEIa1gwpC", + "container": "cj6vRk3nFAi80HSVqX91" + }, + "section": "ZLSAuA1tn0bSdQRwhhj6", + "shortcuts": { + "chip": { + "container": "g3f_cI5usQX7ZOQyDtA9", + "wrapper": "Z35BWOA10YGn5uc9YgAp" + }, + "container": "nlOU1unbFAd7ZHyeSMTH", + "wrapper": "rPV8BmHZXaGIGT2HwvBB" + }, + "wrapper": "UqLpBiryzmdVyUucfSTQ" + }, + "playlist": { + "section": "dZ3U5sTGUTdanNamXe1z" + }, + "topbar": { + "background": "T1xI1RTSFU7Wu94UuvE6", + "background_overlay": "EvIR4O7jOSbNmxtMdIQ0", + "container": "hV9v6y_uYwdAsoiOHpzk", + "wrapper": "facDIsOQo9q7kiWc4jSg" + }, + "wrapper": "jEMA2gVoLgPQqAFrPhFw" + }, + "panel": { + "aside": "ffFwfKcPDbmAPLXzxzKq", + "container": "AAdBM1nhG73supMfnYX7", + "content": { + "wrapper": "zduvaX0Ioxqd5ypeWoAf", + "wrapper_wrapper": "cZCuJDjrGA2QMXja_Sua" + }, + "header": { + "container": "fNXmHtlrj4UVWmhQrJ_5", + "controls": { + "container": "icNf66tgG3uxDMbCCIaN" + }, + "title": { + "link": "gwmgu53xszgwAXxqLT0h", + "wrapper": "eTT681T6oUKXsIMiP8pT" + }, + "wrapper": "W3E0IT3_STcazjTeyOJa" + }, + "root": "OTfMDdomT5S7B5dbYTT8", + "wrapper": "XOawmCGZcQx4cesyNfVO" + }, + "playbar": { + "buttons": { + "button": { + "wrapper": "KAZD28usA1vPz5GVpm63", + "wrapper__active": "RK45o6dbvO1mb0wQtSwq", + "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" + }, + "container": "mwpJrmCgLlVkJVtWjlI1", + "volume": "rT09bwCEwXECMbLbX_7A", + "wrapper": "pLifNBuHRY8cZkZyEqwL" + }, + "container": "udArIAqnfUQPQew2VAns", + "controls": { + "buttons": { + "left": "NKUrT1GciYXAEEUtagN1", + "loop": "Vz6yjzttS0YlLcwrkoUR", + "loop_active": "tP0mccyU1WAa7I9PevC1", + "next": "mnipjT4SLDMgwiDCEnRC", + "play": "cLkUmr", + "previous": "fn72ari9aEmKo4JcwteT", + "right": "Qt226Z4rBQs53aedRQBQ", + "wrapper": "XrZ1iHVHAPMya3jkB2sa" + }, + "container": "gItY2hnfCB4TsDJCkPiO", + "wrapper": "sVv2OQORCQ4kf6iKfUTF" + }, + "footer": "yglmI5m3fCc8baD1Kwdw", + "progress": { + "bar": "B1vgcMXBqOxgMxXh5j1f", + "container": "pn5V0OzovI9p6b8nWq8p", + "time-elapsed": "IPbBrI6yF4zhaizFmrg6", + "time-total": "kQqIrFPM5PjMWb5qUS56", + "wrapper": "w6j_vX6SF5IxSXrrkYw5" + }, + "widget": { + "container": "deomraqfhIAoSB3SgXpu", + "cover": { + "art": { + "container": "H0HbpIM3UrcupWIAjLWu", + "image": "FqmFsMhuF4D0s35Z62Js", + "wrapper": "BFR9Zt3zpL8BATBMiwQB" + }, + "button": "HD9s7U5E1RLSWKpXmrqx", + "wrapper": "GQ5_gIWzIqAfBdmQm8yJ" + }, + "info": { + "artist_text": "w_TTPh4y9H1YD6UrTMHa", + "container": "j96cpCtZAIdqxcDrYHPI", + "text_content": { + "content": "PGSe59fD1Hwc9yUM2d3U", + "wrapper": "W5cB_o0XkkU7Q8tlTGxq", + "wrapper__wrapper": "eSMjmiD29Ox35O95waw6" + }, + "title_text": "PcH6VnzkkDqD36P93i9Q" + }, + "wrapper": "snFK6_ei0caqvFI6As9Q" + }, + "wrapper": "JG5J9NWJkaUO9fiKECMA" + }, + "sidebar": { + "container": "lYpiKR_qEjl1jGGyEvsA", + "navlinks": { + "container": "fFvRIGtMIgsOLVSq_JNS", + "link": { + "container": "hNvCMxbfz7HwgzLjt3IZ", + "container__active": "DzWw3g4E_66wu9ktqn36", + "wrapper": "KAcp7QFuEYSouAsuC5i_" + }, + "links": { + "library": "i_FRcsaqNCEJmoyObIP0" + } + }, + "root": "BdcvqBAid96FaHAmPYw_", + "section": "lHJd4oSttKLxkxuoZ0Lr", + "ylx": { + "container": "hgJel0bLlS_1Uf0EIfSA", + "header": "HjPqU_UW2egr14mRSom9", + "image": { + "container": "vreceNX3ABcxyddeS83B" + }, + "items": { + "container": "WxM1eb7qnneSkMiT4dvw", + "wrapper": "_W_0W9Uld1vxrRfsgdQR" + }, + "rootlist": { + "bottom_sentinel": "qnYVzttodnzg9WdrVQ1p", + "container": "JUa6JJNj7R_Y3i4P8YUX", + "item": "vSC5QuwmzUhqUNWdMTJ5", + "top_sentinel": "lyVkg68L7ycnwyOcO3vj" + }, + "wrapper": "wM72343CksOCaL3bZvKK" + } + }, + "topbar": { + "left": { + "button": { + "icon": { + "wrapper": "IYDlXmBmmUKHveMzIPCF" + }, + "wrapper": "ql0zZd7giPXSnPg75NR0" + }, + "button_t": { + "wrapper": "rBX1EWVZ2EaPwP4y1Gkd" + } + }, + "right": { + "button": { + "wrapper": "WtC1lGbmQRplD6JBhNFU" + }, + "button_t": { + "wrapper": "OomFKn3bsxs5JfNUoWhz" + } + }, + "wrapper": "qHWqOt_TYlFxiF0Dm2fD" + }, + "wrapper": "ZQftYELq0aOsg6tPbVbV" + }, + "modal": { + "track_credits": { + "container": "uV8q95GGAb2VDtL3gpYa", + "content": { + "container": "Nw1INlIyra3LT1JjvoqH" + }, + "header": { + "close": "VKCcyYujazVPj6VkksPM", + "container": "pGU_qEtNT1qWKjrRbvan" + } + }, + "widget_generator": { + "container": "uUYNnjSt8m3EqVjsnHgh", + "content": { + "container": "IJHNf0vxPSbPE1egoG4N" + }, + "header": { + "close": "oBoIIlKrwQjxXpvOiOa0", + "container": "bOIRpQiHUAEfp8ntStTo" + } + } + }, + "play_button": { + "wrapper": "ix_8kg3iUb9VS5SmTnBY" + }, + "profile": { + "figure": "tp8rO9vtqBGPLOhwcdYv", + "image": "Xz3tlahv16UpqKBW5HdK" + }, + "scrollable_text": { + "container": "bMurPtRDRv5LuN78MTVG", + "wrapper": "KjPUGV8uMbl_0bvk9ePv" + }, + "search_box": { + "container": "JzZyf6OGCGtdscOZGt8Y", + "expand_button": "wCl7pMTEE68v1xuZeZiB" + }, + "search_chips": { + "chip": "UnwG2v9ISmcUhnjKj22Y", + "container": "IDNDdMa6ACThrEsGWsXX", + "wrapper": "XAwhJzXeTM5Iv2jLMCEj", + "wrapper_wrapper": "qG4q41T8PJl0SkVgUeJc" + }, + "settings": { + "button": { + "wrapper": "rFFJg1UIumqUUFDgo6n7" + }, + "header": { + "container": "O2vRz41OsAH0O7xVA4La" + }, + "section": { + "container": "k6GFKyOKVR5Ruofj3aTQ" + }, + "text_input": "SkbGMKYv49KtJNB5XxdX" + }, + "shuffle": { + "active": "E1ikFpkPVaoLplbQdrTP", + "button": "lihLVK", + "wrapper": "ecHWOS" + }, + "slider": { + "background": "NoOAOv6U6vtqj_ybS1Cd", + "bar": "TywOcKZEqNynWecCiATc", + "handle": "Vis45PPawTyED7Lt2_LI", + "interactive": "DFtdzavKSbEhwKYkPTa6", + "slider": "epWhU7hHGktzlO_dop6z", + "sliderail": "w699O0LgQRghXyl3bs9u", + "wrapper": "p1ULRzPc4bD8eQ4T_wyp" + }, + "sort_box": { + "list": { + "button": "w6j_vX6SF5IxSXrrkYw5" + } + }, + "tracklist": { + "container": "oIeuP60w1eYpFaXESRSg", + "header": { + "album": "_TH6YAXEzJtzSxhkGSqu", + "column": "rGujAXjCLKEd_N6yTwds", + "container": "ePPpO_NuGDUxVRTw7y6W", + "duration": "PAqIqZXvse_3h6sDVxU0", + "index": "fS0C4IgbHviZxIVGC736", + "title": "w46g_LQVSLE9xK399VYf", + "wrapper": "IpXjqI9ouS_N5zi0WM88" + }, + "row": { + "container": "IjYxRc5luMiDPhKhZVUH", + "container_active": "JgERXNoqNav5zOHiZGfG", + "more": "ObVor_8sQq5whKbtWs8a", + "play": "j2s64Lz8y6VzBLB_V9Gm" + } + } } diff --git a/1020084/classmap.json b/1020084/classmap.json index 9d1a214..d293661 100644 --- a/1020084/classmap.json +++ b/1020084/classmap.json @@ -101,4 +101,4 @@ "tracklist": { "column_header": "qev2KFBSKCHkeXT4fDTl" } -} \ No newline at end of file +} diff --git a/1020092/classmap.json b/1020092/classmap.json index 9897a06..1667337 100644 --- a/1020092/classmap.json +++ b/1020092/classmap.json @@ -1,13 +1,31 @@ { + "context_menu": { + "menu_item": "yqG4N5YYcY4uEujt" + }, "main": { + "navbar": { + "link": { + "container": "kUHE42xvQVzWqabl", + "container__active": "kUHE42xvQVzWqabl", + "wrapper": "kUHE42xvQVzWqabl" + } + }, + "playbar": { + "buttons": { + "button": { + "wrapper": "_EuXSrqTA94N1Evn", + "wrapper__active": "v1kNYVvW5P8Wa4jS", + "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" + } + } + }, "topbar": { - "wrapper": "NXttmW3KPwE1r4Am", "left": { "button": { - "wrapper": "kUHE42xvQVzWqabl", "icon": { "wrapper": "IYDlXmBmmUKHveMzIPCF" - } + }, + "wrapper": "kUHE42xvQVzWqabl" }, "button_t": { "wrapper": "_Bg_zSvFrEutyacG" @@ -23,87 +41,64 @@ "upgrade_button": { "wrapper": "Upqw01TOXETOmR5Td7Dj" } - } - }, - "playbar": { - "widget": {}, - "controls": {}, - "buttons": { - "button": { - "wrapper": "_EuXSrqTA94N1Evn", - "wrapper__active": "v1kNYVvW5P8Wa4jS", - "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" - } - } - }, - "navbar": { - "link": { - "wrapper": "kUHE42xvQVzWqabl", - "container": "kUHE42xvQVzWqabl", - "container__active": "kUHE42xvQVzWqabl" - } - }, - "global_navbar": {}, - "sidebar": {}, - "panel": {} - }, - "tracklist": { - "column_header": "F_VvNCRKZ2cKj1a9" - }, - "search_chips": { - "wrapper_wrapper": "qZHfdckfFVY5AURF", - "wrapper": "x_HLN829yDsvJDgl", - "container": "dxlv5wfAu8zgm7HY", - "chip": "U4idT8HUFwfj7PWP" + }, + "wrapper": "NXttmW3KPwE1r4Am" + } }, "modal": { + "track_credits": { + "container": "p5LJBmlswjcRT458", + "content": { + "container": "yKG9lWaUWIBUyVCu" + }, + "header": { + "close": "_18qFNPzv7Eyyck8", + "container": "idsrX6VAUY49Eczw" + } + }, "widget_generator": { "container": "gx9qsprOAO4ubEFc", - "header": { - "container": "g9sFYudWkUldbuNa", - "close": "Pk4GI8WfBMjS5R2J" - }, "content": { "container": "ywtSYUL2xRwVlvSo" - } - }, - "track_credits": { - "container": "p5LJBmlswjcRT458", - "header": { - "container": "idsrX6VAUY49Eczw", - "close": "_18qFNPzv7Eyyck8" }, - "content": { - "container": "yKG9lWaUWIBUyVCu" + "header": { + "close": "Pk4GI8WfBMjS5R2J", + "container": "g9sFYudWkUldbuNa" } } }, + "scrollable_text": { + "container": "MyaStf5D5Im3gt9M", + "wrapper": "dxlv5wfAu8zgm7HY" + }, + "search_box": { + "container": "S0nU7XTXYMOBcASz", + "expand_button": "eu6ObgEHE5owtzi_" + }, + "search_chips": { + "chip": "U4idT8HUFwfj7PWP", + "container": "dxlv5wfAu8zgm7HY", + "wrapper": "x_HLN829yDsvJDgl", + "wrapper_wrapper": "qZHfdckfFVY5AURF" + }, "settings": { + "button": { + "wrapper": "nHgEqWYM1q0omZ6y" + }, "header": { "container": "fEEfljGMIEhhmWVi" }, "section": { "container": "nWR7PbHjFXQJiYNL" }, - "button": { - "wrapper": "nHgEqWYM1q0omZ6y" - }, "text_input": "SkbGMKYv49KtJNB5XxdX" }, - "scrollable_text": { - "container": "MyaStf5D5Im3gt9M", - "wrapper": "dxlv5wfAu8zgm7HY" - }, "sort_box": { "list": { "button": "LQ6y2jhZZtMS7xCr" } }, - "search_box": { - "container": "S0nU7XTXYMOBcASz", - "expand_button": "eu6ObgEHE5owtzi_" - }, - "context_menu": { - "menu_item": "yqG4N5YYcY4uEujt" + "tracklist": { + "column_header": "F_VvNCRKZ2cKj1a9" } } diff --git a/1020094/classmap.json b/1020094/classmap.json index dd8e490..43b1b36 100644 --- a/1020094/classmap.json +++ b/1020094/classmap.json @@ -1,13 +1,31 @@ { + "context_menu": { + "menu_item": "yqG4N5YYcY4uEujt" + }, "main": { + "navbar": { + "link": { + "container": "kUHE42xvQVzWqabl", + "container__active": "kUHE42xvQVzWqabl", + "wrapper": "kUHE42xvQVzWqabl" + } + }, + "playbar": { + "buttons": { + "button": { + "wrapper": "_EuXSrqTA94N1Evn", + "wrapper__active": "v1kNYVvW5P8Wa4jS", + "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" + } + } + }, "topbar": { - "wrapper": "NXttmW3KPwE1r4Am", "left": { "button": { - "wrapper": "kUHE42xvQVzWqabl", "icon": { "wrapper": "IYDlXmBmmUKHveMzIPCF" - } + }, + "wrapper": "kUHE42xvQVzWqabl" }, "button_t": { "wrapper": "_Bg_zSvFrEutyacG" @@ -23,87 +41,64 @@ "upgrade_button": { "wrapper": "Upqw01TOXETOmR5Td7Dj" } - } - }, - "playbar": { - "widget": {}, - "controls": {}, - "buttons": { - "button": { - "wrapper": "_EuXSrqTA94N1Evn", - "wrapper__active": "v1kNYVvW5P8Wa4jS", - "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" - } - } - }, - "navbar": { - "link": { - "wrapper": "kUHE42xvQVzWqabl", - "container": "kUHE42xvQVzWqabl", - "container__active": "kUHE42xvQVzWqabl" - } - }, - "global_navbar": {}, - "sidebar": {}, - "panel": {} - }, - "tracklist": { - "column_header": "F_VvNCRKZ2cKj1a9" - }, - "search_chips": { - "wrapper_wrapper": "qZHfdckfFVY5AURF", - "wrapper": "x_HLN829yDsvJDgl", - "container": "dxlv5wfAu8zgm7HY", - "chip": "U4idT8HUFwfj7PWP" + }, + "wrapper": "NXttmW3KPwE1r4Am" + } }, "modal": { + "track_credits": { + "container": "p5LJBmlswjcRT458", + "content": { + "container": "yKG9lWaUWIBUyVCu" + }, + "header": { + "close": "_18qFNPzv7Eyyck8", + "container": "idsrX6VAUY49Eczw" + } + }, "widget_generator": { "container": "gx9qsprOAO4ubEFc", - "header": { - "container": "g9sFYudWkUldbuNa", - "close": "Pk4GI8WfBMjS5R2J" - }, "content": { "container": "ywtSYUL2xRwVlvSo" - } - }, - "track_credits": { - "container": "p5LJBmlswjcRT458", - "header": { - "container": "idsrX6VAUY49Eczw", - "close": "_18qFNPzv7Eyyck8" }, - "content": { - "container": "yKG9lWaUWIBUyVCu" + "header": { + "close": "Pk4GI8WfBMjS5R2J", + "container": "g9sFYudWkUldbuNa" } } }, + "scrollable_text": { + "container": "MyaStf5D5Im3gt9M", + "wrapper": "dxlv5wfAu8zgm7HY" + }, + "search_box": { + "container": "S0nU7XTXYMOBcASz", + "expand_button": "eu6ObgEHE5owtzi_" + }, + "search_chips": { + "chip": "U4idT8HUFwfj7PWP", + "container": "dxlv5wfAu8zgm7HY", + "wrapper": "x_HLN829yDsvJDgl", + "wrapper_wrapper": "qZHfdckfFVY5AURF" + }, "settings": { + "button": { + "wrapper": "nHgEqWYM1q0omZ6y" + }, "header": { "container": "fEEfljGMIEhhmWVi" }, "section": { "container": "nWR7PbHjFXQJiYNL" }, - "button": { - "wrapper": "nHgEqWYM1q0omZ6y" - }, "text_input": "e-10451-form-input e-10451-form-control encore-text-body-medium" }, - "scrollable_text": { - "container": "MyaStf5D5Im3gt9M", - "wrapper": "dxlv5wfAu8zgm7HY" - }, "sort_box": { "list": { "button": "LQ6y2jhZZtMS7xCr" } }, - "search_box": { - "container": "S0nU7XTXYMOBcASz", - "expand_button": "eu6ObgEHE5owtzi_" - }, - "context_menu": { - "menu_item": "yqG4N5YYcY4uEujt" + "tracklist": { + "column_header": "F_VvNCRKZ2cKj1a9" } } diff --git a/1020095/classmap.json b/1020095/classmap.json index fe399ff..64274d8 100644 --- a/1020095/classmap.json +++ b/1020095/classmap.json @@ -1,13 +1,31 @@ { + "context_menu": { + "menu_item": "yqG4N5YYcY4uEujt" + }, "main": { + "navbar": { + "link": { + "container": "kUHE42xvQVzWqabl", + "container__active": "kUHE42xvQVzWqabl", + "wrapper": "kUHE42xvQVzWqabl" + } + }, + "playbar": { + "buttons": { + "button": { + "wrapper": "_EuXSrqTA94N1Evn", + "wrapper__active": "v1kNYVvW5P8Wa4jS", + "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" + } + } + }, "topbar": { - "wrapper": "NXttmW3KPwE1r4Am", "left": { "button": { - "wrapper": "kUHE42xvQVzWqabl", "icon": { "wrapper": "IYDlXmBmmUKHveMzIPCF" - } + }, + "wrapper": "kUHE42xvQVzWqabl" }, "button_t": { "wrapper": "_Bg_zSvFrEutyacG" @@ -23,87 +41,64 @@ "upgrade_button": { "wrapper": "Upqw01TOXETOmR5Td7Dj" } - } - }, - "playbar": { - "widget": {}, - "controls": {}, - "buttons": { - "button": { - "wrapper": "_EuXSrqTA94N1Evn", - "wrapper__active": "v1kNYVvW5P8Wa4jS", - "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" - } - } - }, - "navbar": { - "link": { - "wrapper": "kUHE42xvQVzWqabl", - "container": "kUHE42xvQVzWqabl", - "container__active": "kUHE42xvQVzWqabl" - } - }, - "global_navbar": {}, - "sidebar": {}, - "panel": {} - }, - "tracklist": { - "column_header": "F_VvNCRKZ2cKj1a9" - }, - "search_chips": { - "wrapper_wrapper": "qZHfdckfFVY5AURF", - "wrapper": "x_HLN829yDsvJDgl", - "container": "dxlv5wfAu8zgm7HY", - "chip": "U4idT8HUFwfj7PWP" + }, + "wrapper": "NXttmW3KPwE1r4Am" + } }, "modal": { + "track_credits": { + "container": "p5LJBmlswjcRT458", + "content": { + "container": "yKG9lWaUWIBUyVCu" + }, + "header": { + "close": "_18qFNPzv7Eyyck8", + "container": "idsrX6VAUY49Eczw" + } + }, "widget_generator": { "container": "gx9qsprOAO4ubEFc", - "header": { - "container": "g9sFYudWkUldbuNa", - "close": "Pk4GI8WfBMjS5R2J" - }, "content": { "container": "ywtSYUL2xRwVlvSo" - } - }, - "track_credits": { - "container": "p5LJBmlswjcRT458", - "header": { - "container": "idsrX6VAUY49Eczw", - "close": "_18qFNPzv7Eyyck8" }, - "content": { - "container": "yKG9lWaUWIBUyVCu" + "header": { + "close": "Pk4GI8WfBMjS5R2J", + "container": "g9sFYudWkUldbuNa" } } }, + "scrollable_text": { + "container": "MyaStf5D5Im3gt9M", + "wrapper": "dxlv5wfAu8zgm7HY" + }, + "search_box": { + "container": "S0nU7XTXYMOBcASz", + "expand_button": "eu6ObgEHE5owtzi_" + }, + "search_chips": { + "chip": "U4idT8HUFwfj7PWP", + "container": "dxlv5wfAu8zgm7HY", + "wrapper": "x_HLN829yDsvJDgl", + "wrapper_wrapper": "qZHfdckfFVY5AURF" + }, "settings": { + "button": { + "wrapper": "e-10451-legacy-button e-10451-legacy-button--small e-10451-legacy-button-secondary" + }, "header": { "container": "KsnD09q3oqAjJX3e" }, "section": { "container": "fNaaQ0Cp8Yzy19j8" }, - "button": { - "wrapper": "e-10451-legacy-button e-10451-legacy-button--small e-10451-legacy-button-secondary" - }, "text_input": "e-10451-form-input e-10451-form-control encore-text-body-medium" }, - "scrollable_text": { - "container": "MyaStf5D5Im3gt9M", - "wrapper": "dxlv5wfAu8zgm7HY" - }, "sort_box": { "list": { "button": "LQ6y2jhZZtMS7xCr" } }, - "search_box": { - "container": "S0nU7XTXYMOBcASz", - "expand_button": "eu6ObgEHE5owtzi_" - }, - "context_menu": { - "menu_item": "yqG4N5YYcY4uEujt" + "tracklist": { + "column_header": "F_VvNCRKZ2cKj1a9" } } diff --git a/1020096/classmap.json b/1020096/classmap.json index dd8e490..43b1b36 100644 --- a/1020096/classmap.json +++ b/1020096/classmap.json @@ -1,13 +1,31 @@ { + "context_menu": { + "menu_item": "yqG4N5YYcY4uEujt" + }, "main": { + "navbar": { + "link": { + "container": "kUHE42xvQVzWqabl", + "container__active": "kUHE42xvQVzWqabl", + "wrapper": "kUHE42xvQVzWqabl" + } + }, + "playbar": { + "buttons": { + "button": { + "wrapper": "_EuXSrqTA94N1Evn", + "wrapper__active": "v1kNYVvW5P8Wa4jS", + "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" + } + } + }, "topbar": { - "wrapper": "NXttmW3KPwE1r4Am", "left": { "button": { - "wrapper": "kUHE42xvQVzWqabl", "icon": { "wrapper": "IYDlXmBmmUKHveMzIPCF" - } + }, + "wrapper": "kUHE42xvQVzWqabl" }, "button_t": { "wrapper": "_Bg_zSvFrEutyacG" @@ -23,87 +41,64 @@ "upgrade_button": { "wrapper": "Upqw01TOXETOmR5Td7Dj" } - } - }, - "playbar": { - "widget": {}, - "controls": {}, - "buttons": { - "button": { - "wrapper": "_EuXSrqTA94N1Evn", - "wrapper__active": "v1kNYVvW5P8Wa4jS", - "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" - } - } - }, - "navbar": { - "link": { - "wrapper": "kUHE42xvQVzWqabl", - "container": "kUHE42xvQVzWqabl", - "container__active": "kUHE42xvQVzWqabl" - } - }, - "global_navbar": {}, - "sidebar": {}, - "panel": {} - }, - "tracklist": { - "column_header": "F_VvNCRKZ2cKj1a9" - }, - "search_chips": { - "wrapper_wrapper": "qZHfdckfFVY5AURF", - "wrapper": "x_HLN829yDsvJDgl", - "container": "dxlv5wfAu8zgm7HY", - "chip": "U4idT8HUFwfj7PWP" + }, + "wrapper": "NXttmW3KPwE1r4Am" + } }, "modal": { + "track_credits": { + "container": "p5LJBmlswjcRT458", + "content": { + "container": "yKG9lWaUWIBUyVCu" + }, + "header": { + "close": "_18qFNPzv7Eyyck8", + "container": "idsrX6VAUY49Eczw" + } + }, "widget_generator": { "container": "gx9qsprOAO4ubEFc", - "header": { - "container": "g9sFYudWkUldbuNa", - "close": "Pk4GI8WfBMjS5R2J" - }, "content": { "container": "ywtSYUL2xRwVlvSo" - } - }, - "track_credits": { - "container": "p5LJBmlswjcRT458", - "header": { - "container": "idsrX6VAUY49Eczw", - "close": "_18qFNPzv7Eyyck8" }, - "content": { - "container": "yKG9lWaUWIBUyVCu" + "header": { + "close": "Pk4GI8WfBMjS5R2J", + "container": "g9sFYudWkUldbuNa" } } }, + "scrollable_text": { + "container": "MyaStf5D5Im3gt9M", + "wrapper": "dxlv5wfAu8zgm7HY" + }, + "search_box": { + "container": "S0nU7XTXYMOBcASz", + "expand_button": "eu6ObgEHE5owtzi_" + }, + "search_chips": { + "chip": "U4idT8HUFwfj7PWP", + "container": "dxlv5wfAu8zgm7HY", + "wrapper": "x_HLN829yDsvJDgl", + "wrapper_wrapper": "qZHfdckfFVY5AURF" + }, "settings": { + "button": { + "wrapper": "nHgEqWYM1q0omZ6y" + }, "header": { "container": "fEEfljGMIEhhmWVi" }, "section": { "container": "nWR7PbHjFXQJiYNL" }, - "button": { - "wrapper": "nHgEqWYM1q0omZ6y" - }, "text_input": "e-10451-form-input e-10451-form-control encore-text-body-medium" }, - "scrollable_text": { - "container": "MyaStf5D5Im3gt9M", - "wrapper": "dxlv5wfAu8zgm7HY" - }, "sort_box": { "list": { "button": "LQ6y2jhZZtMS7xCr" } }, - "search_box": { - "container": "S0nU7XTXYMOBcASz", - "expand_button": "eu6ObgEHE5owtzi_" - }, - "context_menu": { - "menu_item": "yqG4N5YYcY4uEujt" + "tracklist": { + "column_header": "F_VvNCRKZ2cKj1a9" } } diff --git a/1020097/classmap.json b/1020097/classmap.json index dd8e490..43b1b36 100644 --- a/1020097/classmap.json +++ b/1020097/classmap.json @@ -1,13 +1,31 @@ { + "context_menu": { + "menu_item": "yqG4N5YYcY4uEujt" + }, "main": { + "navbar": { + "link": { + "container": "kUHE42xvQVzWqabl", + "container__active": "kUHE42xvQVzWqabl", + "wrapper": "kUHE42xvQVzWqabl" + } + }, + "playbar": { + "buttons": { + "button": { + "wrapper": "_EuXSrqTA94N1Evn", + "wrapper__active": "v1kNYVvW5P8Wa4jS", + "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" + } + } + }, "topbar": { - "wrapper": "NXttmW3KPwE1r4Am", "left": { "button": { - "wrapper": "kUHE42xvQVzWqabl", "icon": { "wrapper": "IYDlXmBmmUKHveMzIPCF" - } + }, + "wrapper": "kUHE42xvQVzWqabl" }, "button_t": { "wrapper": "_Bg_zSvFrEutyacG" @@ -23,87 +41,64 @@ "upgrade_button": { "wrapper": "Upqw01TOXETOmR5Td7Dj" } - } - }, - "playbar": { - "widget": {}, - "controls": {}, - "buttons": { - "button": { - "wrapper": "_EuXSrqTA94N1Evn", - "wrapper__active": "v1kNYVvW5P8Wa4jS", - "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" - } - } - }, - "navbar": { - "link": { - "wrapper": "kUHE42xvQVzWqabl", - "container": "kUHE42xvQVzWqabl", - "container__active": "kUHE42xvQVzWqabl" - } - }, - "global_navbar": {}, - "sidebar": {}, - "panel": {} - }, - "tracklist": { - "column_header": "F_VvNCRKZ2cKj1a9" - }, - "search_chips": { - "wrapper_wrapper": "qZHfdckfFVY5AURF", - "wrapper": "x_HLN829yDsvJDgl", - "container": "dxlv5wfAu8zgm7HY", - "chip": "U4idT8HUFwfj7PWP" + }, + "wrapper": "NXttmW3KPwE1r4Am" + } }, "modal": { + "track_credits": { + "container": "p5LJBmlswjcRT458", + "content": { + "container": "yKG9lWaUWIBUyVCu" + }, + "header": { + "close": "_18qFNPzv7Eyyck8", + "container": "idsrX6VAUY49Eczw" + } + }, "widget_generator": { "container": "gx9qsprOAO4ubEFc", - "header": { - "container": "g9sFYudWkUldbuNa", - "close": "Pk4GI8WfBMjS5R2J" - }, "content": { "container": "ywtSYUL2xRwVlvSo" - } - }, - "track_credits": { - "container": "p5LJBmlswjcRT458", - "header": { - "container": "idsrX6VAUY49Eczw", - "close": "_18qFNPzv7Eyyck8" }, - "content": { - "container": "yKG9lWaUWIBUyVCu" + "header": { + "close": "Pk4GI8WfBMjS5R2J", + "container": "g9sFYudWkUldbuNa" } } }, + "scrollable_text": { + "container": "MyaStf5D5Im3gt9M", + "wrapper": "dxlv5wfAu8zgm7HY" + }, + "search_box": { + "container": "S0nU7XTXYMOBcASz", + "expand_button": "eu6ObgEHE5owtzi_" + }, + "search_chips": { + "chip": "U4idT8HUFwfj7PWP", + "container": "dxlv5wfAu8zgm7HY", + "wrapper": "x_HLN829yDsvJDgl", + "wrapper_wrapper": "qZHfdckfFVY5AURF" + }, "settings": { + "button": { + "wrapper": "nHgEqWYM1q0omZ6y" + }, "header": { "container": "fEEfljGMIEhhmWVi" }, "section": { "container": "nWR7PbHjFXQJiYNL" }, - "button": { - "wrapper": "nHgEqWYM1q0omZ6y" - }, "text_input": "e-10451-form-input e-10451-form-control encore-text-body-medium" }, - "scrollable_text": { - "container": "MyaStf5D5Im3gt9M", - "wrapper": "dxlv5wfAu8zgm7HY" - }, "sort_box": { "list": { "button": "LQ6y2jhZZtMS7xCr" } }, - "search_box": { - "container": "S0nU7XTXYMOBcASz", - "expand_button": "eu6ObgEHE5owtzi_" - }, - "context_menu": { - "menu_item": "yqG4N5YYcY4uEujt" + "tracklist": { + "column_header": "F_VvNCRKZ2cKj1a9" } } diff --git a/1020098/classmap.json b/1020098/classmap.json index dd8e490..43b1b36 100644 --- a/1020098/classmap.json +++ b/1020098/classmap.json @@ -1,13 +1,31 @@ { + "context_menu": { + "menu_item": "yqG4N5YYcY4uEujt" + }, "main": { + "navbar": { + "link": { + "container": "kUHE42xvQVzWqabl", + "container__active": "kUHE42xvQVzWqabl", + "wrapper": "kUHE42xvQVzWqabl" + } + }, + "playbar": { + "buttons": { + "button": { + "wrapper": "_EuXSrqTA94N1Evn", + "wrapper__active": "v1kNYVvW5P8Wa4jS", + "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" + } + } + }, "topbar": { - "wrapper": "NXttmW3KPwE1r4Am", "left": { "button": { - "wrapper": "kUHE42xvQVzWqabl", "icon": { "wrapper": "IYDlXmBmmUKHveMzIPCF" - } + }, + "wrapper": "kUHE42xvQVzWqabl" }, "button_t": { "wrapper": "_Bg_zSvFrEutyacG" @@ -23,87 +41,64 @@ "upgrade_button": { "wrapper": "Upqw01TOXETOmR5Td7Dj" } - } - }, - "playbar": { - "widget": {}, - "controls": {}, - "buttons": { - "button": { - "wrapper": "_EuXSrqTA94N1Evn", - "wrapper__active": "v1kNYVvW5P8Wa4jS", - "wrapper__indicator": "EHxL6K_6WWDlTCZP6x5w" - } - } - }, - "navbar": { - "link": { - "wrapper": "kUHE42xvQVzWqabl", - "container": "kUHE42xvQVzWqabl", - "container__active": "kUHE42xvQVzWqabl" - } - }, - "global_navbar": {}, - "sidebar": {}, - "panel": {} - }, - "tracklist": { - "column_header": "F_VvNCRKZ2cKj1a9" - }, - "search_chips": { - "wrapper_wrapper": "qZHfdckfFVY5AURF", - "wrapper": "x_HLN829yDsvJDgl", - "container": "dxlv5wfAu8zgm7HY", - "chip": "U4idT8HUFwfj7PWP" + }, + "wrapper": "NXttmW3KPwE1r4Am" + } }, "modal": { + "track_credits": { + "container": "p5LJBmlswjcRT458", + "content": { + "container": "yKG9lWaUWIBUyVCu" + }, + "header": { + "close": "_18qFNPzv7Eyyck8", + "container": "idsrX6VAUY49Eczw" + } + }, "widget_generator": { "container": "gx9qsprOAO4ubEFc", - "header": { - "container": "g9sFYudWkUldbuNa", - "close": "Pk4GI8WfBMjS5R2J" - }, "content": { "container": "ywtSYUL2xRwVlvSo" - } - }, - "track_credits": { - "container": "p5LJBmlswjcRT458", - "header": { - "container": "idsrX6VAUY49Eczw", - "close": "_18qFNPzv7Eyyck8" }, - "content": { - "container": "yKG9lWaUWIBUyVCu" + "header": { + "close": "Pk4GI8WfBMjS5R2J", + "container": "g9sFYudWkUldbuNa" } } }, + "scrollable_text": { + "container": "MyaStf5D5Im3gt9M", + "wrapper": "dxlv5wfAu8zgm7HY" + }, + "search_box": { + "container": "S0nU7XTXYMOBcASz", + "expand_button": "eu6ObgEHE5owtzi_" + }, + "search_chips": { + "chip": "U4idT8HUFwfj7PWP", + "container": "dxlv5wfAu8zgm7HY", + "wrapper": "x_HLN829yDsvJDgl", + "wrapper_wrapper": "qZHfdckfFVY5AURF" + }, "settings": { + "button": { + "wrapper": "nHgEqWYM1q0omZ6y" + }, "header": { "container": "fEEfljGMIEhhmWVi" }, "section": { "container": "nWR7PbHjFXQJiYNL" }, - "button": { - "wrapper": "nHgEqWYM1q0omZ6y" - }, "text_input": "e-10451-form-input e-10451-form-control encore-text-body-medium" }, - "scrollable_text": { - "container": "MyaStf5D5Im3gt9M", - "wrapper": "dxlv5wfAu8zgm7HY" - }, "sort_box": { "list": { "button": "LQ6y2jhZZtMS7xCr" } }, - "search_box": { - "container": "S0nU7XTXYMOBcASz", - "expand_button": "eu6ObgEHE5owtzi_" - }, - "context_menu": { - "menu_item": "yqG4N5YYcY4uEujt" + "tracklist": { + "column_header": "F_VvNCRKZ2cKj1a9" } } diff --git a/1020099/classmap.json b/1020099/classmap.json index 997be31..ebec63a 100644 --- a/1020099/classmap.json +++ b/1020099/classmap.json @@ -1,13 +1,31 @@ { + "context_menu": { + "menu_item": "KzLH25pAEr43wpSc" + }, "main": { + "navbar": { + "link": { + "container": "kUHE42xvQVzWqabl", + "container__active": "kxv3By32Og8yDEXy", + "wrapper": "kUHE42xvQVzWqabl" + } + }, + "playbar": { + "buttons": { + "button": { + "wrapper": "e-10810-legacy-button e-10810-legacy-button-tertiary e-10810-button-tertiary--icon-only e-10810-button-tertiary--icon-only-small", + "wrapper__active": "e-10810-legacy-button e-10810-legacy-button-primary", + "wrapper__indicator": "e-10810-button-primary__inner" + } + } + }, "topbar": { - "wrapper": "oy96nRTKcIs7wm5K", "left": { "button": { - "wrapper": "e-10810-legacy-button e-10810-legacy-button-tertiary e-10810-button-tertiary--icon-only", "icon": { "wrapper": "e-10810-icon e-10810-icon--auto-mirror" - } + }, + "wrapper": "e-10810-legacy-button e-10810-legacy-button-tertiary e-10810-button-tertiary--icon-only" }, "button_t": { "wrapper": "e-10810-legacy-button e-10810-legacy-button-tertiary e-10810-button-tertiary--icon-only" @@ -23,87 +41,64 @@ "upgrade_button": { "wrapper": "e-10810-legacy-button e-10810-legacy-button-primary" } - } - }, - "playbar": { - "widget": {}, - "controls": {}, - "buttons": { - "button": { - "wrapper": "e-10810-legacy-button e-10810-legacy-button-tertiary e-10810-button-tertiary--icon-only e-10810-button-tertiary--icon-only-small", - "wrapper__active": "e-10810-legacy-button e-10810-legacy-button-primary", - "wrapper__indicator": "e-10810-button-primary__inner" - } - } - }, - "navbar": { - "link": { - "wrapper": "kUHE42xvQVzWqabl", - "container": "kUHE42xvQVzWqabl", - "container__active": "kxv3By32Og8yDEXy" - } - }, - "global_navbar": {}, - "sidebar": {}, - "panel": {} - }, - "tracklist": { - "column_header": "e-10810-legacy-list-row__header" - }, - "search_chips": { - "wrapper_wrapper": "e-10810-chip-group", - "wrapper": "e-10810-chip-group__component", - "container": "e-10810-chip-group__component", - "chip": "e-10810-legacy-chip" + }, + "wrapper": "oy96nRTKcIs7wm5K" + } }, "modal": { + "track_credits": { + "container": "p5LJBmlswjcRT458", + "content": { + "container": "yKG9lWaUWIBUyVCu" + }, + "header": { + "close": "_18qFNPzv7Eyyck8", + "container": "idsrX6VAUY49Eczw" + } + }, "widget_generator": { "container": "gx9qsprOAO4ubEFc", - "header": { - "container": "g9sFYudWkUldbuNa", - "close": "Pk4GI8WfBMjS5R2J" - }, "content": { "container": "ywtSYUL2xRwVlvSo" - } - }, - "track_credits": { - "container": "p5LJBmlswjcRT458", - "header": { - "container": "idsrX6VAUY49Eczw", - "close": "_18qFNPzv7Eyyck8" }, - "content": { - "container": "yKG9lWaUWIBUyVCu" + "header": { + "close": "Pk4GI8WfBMjS5R2J", + "container": "g9sFYudWkUldbuNa" } } }, + "scrollable_text": { + "container": "e-10810-legacy-box e-10810-legacy-box--naked", + "wrapper": "e-10810-legacy-box" + }, + "search_box": { + "container": "YsP3Gj4aJDYsaOC5", + "expand_button": "e-10810-legacy-button e-10810-legacy-button-tertiary e-10810-button-tertiary--icon-only _Bg_zSvFrEutyacG" + }, + "search_chips": { + "chip": "e-10810-legacy-chip", + "container": "e-10810-chip-group__component", + "wrapper": "e-10810-chip-group__component", + "wrapper_wrapper": "e-10810-chip-group" + }, "settings": { + "button": { + "wrapper": "e-10810-legacy-button e-10810-legacy-button--small e-10810-legacy-button-secondary" + }, "header": { "container": "KsnD09q3oqAjJX3e" }, "section": { "container": "fNaaQ0Cp8Yzy19j8" }, - "button": { - "wrapper": "e-10810-legacy-button e-10810-legacy-button--small e-10810-legacy-button-secondary" - }, "text_input": "e-10810-form-input e-10810-form-control encore-text-body-medium" }, - "scrollable_text": { - "container": "e-10810-legacy-box e-10810-legacy-box--naked", - "wrapper": "e-10810-legacy-box" - }, "sort_box": { "list": { "button": "LQ6y2jhZZtMS7xCr" } }, - "search_box": { - "container": "YsP3Gj4aJDYsaOC5", - "expand_button": "e-10810-legacy-button e-10810-legacy-button-tertiary e-10810-button-tertiary--icon-only _Bg_zSvFrEutyacG" - }, - "context_menu": { - "menu_item": "KzLH25pAEr43wpSc" + "tracklist": { + "column_header": "e-10810-legacy-list-row__header" } } diff --git a/1030000/classmap.json b/1030000/classmap.json index f6d2cea..27c8ec6 100644 --- a/1030000/classmap.json +++ b/1030000/classmap.json @@ -1,13 +1,31 @@ { + "context_menu": { + "menu_item": "fmmygDM45MTS7I35OyLh" + }, "main": { + "navbar": { + "link": { + "container": "xlH6FJnBzFT4l3TgUBre", + "container__active": "ajKYUZPcNmFVzcAzzlwe", + "wrapper": "xlH6FJnBzFT4l3TgUBre" + } + }, + "playbar": { + "buttons": { + "button": { + "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-small", + "wrapper__active": "e-10860-legacy-button e-10860-legacy-button-primary", + "wrapper__indicator": "e-10860-button-primary__inner" + } + } + }, "topbar": { - "wrapper": "bQetA_KiP9n0DQVcGa7m", "left": { "button": { - "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium", "icon": { "wrapper": "e-10860-icon e-10860-icon--auto-mirror" - } + }, + "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium" }, "button_t": { "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium" @@ -23,87 +41,64 @@ "upgrade_button": { "wrapper": "e-10860-legacy-button e-10860-legacy-button-primary" } - } - }, - "playbar": { - "widget": {}, - "controls": {}, - "buttons": { - "button": { - "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-small", - "wrapper__active": "e-10860-legacy-button e-10860-legacy-button-primary", - "wrapper__indicator": "e-10860-button-primary__inner" - } - } - }, - "navbar": { - "link": { - "wrapper": "xlH6FJnBzFT4l3TgUBre", - "container": "xlH6FJnBzFT4l3TgUBre", - "container__active": "ajKYUZPcNmFVzcAzzlwe" - } - }, - "global_navbar": {}, - "sidebar": {}, - "panel": {} - }, - "tracklist": { - "column_header": "e-10860-legacy-list-row__header" - }, - "search_chips": { - "wrapper_wrapper": "e-10860-chip-group", - "wrapper": "e-10860-chip-group__component", - "container": "e-10860-chip-group__component", - "chip": "e-10860-legacy-chip" + }, + "wrapper": "bQetA_KiP9n0DQVcGa7m" + } }, "modal": { + "track_credits": { + "container": "gtXQasDkhtg93wGk6yNG", + "content": { + "container": "wYQNBdH2rNMGe_B4oumf" + }, + "header": { + "close": "CaHvWv36mAu4x5QTKlb1", + "container": "nQ_c7yoZ2XOD6fWtmv18" + } + }, "widget_generator": { "container": "_XeAt3IgtZQepOYqU2g4", - "header": { - "container": "mnU1hqlaF5JIcrTxLnZF", - "close": "AslPcD0TRZBBGZofe58g" - }, "content": { "container": "Xu_Z4l6TIOF1f5kDB9dt" - } - }, - "track_credits": { - "container": "gtXQasDkhtg93wGk6yNG", - "header": { - "container": "nQ_c7yoZ2XOD6fWtmv18", - "close": "CaHvWv36mAu4x5QTKlb1" }, - "content": { - "container": "wYQNBdH2rNMGe_B4oumf" + "header": { + "close": "AslPcD0TRZBBGZofe58g", + "container": "mnU1hqlaF5JIcrTxLnZF" } } }, + "scrollable_text": { + "container": "e-10860-legacy-box e-10860-legacy-box--naked", + "wrapper": "e-10860-legacy-box" + }, + "search_box": { + "container": "mlx0CgXAnGz0dADVsCdS", + "expand_button": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium aslJk0YypwKvyKXaBJcG" + }, + "search_chips": { + "chip": "e-10860-legacy-chip", + "container": "e-10860-chip-group__component", + "wrapper": "e-10860-chip-group__component", + "wrapper_wrapper": "e-10860-chip-group" + }, "settings": { + "button": { + "wrapper": "e-10860-legacy-button e-10860-legacy-button--small e-10860-legacy-button-secondary" + }, "header": { "container": "Hwax_pfCzrntLhnYXiqL" }, "section": { "container": "HdwEUcX8xijMdU7doYPt" }, - "button": { - "wrapper": "e-10860-legacy-button e-10860-legacy-button--small e-10860-legacy-button-secondary" - }, "text_input": "e-10860-form-input e-10860-form-control encore-text-body-medium" }, - "scrollable_text": { - "container": "e-10860-legacy-box e-10860-legacy-box--naked", - "wrapper": "e-10860-legacy-box" - }, "sort_box": { "list": { "button": "MYWr4UY4pKAAscUXvqrB" } }, - "search_box": { - "container": "mlx0CgXAnGz0dADVsCdS", - "expand_button": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium aslJk0YypwKvyKXaBJcG" - }, - "context_menu": { - "menu_item": "fmmygDM45MTS7I35OyLh" + "tracklist": { + "column_header": "e-10860-legacy-list-row__header" } } diff --git a/1030001/classmap.json b/1030001/classmap.json index f6d2cea..27c8ec6 100644 --- a/1030001/classmap.json +++ b/1030001/classmap.json @@ -1,13 +1,31 @@ { + "context_menu": { + "menu_item": "fmmygDM45MTS7I35OyLh" + }, "main": { + "navbar": { + "link": { + "container": "xlH6FJnBzFT4l3TgUBre", + "container__active": "ajKYUZPcNmFVzcAzzlwe", + "wrapper": "xlH6FJnBzFT4l3TgUBre" + } + }, + "playbar": { + "buttons": { + "button": { + "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-small", + "wrapper__active": "e-10860-legacy-button e-10860-legacy-button-primary", + "wrapper__indicator": "e-10860-button-primary__inner" + } + } + }, "topbar": { - "wrapper": "bQetA_KiP9n0DQVcGa7m", "left": { "button": { - "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium", "icon": { "wrapper": "e-10860-icon e-10860-icon--auto-mirror" - } + }, + "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium" }, "button_t": { "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium" @@ -23,87 +41,64 @@ "upgrade_button": { "wrapper": "e-10860-legacy-button e-10860-legacy-button-primary" } - } - }, - "playbar": { - "widget": {}, - "controls": {}, - "buttons": { - "button": { - "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-small", - "wrapper__active": "e-10860-legacy-button e-10860-legacy-button-primary", - "wrapper__indicator": "e-10860-button-primary__inner" - } - } - }, - "navbar": { - "link": { - "wrapper": "xlH6FJnBzFT4l3TgUBre", - "container": "xlH6FJnBzFT4l3TgUBre", - "container__active": "ajKYUZPcNmFVzcAzzlwe" - } - }, - "global_navbar": {}, - "sidebar": {}, - "panel": {} - }, - "tracklist": { - "column_header": "e-10860-legacy-list-row__header" - }, - "search_chips": { - "wrapper_wrapper": "e-10860-chip-group", - "wrapper": "e-10860-chip-group__component", - "container": "e-10860-chip-group__component", - "chip": "e-10860-legacy-chip" + }, + "wrapper": "bQetA_KiP9n0DQVcGa7m" + } }, "modal": { + "track_credits": { + "container": "gtXQasDkhtg93wGk6yNG", + "content": { + "container": "wYQNBdH2rNMGe_B4oumf" + }, + "header": { + "close": "CaHvWv36mAu4x5QTKlb1", + "container": "nQ_c7yoZ2XOD6fWtmv18" + } + }, "widget_generator": { "container": "_XeAt3IgtZQepOYqU2g4", - "header": { - "container": "mnU1hqlaF5JIcrTxLnZF", - "close": "AslPcD0TRZBBGZofe58g" - }, "content": { "container": "Xu_Z4l6TIOF1f5kDB9dt" - } - }, - "track_credits": { - "container": "gtXQasDkhtg93wGk6yNG", - "header": { - "container": "nQ_c7yoZ2XOD6fWtmv18", - "close": "CaHvWv36mAu4x5QTKlb1" }, - "content": { - "container": "wYQNBdH2rNMGe_B4oumf" + "header": { + "close": "AslPcD0TRZBBGZofe58g", + "container": "mnU1hqlaF5JIcrTxLnZF" } } }, + "scrollable_text": { + "container": "e-10860-legacy-box e-10860-legacy-box--naked", + "wrapper": "e-10860-legacy-box" + }, + "search_box": { + "container": "mlx0CgXAnGz0dADVsCdS", + "expand_button": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium aslJk0YypwKvyKXaBJcG" + }, + "search_chips": { + "chip": "e-10860-legacy-chip", + "container": "e-10860-chip-group__component", + "wrapper": "e-10860-chip-group__component", + "wrapper_wrapper": "e-10860-chip-group" + }, "settings": { + "button": { + "wrapper": "e-10860-legacy-button e-10860-legacy-button--small e-10860-legacy-button-secondary" + }, "header": { "container": "Hwax_pfCzrntLhnYXiqL" }, "section": { "container": "HdwEUcX8xijMdU7doYPt" }, - "button": { - "wrapper": "e-10860-legacy-button e-10860-legacy-button--small e-10860-legacy-button-secondary" - }, "text_input": "e-10860-form-input e-10860-form-control encore-text-body-medium" }, - "scrollable_text": { - "container": "e-10860-legacy-box e-10860-legacy-box--naked", - "wrapper": "e-10860-legacy-box" - }, "sort_box": { "list": { "button": "MYWr4UY4pKAAscUXvqrB" } }, - "search_box": { - "container": "mlx0CgXAnGz0dADVsCdS", - "expand_button": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium aslJk0YypwKvyKXaBJcG" - }, - "context_menu": { - "menu_item": "fmmygDM45MTS7I35OyLh" + "tracklist": { + "column_header": "e-10860-legacy-list-row__header" } } diff --git a/index.json b/index.json index f3fc392..bd9644b 100644 --- a/index.json +++ b/index.json @@ -1,183 +1,183 @@ { - "expose": { - "file": "expose.json", - "sha256": "3c609aca27255ad637cae51c1355482ef02fe6c1e61156d3e82039e1fe6a372a" - }, - "keys": { - "1020038": { - "classmap": { - "file": "classmap.json", - "sha256": "29522d09a1de092d6ba4c68f249d8a0bcfa1539d0c0f74f9347328443ec09d73" - } - }, - "1020040": { - "classmap": { - "file": "classmap.json", - "sha256": "d281c6c73b4ae40d9bddc5ade971b818a69325b0626e5347a3a47615df2fcde3" - } - }, - "1020045": { - "classmap": { - "file": "classmap.json", - "sha256": "97dd9b8882fa3e1eb39b63d6d52303a6550147e8553276ddf7639dcfde9b29ef" - } - }, - "1020084": { - "classmap": { - "file": "classmap.json", - "sha256": "e5800bf048dfe57043d5e3611da043d44f3480e1fa6dba4781085f9ec37c784e" - }, - "meta": { - "file": "META.json", - "sha256": "0670e8c0d8e34b31612f986ba1bb3ab917c38fdbd4006255b4f27efdb48093ba" - }, - "spotifyVersion": "1.2.84.476", - "status": "verified" - }, - "1020092": { - "classmap": { - "file": "classmap.json", - "sha256": "817cf36500c70d90d6c77944e86095b084f0c56482c4ef9e345fc3c211ffaa53" - }, - "cssMapOverlay": { - "file": "css-map.json", - "sha256": "8c05d9ca347e4e13fb31013df4c42bdbf16046408b989afe4d0ea6a25037800e" - }, - "meta": { - "file": "META.json", - "sha256": "6866bb9564e11aa019c5bc18f9185f2ee2f4ba4d696f9fb5e7905d5d0745a199" - }, - "spotifyVersion": "1.2.92.148", - "status": "verified" - }, - "1020094": { - "classmap": { - "file": "classmap.json", - "sha256": "d69d189567b5ddb7fcafffa9f4e7d84ee8b2ed27160ae168ae7bd8c8d03d7956" - }, - "cssMapOverlay": { - "file": "css-map.json", - "sha256": "63828d9b3b7ad07d4a8dda92a22706a491c12106e817c12f0b701e289fd07efc" - }, - "meta": { - "file": "META.json", - "sha256": "b9924ef7604c1707e9e3732141fca4b552b63e1f2d3f4729aea83c2b360976ac" - }, - "spotifyVersion": "1.2.94.583", - "status": "verified" - }, - "1020095": { - "classmap": { - "file": "classmap.json", - "sha256": "57cbc252b22f7fd914be7c8d7fc1e932168b28ebde8a4ecaa2129297fe6010a2" - }, - "cssMapOverlay": { - "file": "css-map.json", - "sha256": "108d9a0fcd9f6a23da409da11fce3cdfde6dc854ba0795be73ffd49d1ea266ba" - }, - "meta": { - "file": "META.json", - "sha256": "3da34efd30775fd5afb78607483d61afb0f7dfb1626695296c405d815d48cbf1" - }, - "spotifyVersion": "1.2.95.453", - "status": "verified" - }, - "1020096": { - "classmap": { - "file": "classmap.json", - "sha256": "d69d189567b5ddb7fcafffa9f4e7d84ee8b2ed27160ae168ae7bd8c8d03d7956" - }, - "cssMapOverlay": { - "file": "css-map.json", - "sha256": "8c05d9ca347e4e13fb31013df4c42bdbf16046408b989afe4d0ea6a25037800e" - }, - "meta": { - "file": "META.json", - "sha256": "5d5d57a74ebb2b2d7614258df1f6b81eac636cb3d138068e9dd1ff3595a539c4" - }, - "spotifyVersion": "1.2.96.518", - "status": "verified" - }, - "1020097": { - "classmap": { - "file": "classmap.json", - "sha256": "d69d189567b5ddb7fcafffa9f4e7d84ee8b2ed27160ae168ae7bd8c8d03d7956" - }, - "cssMapOverlay": { - "file": "css-map.json", - "sha256": "8c05d9ca347e4e13fb31013df4c42bdbf16046408b989afe4d0ea6a25037800e" - }, - "meta": { - "file": "META.json", - "sha256": "773129d3306739588e90c0abe4cf32311f8ac39a1448bca2b3984cf80cfe2c68" - }, - "spotifyVersion": "1.2.97.270", - "status": "verified" - }, - "1020098": { - "classmap": { - "file": "classmap.json", - "sha256": "d69d189567b5ddb7fcafffa9f4e7d84ee8b2ed27160ae168ae7bd8c8d03d7956" - }, - "cssMapOverlay": { - "file": "css-map.json", - "sha256": "8c05d9ca347e4e13fb31013df4c42bdbf16046408b989afe4d0ea6a25037800e" - }, - "meta": { - "file": "META.json", - "sha256": "fa0334b0b336907d6a796e259c2607907bf4e1a028545c1a4e105621130bab87" - }, - "spotifyVersion": "1.2.98.301", - "status": "verified" - }, - "1020099": { - "classmap": { - "file": "classmap.json", - "sha256": "7a520fd3af3e3da2e79d0b6660fd9cafd16de9fb74041c434251f062de0db64a" - }, - "cssMapOverlay": { - "file": "css-map.json", - "sha256": "b4d8016a12301d6be24369dba618093813ad33376617ee6a7fc651d06c959600" - }, - "meta": { - "file": "META.json", - "sha256": "11ef871fe8d029f94e138f442aede11190d068fa0b07febb776f28742d8f8a6d" - }, - "spotifyVersion": "1.2.99.317", - "status": "verified" - }, - "1030000": { - "classmap": { - "file": "classmap.json", - "sha256": "17d9575c38a5a7cce9d6bb28665aff0c6e88b4ce4d94955bad297e19c97b33dc" - }, - "cssMapOverlay": { - "file": "css-map.json", - "sha256": "c0c993277547bfc8ec53f4fa0a055c6e6a470dc5dbb38e37e53ed13514f20e01" - }, - "meta": { - "file": "META.json", - "sha256": "f3c6870c099a73b1ddeea9361ed829675c0dcceacdb262fd96c4f8fd3c2fba21" - }, - "spotifyVersion": "1.3.0.277", - "status": "verified" - }, - "1030001": { - "classmap": { - "file": "classmap.json", - "sha256": "17d9575c38a5a7cce9d6bb28665aff0c6e88b4ce4d94955bad297e19c97b33dc" - }, - "cssMapOverlay": { - "file": "css-map.json", - "sha256": "c0c993277547bfc8ec53f4fa0a055c6e6a470dc5dbb38e37e53ed13514f20e01" - }, - "meta": { - "file": "META.json", - "sha256": "ffd04eb6c95553b3c48cd3ebbc89fada6d98455215b167f813915a71963a6f3f" - }, - "spotifyVersion": "1.3.1.234", - "status": "verified" - } - }, - "version": 1 + "expose": { + "file": "expose.json", + "sha256": "3c609aca27255ad637cae51c1355482ef02fe6c1e61156d3e82039e1fe6a372a" + }, + "keys": { + "1020038": { + "classmap": { + "file": "classmap.json", + "sha256": "d5be975944065eff2525b0df7d6d3c87e5abd913757e1c92a8f4e6d510ea18dd" + } + }, + "1020040": { + "classmap": { + "file": "classmap.json", + "sha256": "49babed3d08a81926bbd860d0320904510c88e5c614705539839bb33154dcf31" + } + }, + "1020045": { + "classmap": { + "file": "classmap.json", + "sha256": "bd36f3890364fe30db8a41b5a34cb9ca24c05309e29ae57b7b3670a947a0ad1d" + } + }, + "1020084": { + "classmap": { + "file": "classmap.json", + "sha256": "027bd34749eb1a3cd4318316e15127db87f09f839537e802c89fe5137b954cf0" + }, + "meta": { + "file": "META.json", + "sha256": "0670e8c0d8e34b31612f986ba1bb3ab917c38fdbd4006255b4f27efdb48093ba" + }, + "spotifyVersion": "1.2.84.476", + "status": "verified" + }, + "1020092": { + "classmap": { + "file": "classmap.json", + "sha256": "7b0ec24ad5bf62b6d71279e6311dd36222938b63f6968e1719250596317b7812" + }, + "cssMapOverlay": { + "file": "css-map.json", + "sha256": "8c05d9ca347e4e13fb31013df4c42bdbf16046408b989afe4d0ea6a25037800e" + }, + "meta": { + "file": "META.json", + "sha256": "6866bb9564e11aa019c5bc18f9185f2ee2f4ba4d696f9fb5e7905d5d0745a199" + }, + "spotifyVersion": "1.2.92.148", + "status": "verified" + }, + "1020094": { + "classmap": { + "file": "classmap.json", + "sha256": "dc5212855a7b460980548d296313bfaf76696d9f5a43aa456289990273eefec3" + }, + "cssMapOverlay": { + "file": "css-map.json", + "sha256": "63828d9b3b7ad07d4a8dda92a22706a491c12106e817c12f0b701e289fd07efc" + }, + "meta": { + "file": "META.json", + "sha256": "b9924ef7604c1707e9e3732141fca4b552b63e1f2d3f4729aea83c2b360976ac" + }, + "spotifyVersion": "1.2.94.583", + "status": "verified" + }, + "1020095": { + "classmap": { + "file": "classmap.json", + "sha256": "98a516adfa3161e7ace6ff4d28e9048f953aa88027ab3d55bff25920ef6d9aac" + }, + "cssMapOverlay": { + "file": "css-map.json", + "sha256": "108d9a0fcd9f6a23da409da11fce3cdfde6dc854ba0795be73ffd49d1ea266ba" + }, + "meta": { + "file": "META.json", + "sha256": "3da34efd30775fd5afb78607483d61afb0f7dfb1626695296c405d815d48cbf1" + }, + "spotifyVersion": "1.2.95.453", + "status": "verified" + }, + "1020096": { + "classmap": { + "file": "classmap.json", + "sha256": "dc5212855a7b460980548d296313bfaf76696d9f5a43aa456289990273eefec3" + }, + "cssMapOverlay": { + "file": "css-map.json", + "sha256": "8c05d9ca347e4e13fb31013df4c42bdbf16046408b989afe4d0ea6a25037800e" + }, + "meta": { + "file": "META.json", + "sha256": "5d5d57a74ebb2b2d7614258df1f6b81eac636cb3d138068e9dd1ff3595a539c4" + }, + "spotifyVersion": "1.2.96.518", + "status": "verified" + }, + "1020097": { + "classmap": { + "file": "classmap.json", + "sha256": "dc5212855a7b460980548d296313bfaf76696d9f5a43aa456289990273eefec3" + }, + "cssMapOverlay": { + "file": "css-map.json", + "sha256": "8c05d9ca347e4e13fb31013df4c42bdbf16046408b989afe4d0ea6a25037800e" + }, + "meta": { + "file": "META.json", + "sha256": "773129d3306739588e90c0abe4cf32311f8ac39a1448bca2b3984cf80cfe2c68" + }, + "spotifyVersion": "1.2.97.270", + "status": "verified" + }, + "1020098": { + "classmap": { + "file": "classmap.json", + "sha256": "dc5212855a7b460980548d296313bfaf76696d9f5a43aa456289990273eefec3" + }, + "cssMapOverlay": { + "file": "css-map.json", + "sha256": "8c05d9ca347e4e13fb31013df4c42bdbf16046408b989afe4d0ea6a25037800e" + }, + "meta": { + "file": "META.json", + "sha256": "fa0334b0b336907d6a796e259c2607907bf4e1a028545c1a4e105621130bab87" + }, + "spotifyVersion": "1.2.98.301", + "status": "verified" + }, + "1020099": { + "classmap": { + "file": "classmap.json", + "sha256": "dbbdd2c325bc6ff55b903867596e9371952320841248ad04997ee0ea7cf00fb7" + }, + "cssMapOverlay": { + "file": "css-map.json", + "sha256": "b4d8016a12301d6be24369dba618093813ad33376617ee6a7fc651d06c959600" + }, + "meta": { + "file": "META.json", + "sha256": "11ef871fe8d029f94e138f442aede11190d068fa0b07febb776f28742d8f8a6d" + }, + "spotifyVersion": "1.2.99.317", + "status": "verified" + }, + "1030000": { + "classmap": { + "file": "classmap.json", + "sha256": "d91fa1f1b40a19c51c5c7781a46e1ac41a44c56f2ea4f10096ce3e289f5f0f85" + }, + "cssMapOverlay": { + "file": "css-map.json", + "sha256": "c0c993277547bfc8ec53f4fa0a055c6e6a470dc5dbb38e37e53ed13514f20e01" + }, + "meta": { + "file": "META.json", + "sha256": "f3c6870c099a73b1ddeea9361ed829675c0dcceacdb262fd96c4f8fd3c2fba21" + }, + "spotifyVersion": "1.3.0.277", + "status": "verified" + }, + "1030001": { + "classmap": { + "file": "classmap.json", + "sha256": "d91fa1f1b40a19c51c5c7781a46e1ac41a44c56f2ea4f10096ce3e289f5f0f85" + }, + "cssMapOverlay": { + "file": "css-map.json", + "sha256": "c0c993277547bfc8ec53f4fa0a055c6e6a470dc5dbb38e37e53ed13514f20e01" + }, + "meta": { + "file": "META.json", + "sha256": "ffd04eb6c95553b3c48cd3ebbc89fada6d98455215b167f813915a71963a6f3f" + }, + "spotifyVersion": "1.3.1.234", + "status": "verified" + } + }, + "version": 1 } diff --git a/scripts/build_index.py b/scripts/build_index.py index 12f013a..b6bee3b 100644 --- a/scripts/build_index.py +++ b/scripts/build_index.py @@ -84,7 +84,7 @@ def main() -> int: parser.add_argument("--check", action="store_true", help="fail if index.json is stale") args = parser.parse_args() - rendered = json.dumps(build(), indent="\t", sort_keys=True) + "\n" + rendered = json.dumps(build(), indent=2, sort_keys=True) + "\n" if args.check: current = INDEX.read_text() if INDEX.is_file() else "" From ec90aaa5a8f92fdbab59fba71256dcff6183136b Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:11:26 +0200 Subject: [PATCH 04/20] chore: ignore Python bytecode --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ From d4f62958ee146b0f23c994fd886c18fbb011c37d Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:11:33 +0200 Subject: [PATCH 05/20] refactor(meta): keep machine-read fields in META.json and move history to VERIFICATION.md META.json now has a fixed schema_version 2 field set. required_paths uses one status vocabulary, source replaces inherited_from, and notes, statistics, guards and verification runs move to each key's VERIFICATION.md. 1020099 no longer lists its stale upgrade button as unverified, and its notes no longer claim that stock 1.2.99 ships readable class names. --- 1020084/META.json | 52 ++++--------- 1020084/VERIFICATION.md | 31 ++++++++ 1020092/META.json | 53 ++++--------- 1020092/VERIFICATION.md | 33 ++++++++ 1020094/META.json | 61 ++++----------- 1020094/VERIFICATION.md | 44 +++++++++++ 1020095/META.json | 72 ++++-------------- 1020095/VERIFICATION.md | 51 +++++++++++++ 1020096/META.json | 51 ++++--------- 1020096/VERIFICATION.md | 30 ++++++++ 1020097/META.json | 49 ++++-------- 1020097/VERIFICATION.md | 30 ++++++++ 1020098/META.json | 49 ++++-------- 1020098/VERIFICATION.md | 30 ++++++++ 1020099/META.json | 90 ++++------------------ 1020099/VERIFICATION.md | 69 +++++++++++++++++ 1030000/META.json | 165 +++++----------------------------------- 1030000/VERIFICATION.md | 136 +++++++++++++++++++++++++++++++++ 1030001/META.json | 85 ++++----------------- 1030001/VERIFICATION.md | 63 +++++++++++++++ index.json | 20 ++--- 21 files changed, 684 insertions(+), 580 deletions(-) create mode 100644 1020084/VERIFICATION.md create mode 100644 1020092/VERIFICATION.md create mode 100644 1020094/VERIFICATION.md create mode 100644 1020095/VERIFICATION.md create mode 100644 1020096/VERIFICATION.md create mode 100644 1020097/VERIFICATION.md create mode 100644 1020098/VERIFICATION.md create mode 100644 1020099/VERIFICATION.md create mode 100644 1030000/VERIFICATION.md create mode 100644 1030001/VERIFICATION.md diff --git a/1020084/META.json b/1020084/META.json index 01780cd..589497a 100644 --- a/1020084/META.json +++ b/1020084/META.json @@ -1,56 +1,36 @@ { - "spotify_version": "1.2.84.476", "classmap_key": "1020084", - "status": "verified", "generated": "2026-09-02", - "inherited_from": null, - "pipeline": "css-map inversion over stock target CSS + migrate(1020040) cross-check + static target-CSS verification + CDP e2e (deep, live Linux client)", - "stats": { - "leaves": 36, - "inherited": 0, - "static_present": 31, - "verified_cdp": 20, - "live_only": 2, - "unresolved_missing": 3, - "stale": 3, - "cdp_hit_rate": 0.5556, - "overlay_entries": 0 - }, "required_paths": { + "context_menu.menu_item": "verified_cdp", + "main.navbar.link.wrapper": "verified_cdp", "main.playbar.buttons.button.wrapper": "verified_cdp", "main.topbar.wrapper": "verified_cdp", - "main.navbar.link.wrapper": "verified_cdp", - "tracklist.column_header": "verified_cdp", - "context_menu.menu_item": "verified_cdp", + "modal.track_credits.container": "verified_static", + "modal.widget_generator.container": "verified_static", "search_box.container": "verified_cdp", "search_chips.wrapper": "verified_cdp", - "sort_box.list.button": "verified_cdp", + "settings.button.wrapper": "verified_static", "settings.header.container": "verified_cdp", - "settings.section.container": "verified (present in target CSS)", - "settings.button.wrapper": "verified (present in target CSS)", - "modal.track_credits.container": "verified (present in target CSS)", - "modal.widget_generator.container": "verified (present in target CSS)" + "settings.section.container": "verified_static", + "sort_box.list.button": "verified_cdp", + "tracklist.column_header": "verified_cdp" }, + "schema_version": 2, + "source": { + "key": null, + "method": "derived" + }, + "spotify_version": "1.2.84.476", "stale_leaves": [ "main.topbar.right.upgrade_button.wrapper", "search_chips.chip", "search_chips.wrapper_wrapper" ], + "status": "verified", "unverified_leaves": [ "main.playbar.buttons.button.wrapper__active", "main.playbar.buttons.button.wrapper__indicator", "main.topbar.left.button_t.wrapper" - ], - "notes": [ - "1.2.84 is the current Linux build; its hashes match no published key (verified: 0/36 raw-hash hits for both 1020045 and 1020092 against this build), so this key was derived rather than inherited.", - "Primary derivation inverts the CLI's embedded css-map over the classes present in the stock 1.2.84 bundle: 27/36 paths resolve to a unique hash whose semantic matches the 1020097 ABI meaning; a migrate(1020040) run agreed on 14 and was overruled on 13 where the inversion disagreed.", - "The three main.playbar.buttons.button.* leaves were corrected from the migration's collapse onto the player-controls__buttons container (the known text-theme ghost-button drift); the live fullscreen button carries the corrected wrapper hash.", - "Two leaves are encore literals on this build (e-91000-button__icon-wrapper, e-91000-form-input family); both are absent from the static stylesheet but verified live in the DOM (223 and 2 instances).", - "Three leaves keep dead 1020097-era hashes deliberately (upgrade button not rendered on this build's sessions observed, category grid items not present in css-map for this build): a dead class is a harmless no-op where a wrong live class would misstyle.", - "Deep CDP verification observed 20/36 paths by raw hash on the routes and surfaces exercised by the verifier; modal and playback-state leaves need surfaces the verifier does not open." - ], - "verification_summary": { - "needs_manual_check": 31, - "missing_in_css": 5 - } + ] } diff --git a/1020084/VERIFICATION.md b/1020084/VERIFICATION.md new file mode 100644 index 0000000..053aadf --- /dev/null +++ b/1020084/VERIFICATION.md @@ -0,0 +1,31 @@ +# Spotify 1.2.84.476 (1020084) + +Pipeline: css-map inversion over stock target CSS + migrate(1020040) cross-check + static target-CSS verification + CDP e2e (deep, live Linux client). + +## Notes + +- 1.2.84 is the current Linux build; its hashes match no published key (verified: 0/36 raw-hash hits for both 1020045 and 1020092 against this build), so this key was derived rather than inherited. +- Primary derivation inverts the CLI's embedded css-map over the classes present in the stock 1.2.84 bundle: 27/36 paths resolve to a unique hash whose semantic matches the 1020097 ABI meaning; a migrate(1020040) run agreed on 14 and was overruled on 13 where the inversion disagreed. +- The three main.playbar.buttons.button.* leaves were corrected from the migration's collapse onto the player-controls__buttons container (the known text-theme ghost-button drift); the live fullscreen button carries the corrected wrapper hash. +- Two leaves are encore literals on this build (e-91000-button__icon-wrapper, e-91000-form-input family); both are absent from the static stylesheet but verified live in the DOM (223 and 2 instances). +- Three leaves keep dead 1020097-era hashes deliberately (upgrade button not rendered on this build's sessions observed, category grid items not present in css-map for this build): a dead class is a harmless no-op where a wrong live class would misstyle. +- Deep CDP verification observed 20/36 paths by raw hash on the routes and surfaces exercised by the verifier; modal and playback-state leaves need surfaces the verifier does not open. + +## Statistics at publication + +| Field | Value | +| --- | --- | +| leaves | 36 | +| inherited | 0 | +| static_present | 31 | +| verified_cdp | 20 | +| live_only | 2 | +| unresolved_missing | 3 | +| stale | 3 | +| cdp_hit_rate | 0.5556 | +| overlay_entries | 0 | + +## Verification summary at publication + +- Needs manual check: 31 +- Missing in CSS: 5 diff --git a/1020092/META.json b/1020092/META.json index 04e006d..641801d 100644 --- a/1020092/META.json +++ b/1020092/META.json @@ -1,53 +1,32 @@ { - "spotify_version": "1.2.92.148", "classmap_key": "1020092", - "status": "verified", "generated": "2026-07-21", - "pipeline": "migrate(1020040 -> 1020092) + static verify + CDP e2e (deep) + manual DOM probe", - "guards": [ - "playbar!=actionBar", - "content!=closeBtn", - "settings!=equalizer/NPV/actionBar", - "semantic-only requires target CSS presence (sem>=0.75)" - ], - "stats": { - "leaves": 36, - "verified": 33, - "verified_cdp": 19, - "verified_dom_probe": 2, - "stale": 3, - "verify_rate": 0.9167, - "threshold": 0.55, - "css_map_entries": 2585 - }, "required_paths": { + "context_menu.menu_item": "verified", + "main.navbar.link.wrapper": "verified", "main.playbar.buttons.button.wrapper": "verified", "main.topbar.wrapper": "verified", - "main.navbar.link.wrapper": "verified", - "tracklist.column_header": "verified", - "context_menu.menu_item": "verified", + "modal.track_credits.container": "verified", + "modal.widget_generator.container": "verified", "search_box.container": "verified", "search_chips.wrapper": "verified", - "sort_box.list.button": "verified", "settings.button.wrapper": "verified", - "settings.header.container": "verified (DOM probe)", - "settings.section.container": "verified (DOM probe)", - "modal.track_credits.container": "verified", - "modal.widget_generator.container": "verified" + "settings.header.container": "verified_targeted", + "settings.section.container": "verified_targeted", + "sort_box.list.button": "verified", + "tracklist.column_header": "verified" }, + "schema_version": 2, + "source": { + "key": "1020040", + "method": "derived" + }, + "spotify_version": "1.2.92.148", "stale_leaves": [ "main.topbar.left.button.icon.wrapper", "main.topbar.right.upgrade_button.wrapper", "settings.text_input" ], - "unverified_leaves": [], - "notes": [ - "CDP e2e (deep navigation: home/search/library/playlist/context menu/settings) verified 19 leaves by hashed-class DOM presence on a stock 1.2.92.148 client.", - "modal.track_credits.* remapped to trackCreditsModalV2 classes (Spotify replaced the V1 modal); V2 hashes verified in live DOM via View credits.", - "modal.widget_generator.* verified in live DOM via Share > Embed, including content.container (ywtSYUL2xRwVlvSo, previously stale).", - "scrollable_text.container/wrapper remapped via css-map reverse lookup (single candidate in spa) and verified in live DOM on a search results page.", - "search_chips.wrapper_wrapper remapped to the 1.2.9x search results chips bar (qZHfdckfFVY5AURF), DOM-verified; search_chips.chip corrected from the home filterChips class to the search page chip link (U4idT8HUFwfj7PWP).", - "main.topbar.left.button_t.wrapper remapped to main-globalNav-link-icon (_Bg_zSvFrEutyacG), live on the nav buttons custom navlinks clone.", - "Retired in 1.2.9x (kept stale, no candidate hash anywhere in the spa): topbar.left.button.icon.wrapper (icon span unclassed), topbar.right.upgrade_button.wrapper (no Upgrade button surface), settings.text_input (settings inputs are Encore form controls now)." - ] + "status": "verified", + "unverified_leaves": [] } diff --git a/1020092/VERIFICATION.md b/1020092/VERIFICATION.md new file mode 100644 index 0000000..cc3ffe2 --- /dev/null +++ b/1020092/VERIFICATION.md @@ -0,0 +1,33 @@ +# Spotify 1.2.92.148 (1020092) + +Pipeline: migrate(1020040 -> 1020092) + static verify + CDP e2e (deep) + manual DOM probe. + +## Notes + +- CDP e2e (deep navigation: home/search/library/playlist/context menu/settings) verified 19 leaves by hashed-class DOM presence on a stock 1.2.92.148 client. +- modal.track_credits.* remapped to trackCreditsModalV2 classes (Spotify replaced the V1 modal); V2 hashes verified in live DOM via View credits. +- modal.widget_generator.* verified in live DOM via Share > Embed, including content.container (ywtSYUL2xRwVlvSo, previously stale). +- scrollable_text.container/wrapper remapped via css-map reverse lookup (single candidate in spa) and verified in live DOM on a search results page. +- search_chips.wrapper_wrapper remapped to the 1.2.9x search results chips bar (qZHfdckfFVY5AURF), DOM-verified; search_chips.chip corrected from the home filterChips class to the search page chip link (U4idT8HUFwfj7PWP). +- main.topbar.left.button_t.wrapper remapped to main-globalNav-link-icon (_Bg_zSvFrEutyacG), live on the nav buttons custom navlinks clone. +- Retired in 1.2.9x (kept stale, no candidate hash anywhere in the spa): topbar.left.button.icon.wrapper (icon span unclassed), topbar.right.upgrade_button.wrapper (no Upgrade button surface), settings.text_input (settings inputs are Encore form controls now). + +## Statistics at publication + +| Field | Value | +| --- | --- | +| leaves | 36 | +| verified | 33 | +| verified_cdp | 19 | +| verified_dom_probe | 2 | +| stale | 3 | +| verify_rate | 0.9167 | +| threshold | 0.55 | +| css_map_entries | 2585 | + +## Migration guards + +- playbar!=actionBar +- content!=closeBtn +- settings!=equalizer/NPV/actionBar +- semantic-only requires target CSS presence (sem>=0.75) diff --git a/1020094/META.json b/1020094/META.json index ce67dd6..17b5a46 100644 --- a/1020094/META.json +++ b/1020094/META.json @@ -1,64 +1,33 @@ { - "spotify_version": "1.2.94.583", "classmap_key": "1020094", - "status": "verified", "generated": "2026-07-21", - "pipeline": "migrate(1020092 -> 1020094) + static verify + CDP e2e (deep) on live 1.2.94.583", - "guards": [ - "playbar!=actionBar", - "content!=closeBtn", - "settings!=equalizer/NPV/actionBar", - "semantic-only requires target CSS presence (sem>=0.75)" - ], - "stats": { - "leaves": 36, - "identity_from_1020092": 27, - "verified": 33, - "verified_cdp": 21, - "stale": 2, - "verify_rate": 0.9167, - "threshold": 0.55, - "css_map_entries": 2585, - "unverified": 1 - }, "required_paths": { + "context_menu.menu_item": "verified", + "main.navbar.link.wrapper": "verified", "main.playbar.buttons.button.wrapper": "verified", "main.topbar.wrapper": "verified", - "main.navbar.link.wrapper": "verified", - "tracklist.column_header": "verified", - "context_menu.menu_item": "verified", + "modal.track_credits.container": "verified", + "modal.widget_generator.container": "verified", "search_box.container": "verified", "search_chips.wrapper": "verified", - "sort_box.list.button": "verified", + "settings.button.wrapper": "unverified", "settings.header.container": "verified", "settings.section.container": "verified", - "settings.button.wrapper": "unverified (in CSS, not rendered without settings scroll)", - "modal.track_credits.container": "verified", - "modal.widget_generator.container": "verified" + "sort_box.list.button": "verified", + "tracklist.column_header": "verified" + }, + "schema_version": 2, + "source": { + "key": "1020092", + "method": "derived" }, + "spotify_version": "1.2.94.583", "stale_leaves": [ "main.topbar.left.button.icon.wrapper", "main.topbar.right.upgrade_button.wrapper" ], + "status": "verified", "unverified_leaves": [ "settings.button.wrapper" - ], - "notes": [ - "27 of 28 good 1020092 leaves survived unchanged into 1.2.94 (identity); the only non-identity migrate hit was the known artist-artistDiscography-topBar false friend and was reverted to stale.", - "CDP e2e (deep navigation) on a live stock 1.2.94.583 client verified 20 leaves by hashed-class DOM presence, including the two settings fixes hand-mapped during 1020092 verification.", - "settings.button.wrapper is present in 1.2.94 CSS but was not rendered during navigation (proxy section requires settings scroll); left unverified.", - "modal.track_credits.* remapped to trackCreditsModalV2 classes (Spotify replaced the V1 modal); V2 hashes verified in live DOM via View credits.", - "modal.widget_generator.* verified in live DOM via Share > Embed, including content.container (ywtSYUL2xRwVlvSo, previously stale).", - "scrollable_text.container/wrapper remapped via css-map reverse lookup (single candidate in spa) and verified in live DOM on a search results page.", - "search_chips.wrapper_wrapper remapped to the 1.2.9x search results chips bar (qZHfdckfFVY5AURF), DOM-verified; search_chips.chip corrected from the home filterChips class to the search page chip link (U4idT8HUFwfj7PWP).", - "main.topbar.left.button_t.wrapper remapped to main-globalNav-link-icon (_Bg_zSvFrEutyacG), live on the nav buttons custom navlinks clone.", - "Retired in 1.2.9x (kept stale, no candidate hash anywhere in the spa): topbar.left.button.icon.wrapper (icon span unclassed), topbar.right.upgrade_button.wrapper (no Upgrade button surface).", - "settings.text_input remapped to the Encore form control classes (e-10451-form-input e-10451-form-control encore-text-body-medium), CDP-verified on a live 1.2.94.583 settings page (Search locations input). Multi-class value; valid for className contexts, which is the leaf's only use.", - "The qnaFIKUJ9oUIkN97 overlay has pointer-events:none, an absolute three-column panel-gap grid, and black child backgrounds in the stock xpui-snapshot.css. It supplies the existing Flow theme main-layoutResizer-seam child-background rule. No visibility-state mapping is added because its element role has not been verified." - ], - "overlay": { - "file": "css-map.json", - "entries": 4, - "note": "Flat hash -> semantic bridge for the classic preprocess pipeline. The layout resizer seam is identified from stock 1.2.94.583 CSS; its Flow theme hover and resize behavior has not been verified live." - } + ] } diff --git a/1020094/VERIFICATION.md b/1020094/VERIFICATION.md new file mode 100644 index 0000000..7313413 --- /dev/null +++ b/1020094/VERIFICATION.md @@ -0,0 +1,44 @@ +# Spotify 1.2.94.583 (1020094) + +Pipeline: migrate(1020092 -> 1020094) + static verify + CDP e2e (deep) on live 1.2.94.583. + +## Notes + +- 27 of 28 good 1020092 leaves survived unchanged into 1.2.94 (identity); the only non-identity migrate hit was the known artist-artistDiscography-topBar false friend and was reverted to stale. +- CDP e2e (deep navigation) on a live stock 1.2.94.583 client verified 20 leaves by hashed-class DOM presence, including the two settings fixes hand-mapped during 1020092 verification. +- settings.button.wrapper is present in 1.2.94 CSS but was not rendered during navigation (proxy section requires settings scroll); left unverified. +- modal.track_credits.* remapped to trackCreditsModalV2 classes (Spotify replaced the V1 modal); V2 hashes verified in live DOM via View credits. +- modal.widget_generator.* verified in live DOM via Share > Embed, including content.container (ywtSYUL2xRwVlvSo, previously stale). +- scrollable_text.container/wrapper remapped via css-map reverse lookup (single candidate in spa) and verified in live DOM on a search results page. +- search_chips.wrapper_wrapper remapped to the 1.2.9x search results chips bar (qZHfdckfFVY5AURF), DOM-verified; search_chips.chip corrected from the home filterChips class to the search page chip link (U4idT8HUFwfj7PWP). +- main.topbar.left.button_t.wrapper remapped to main-globalNav-link-icon (_Bg_zSvFrEutyacG), live on the nav buttons custom navlinks clone. +- Retired in 1.2.9x (kept stale, no candidate hash anywhere in the spa): topbar.left.button.icon.wrapper (icon span unclassed), topbar.right.upgrade_button.wrapper (no Upgrade button surface). +- settings.text_input remapped to the Encore form control classes (e-10451-form-input e-10451-form-control encore-text-body-medium), CDP-verified on a live 1.2.94.583 settings page (Search locations input). Multi-class value; valid for className contexts, which is the leaf's only use. +- The qnaFIKUJ9oUIkN97 overlay has pointer-events:none, an absolute three-column panel-gap grid, and black child backgrounds in the stock xpui-snapshot.css. It supplies the existing Flow theme main-layoutResizer-seam child-background rule. No visibility-state mapping is added because its element role has not been verified. + +## Statistics at publication + +| Field | Value | +| --- | --- | +| leaves | 36 | +| identity_from_1020092 | 27 | +| verified | 33 | +| verified_cdp | 21 | +| stale | 2 | +| verify_rate | 0.9167 | +| threshold | 0.55 | +| css_map_entries | 2585 | +| unverified | 1 | + +## Migration guards + +- playbar!=actionBar +- content!=closeBtn +- settings!=equalizer/NPV/actionBar +- semantic-only requires target CSS presence (sem>=0.75) + +## CSS overlay + +- File: css-map.json +- Entries: 4 +- Note: Flat hash -> semantic bridge for the classic preprocess pipeline. The layout resizer seam is identified from stock 1.2.94.583 CSS; its Flow theme hover and resize behavior has not been verified live. diff --git a/1020095/META.json b/1020095/META.json index 882d9c6..891fdba 100644 --- a/1020095/META.json +++ b/1020095/META.json @@ -1,74 +1,34 @@ { - "spotify_version": "1.2.95.453", "classmap_key": "1020095", - "status": "verified", "generated": "2026-09-29", - "derived_from": "1020094", - "pipeline": "derive 1020094 with three settings corrections + stock source/CSS verification + deep CDP + Linux workflow verification", - "stats": { - "leaves": 36, - "unchanged_from_1020094": 33, - "corrected": 3, - "static_present": 31, - "verified_cdp": 22, - "live_only": 2, - "unresolved_missing": 3, - "stale": 2, - "cdp_hit_rate": 0.6111, - "overlay_entries": 2 - }, "required_paths": { + "context_menu.menu_item": "verified_cdp", + "main.navbar.link.wrapper": "verified_cdp", "main.playbar.buttons.button.wrapper": "verified_cdp", "main.topbar.wrapper": "verified_cdp", - "main.navbar.link.wrapper": "verified_cdp", - "tracklist.column_header": "verified_cdp", - "context_menu.menu_item": "verified_cdp", + "modal.track_credits.container": "verified_static", + "modal.widget_generator.container": "verified_static", "search_box.container": "verified_cdp", "search_chips.wrapper": "verified_cdp", - "sort_box.list.button": "verified_cdp", - "settings.header.container": "source_verified; not rendered on Linux", - "settings.section.container": "verified_cdp", "settings.button.wrapper": "verified_cdp", - "modal.track_credits.container": "verified_static; not observed in this live run", - "modal.widget_generator.container": "verified_static; not observed in this live run" + "settings.header.container": "unverified", + "settings.section.container": "verified_cdp", + "sort_box.list.button": "verified_cdp", + "tracklist.column_header": "verified_cdp" }, + "schema_version": 2, + "source": { + "key": "1020094", + "method": "derived" + }, + "spotify_version": "1.2.95.453", "stale_leaves": [ "main.topbar.left.button.icon.wrapper", "main.topbar.right.upgrade_button.wrapper" ], + "status": "verified", "unverified_leaves": [ "main.playbar.buttons.button.wrapper__indicator", "settings.header.container" - ], - "notes": [ - "Linux stable Spotify 1.2.95.453.g0eeebbed tested on Ubuntu 24.04.5 x86_64, X11, emulated on Apple Silicon. No Windows, macOS or Wayland runtime claim.", - "Settings header KsnD09q3oqAjJX3e belongs to the output-device section in xpui-routes-desktop-settings.js. It did not render in this Linux session and remains unverified live.", - "Settings section fNaaQ0Cp8Yzy19j8 wraps real settings groups; settings buttons use the observed small Encore secondary-button classes. nHgEqWYM1q0omZ6y is a proxy-settings container, not a button.", - "Removed the inherited fEEfljGMIEhhmWVi and nWR7PbHjFXQJiYNL overlays: xpui-root-dialogs.js uses them for concert-location dialog content and its section, not settings.", - "The two retained overlays identify search category links and the panel-gap grid. The panel-gap stock CSS has pointer-events:none and black child backgrounds.", - "Deep CDP found 22/36 leaves with seven successful navigation steps out of eight. Context-menu dispatch was not counted as successful; the profile and library sort menus were separately exercised.", - "Paired 1.2.96 and 1.2.95 Flow settings checks changed and restored a toggle and quality selection, and entered/cleared settings search. Store install/removal and the profile Spicetify Settings entry worked on 1.2.95.", - "Tested CLI /home/test/.spicetify/spicetify and daemon 3.0.0-beta.19 are matching local builds from a036e07de398b437288d13c1cec26217207ecabb; this is not published-artifact verification.", - "The normal `spicetify spotify install` stable path failed without 1020095, then downloaded, patched and launched 1.2.95 with classmapVerified=true and classmapSpotify=1.2.95 using a temporary localhost candidate index. The native VM window returned to Home with the styled Store button; native input injection was unreliable, so interaction checks used CDP.", - "The managed installer intentionally restarted the daemon during activation. Its health returned with both watchers active; service and protocol-registration files were unchanged. This is not a daemon-continuity claim.", - "At an 800px-wide Flow window, scrolling a control into view shifted the settings view horizontally. The paired initial layouts matched; the full-width Store and final default-settings captures were aligned. Queue toggled active, and its empty-state panel was visible after removing Flow. Playback, credits/embed dialogs and external navigation were not exercised." - ], - "verification_summary": { - "needs_manual_check": 31, - "missing_in_css": 5 - }, - "verification": { - "platform": "linux-x86_64", - "spotify_package_sha256": "256e2eb1ec96fbc0c51afab247298e4e6eabfabbafafd4617584f44cca2fb31f", - "stock_spa_sha256": "7ce05c0001be22c7b5124d9dd59dd1ab1e0c188640967836b996f0d32a62f74e", - "classmap_sha256": "57cbc252b22f7fd914be7c8d7fc1e932168b28ebde8a4ecaa2129297fe6010a2", - "static_report_sha256": "51eba62de40ba5055acca2554e79d7e9f2468fe42116d66bd182815fc3f5fcb7", - "cdp_report_sha256": "08c086085bab4d37d32716d9f41ba844ae69026c346a98a05b33045795404b1b", - "core_modules": { - "manager": "1.4.0", - "stdlib": "1.13.1", - "store": "1.7.8" - }, - "theme": "Flow 0.1.2, Ocean palette, removed after testing" - } + ] } diff --git a/1020095/VERIFICATION.md b/1020095/VERIFICATION.md new file mode 100644 index 0000000..9153198 --- /dev/null +++ b/1020095/VERIFICATION.md @@ -0,0 +1,51 @@ +# Spotify 1.2.95.453 (1020095) + +Pipeline: derive 1020094 with three settings corrections + stock source/CSS verification + deep CDP + Linux workflow verification. + +## Notes + +- Linux stable Spotify 1.2.95.453.g0eeebbed tested on Ubuntu 24.04.5 x86_64, X11, emulated on Apple Silicon. No Windows, macOS or Wayland runtime claim. +- Settings header KsnD09q3oqAjJX3e belongs to the output-device section in xpui-routes-desktop-settings.js. It did not render in this Linux session and remains unverified live. +- Settings section fNaaQ0Cp8Yzy19j8 wraps real settings groups; settings buttons use the observed small Encore secondary-button classes. nHgEqWYM1q0omZ6y is a proxy-settings container, not a button. +- Removed the inherited fEEfljGMIEhhmWVi and nWR7PbHjFXQJiYNL overlays: xpui-root-dialogs.js uses them for concert-location dialog content and its section, not settings. +- The two retained overlays identify search category links and the panel-gap grid. The panel-gap stock CSS has pointer-events:none and black child backgrounds. +- Deep CDP found 22/36 leaves with seven successful navigation steps out of eight. Context-menu dispatch was not counted as successful; the profile and library sort menus were separately exercised. +- Paired 1.2.96 and 1.2.95 Flow settings checks changed and restored a toggle and quality selection, and entered/cleared settings search. Store install/removal and the profile Spicetify Settings entry worked on 1.2.95. +- Tested CLI /home/test/.spicetify/spicetify and daemon 3.0.0-beta.19 are matching local builds from a036e07de398b437288d13c1cec26217207ecabb; this is not published-artifact verification. +- The normal `spicetify spotify install` stable path failed without 1020095, then downloaded, patched and launched 1.2.95 with classmapVerified=true and classmapSpotify=1.2.95 using a temporary localhost candidate index. The native VM window returned to Home with the styled Store button; native input injection was unreliable, so interaction checks used CDP. +- The managed installer intentionally restarted the daemon during activation. Its health returned with both watchers active; service and protocol-registration files were unchanged. This is not a daemon-continuity claim. +- At an 800px-wide Flow window, scrolling a control into view shifted the settings view horizontally. The paired initial layouts matched; the full-width Store and final default-settings captures were aligned. Queue toggled active, and its empty-state panel was visible after removing Flow. Playback, credits/embed dialogs and external navigation were not exercised. + +## Statistics at publication + +| Field | Value | +| --- | --- | +| leaves | 36 | +| unchanged_from_1020094 | 33 | +| corrected | 3 | +| static_present | 31 | +| verified_cdp | 22 | +| live_only | 2 | +| unresolved_missing | 3 | +| stale | 2 | +| cdp_hit_rate | 0.6111 | +| overlay_entries | 2 | + +## Verification summary at publication + +- Needs manual check: 31 +- Missing in CSS: 5 + +## Linux verification + +- Platform: linux-x86_64 +- Spotify package SHA-256: `256e2eb1ec96fbc0c51afab247298e4e6eabfabbafafd4617584f44cca2fb31f` +- Stock SPA SHA-256: `7ce05c0001be22c7b5124d9dd59dd1ab1e0c188640967836b996f0d32a62f74e` +- Classmap SHA-256: `57cbc252b22f7fd914be7c8d7fc1e932168b28ebde8a4ecaa2129297fe6010a2` +- Static report SHA-256: `51eba62de40ba5055acca2554e79d7e9f2468fe42116d66bd182815fc3f5fcb7` +- CDP report SHA-256: `08c086085bab4d37d32716d9f41ba844ae69026c346a98a05b33045795404b1b` +- Core modules: + - Manager: 1.4.0 + - Stdlib: 1.13.1 + - Store: 1.7.8 +- Theme: Flow 0.1.2, Ocean palette, removed after testing diff --git a/1020096/META.json b/1020096/META.json index b7dc4d2..0ee7f57 100644 --- a/1020096/META.json +++ b/1020096/META.json @@ -1,52 +1,33 @@ { - "spotify_version": "1.2.96.518", "classmap_key": "1020096", - "status": "verified", "generated": "2026-08-12", - "inherited_from": "1020094", - "pipeline": "inherit(1020094 -> 1020096) + static target-CSS verification + CDP e2e (deep)", - "stats": { - "leaves": 36, - "inherited": 36, - "static_present": 32, - "verified_cdp": 20, - "live_only": 1, - "unresolved_missing": 3, - "stale": 2, - "cdp_hit_rate": 0.5556, - "overlay_entries": 3 - }, "required_paths": { + "context_menu.menu_item": "verified_cdp", + "main.navbar.link.wrapper": "verified_cdp", "main.playbar.buttons.button.wrapper": "verified_cdp", "main.topbar.wrapper": "verified_cdp", - "main.navbar.link.wrapper": "verified_cdp", - "tracklist.column_header": "verified_cdp", - "context_menu.menu_item": "verified_cdp", + "modal.track_credits.container": "verified_static", + "modal.widget_generator.container": "verified_static", "search_box.container": "verified_cdp", "search_chips.wrapper": "verified_cdp", - "sort_box.list.button": "verified_cdp", - "settings.header.container": "verified (inherited; present in target CSS)", - "settings.section.container": "verified (inherited; present in target CSS)", "settings.button.wrapper": "verified_cdp", - "modal.track_credits.container": "verified (inherited; present in target CSS)", - "modal.widget_generator.container": "verified (inherited; present in target CSS)" + "settings.header.container": "verified_static", + "settings.section.container": "verified_static", + "sort_box.list.button": "verified_cdp", + "tracklist.column_header": "verified_cdp" }, + "schema_version": 2, + "source": { + "key": "1020094", + "method": "inherited" + }, + "spotify_version": "1.2.96.518", "stale_leaves": [ "main.topbar.left.button.icon.wrapper", "main.topbar.right.upgrade_button.wrapper" ], + "status": "verified", "unverified_leaves": [ "main.playbar.buttons.button.wrapper__indicator" - ], - "notes": [ - "Classmap inherited byte-for-byte from 1020094; no migration guesses were accepted.", - "Static verification found 32/36 paths in the target CSS.", - "One CSS-only miss was observed live and remains verified.", - "Deep CDP verification observed 20/36 paths on the routes and transient surfaces exercised by the verifier.", - "Unresolved new misses remain usable but are marked unverified; inherited stale paths stay blocked." - ], - "verification_summary": { - "needs_manual_check": 32, - "missing_in_css": 4 - } + ] } diff --git a/1020096/VERIFICATION.md b/1020096/VERIFICATION.md new file mode 100644 index 0000000..7103bdf --- /dev/null +++ b/1020096/VERIFICATION.md @@ -0,0 +1,30 @@ +# Spotify 1.2.96.518 (1020096) + +Pipeline: inherit(1020094 -> 1020096) + static target-CSS verification + CDP e2e (deep). + +## Notes + +- Classmap inherited byte-for-byte from 1020094; no migration guesses were accepted. +- Static verification found 32/36 paths in the target CSS. +- One CSS-only miss was observed live and remains verified. +- Deep CDP verification observed 20/36 paths on the routes and transient surfaces exercised by the verifier. +- Unresolved new misses remain usable but are marked unverified; inherited stale paths stay blocked. + +## Statistics at publication + +| Field | Value | +| --- | --- | +| leaves | 36 | +| inherited | 36 | +| static_present | 32 | +| verified_cdp | 20 | +| live_only | 1 | +| unresolved_missing | 3 | +| stale | 2 | +| cdp_hit_rate | 0.5556 | +| overlay_entries | 3 | + +## Verification summary at publication + +- Needs manual check: 32 +- Missing in CSS: 4 diff --git a/1020097/META.json b/1020097/META.json index 9f8c7ad..c2f6166 100644 --- a/1020097/META.json +++ b/1020097/META.json @@ -1,53 +1,34 @@ { - "spotify_version": "1.2.97.270", "classmap_key": "1020097", - "status": "verified", "generated": "2026-08-24", - "inherited_from": "1020096", - "pipeline": "inherit(1020096 -> 1020097) + static target-CSS verification + CDP e2e (deep)", - "stats": { - "leaves": 36, - "inherited": 36, - "static_present": 32, - "verified_cdp": 21, - "live_only": 0, - "unresolved_missing": 4, - "stale": 2, - "cdp_hit_rate": 0.5833, - "overlay_entries": 3 - }, "required_paths": { + "context_menu.menu_item": "verified_cdp", + "main.navbar.link.wrapper": "verified_cdp", "main.playbar.buttons.button.wrapper": "verified_cdp", "main.topbar.wrapper": "verified_cdp", - "main.navbar.link.wrapper": "verified_cdp", - "tracklist.column_header": "verified_cdp", - "context_menu.menu_item": "verified_cdp", + "modal.track_credits.container": "verified_static", + "modal.widget_generator.container": "verified_static", "search_box.container": "verified_cdp", "search_chips.wrapper": "verified_cdp", - "sort_box.list.button": "verified_cdp", + "settings.button.wrapper": "verified_cdp", "settings.header.container": "verified_cdp", "settings.section.container": "verified_cdp", - "settings.button.wrapper": "verified_cdp", - "modal.track_credits.container": "verified (inherited; present in target CSS)", - "modal.widget_generator.container": "verified (inherited; present in target CSS)" + "sort_box.list.button": "verified_cdp", + "tracklist.column_header": "verified_cdp" + }, + "schema_version": 2, + "source": { + "key": "1020096", + "method": "inherited" }, + "spotify_version": "1.2.97.270", "stale_leaves": [ "main.topbar.left.button.icon.wrapper", "main.topbar.right.upgrade_button.wrapper" ], + "status": "verified", "unverified_leaves": [ "main.playbar.buttons.button.wrapper__indicator", "settings.text_input" - ], - "notes": [ - "Classmap inherited byte-for-byte from 1020096; no migration guesses were accepted.", - "Static verification found 32/36 paths in the target CSS.", - "0 CSS-only misses were observed live and remain verified.", - "Deep CDP verification observed 21/36 paths on the routes and transient surfaces exercised by the verifier.", - "Unresolved new misses remain usable but are marked unverified; inherited stale paths stay blocked." - ], - "verification_summary": { - "needs_manual_check": 32, - "missing_in_css": 4 - } + ] } diff --git a/1020097/VERIFICATION.md b/1020097/VERIFICATION.md new file mode 100644 index 0000000..9506486 --- /dev/null +++ b/1020097/VERIFICATION.md @@ -0,0 +1,30 @@ +# Spotify 1.2.97.270 (1020097) + +Pipeline: inherit(1020096 -> 1020097) + static target-CSS verification + CDP e2e (deep). + +## Notes + +- Classmap inherited byte-for-byte from 1020096; no migration guesses were accepted. +- Static verification found 32/36 paths in the target CSS. +- 0 CSS-only misses were observed live and remain verified. +- Deep CDP verification observed 21/36 paths on the routes and transient surfaces exercised by the verifier. +- Unresolved new misses remain usable but are marked unverified; inherited stale paths stay blocked. + +## Statistics at publication + +| Field | Value | +| --- | --- | +| leaves | 36 | +| inherited | 36 | +| static_present | 32 | +| verified_cdp | 21 | +| live_only | 0 | +| unresolved_missing | 4 | +| stale | 2 | +| cdp_hit_rate | 0.5833 | +| overlay_entries | 3 | + +## Verification summary at publication + +- Needs manual check: 32 +- Missing in CSS: 4 diff --git a/1020098/META.json b/1020098/META.json index caeea87..2326733 100644 --- a/1020098/META.json +++ b/1020098/META.json @@ -1,53 +1,34 @@ { - "spotify_version": "1.2.98.301", "classmap_key": "1020098", - "status": "verified", "generated": "2026-09-05", - "inherited_from": "1020097", - "pipeline": "inherit(1020097 -> 1020098) + static target-CSS verification + CDP e2e (deep)", - "stats": { - "leaves": 36, - "inherited": 36, - "static_present": 32, - "verified_cdp": 21, - "live_only": 0, - "unresolved_missing": 4, - "stale": 2, - "cdp_hit_rate": 0.5833, - "overlay_entries": 3 - }, "required_paths": { + "context_menu.menu_item": "verified_cdp", + "main.navbar.link.wrapper": "verified_cdp", "main.playbar.buttons.button.wrapper": "verified_cdp", "main.topbar.wrapper": "verified_cdp", - "main.navbar.link.wrapper": "verified_cdp", - "tracklist.column_header": "verified_cdp", - "context_menu.menu_item": "verified_cdp", + "modal.track_credits.container": "verified_static", + "modal.widget_generator.container": "verified_static", "search_box.container": "verified_cdp", "search_chips.wrapper": "verified_cdp", - "sort_box.list.button": "verified_cdp", + "settings.button.wrapper": "verified_cdp", "settings.header.container": "verified_cdp", "settings.section.container": "verified_cdp", - "settings.button.wrapper": "verified_cdp", - "modal.track_credits.container": "verified (inherited; present in target CSS)", - "modal.widget_generator.container": "verified (inherited; present in target CSS)" + "sort_box.list.button": "verified_cdp", + "tracklist.column_header": "verified_cdp" + }, + "schema_version": 2, + "source": { + "key": "1020097", + "method": "inherited" }, + "spotify_version": "1.2.98.301", "stale_leaves": [ "main.topbar.left.button.icon.wrapper", "main.topbar.right.upgrade_button.wrapper" ], + "status": "verified", "unverified_leaves": [ "main.playbar.buttons.button.wrapper__indicator", "settings.text_input" - ], - "notes": [ - "Classmap inherited byte-for-byte from 1020097; no migration guesses were accepted.", - "Static verification found 32/36 paths in the target CSS.", - "0 CSS-only misses were observed live and remain verified.", - "Deep CDP verification observed 21/36 paths on the routes and transient surfaces exercised by the verifier.", - "Unresolved new misses remain usable but are marked unverified; inherited stale paths stay blocked." - ], - "verification_summary": { - "needs_manual_check": 32, - "missing_in_css": 4 - } + ] } diff --git a/1020098/VERIFICATION.md b/1020098/VERIFICATION.md new file mode 100644 index 0000000..cf2003e --- /dev/null +++ b/1020098/VERIFICATION.md @@ -0,0 +1,30 @@ +# Spotify 1.2.98.301 (1020098) + +Pipeline: inherit(1020097 -> 1020098) + static target-CSS verification + CDP e2e (deep). + +## Notes + +- Classmap inherited byte-for-byte from 1020097; no migration guesses were accepted. +- Static verification found 32/36 paths in the target CSS. +- 0 CSS-only misses were observed live and remain verified. +- Deep CDP verification observed 21/36 paths on the routes and transient surfaces exercised by the verifier. +- Unresolved new misses remain usable but are marked unverified; inherited stale paths stay blocked. + +## Statistics at publication + +| Field | Value | +| --- | --- | +| leaves | 36 | +| inherited | 36 | +| static_present | 32 | +| verified_cdp | 21 | +| live_only | 0 | +| unresolved_missing | 4 | +| stale | 2 | +| cdp_hit_rate | 0.5833 | +| overlay_entries | 3 | + +## Verification summary at publication + +- Needs manual check: 32 +- Missing in CSS: 4 diff --git a/1020099/META.json b/1020099/META.json index 2f700f1..58eaff8 100644 --- a/1020099/META.json +++ b/1020099/META.json @@ -1,91 +1,31 @@ { - "spotify_version": "1.2.99.317", "classmap_key": "1020099", - "status": "verified", "generated": "2026-09-05", - "inherited_from": "1020097", - "pipeline": "runtime CDP extraction (Spotify --remote-debugging-port=9222) + live DOM/CSS verification + static xpui CSS cross-check", - "stats": { - "leaves": 36, - "inherited": 2, - "static_present": 36, - "verified_cdp": 25, - "live_only": 0, - "unresolved_missing": 0, - "stale": 1, - "cdp_hit_rate": 0.6944, - "overlay_entries": 2 - }, "required_paths": { + "context_menu.menu_item": "verified_cdp", + "main.navbar.link.wrapper": "verified_cdp", "main.playbar.buttons.button.wrapper": "verified_cdp", "main.topbar.wrapper": "verified_cdp", - "main.navbar.link.wrapper": "verified_cdp", - "tracklist.column_header": "verified_cdp", - "context_menu.menu_item": "verified_cdp", + "modal.track_credits.container": "verified_cdp", + "modal.widget_generator.container": "verified_cdp", "search_box.container": "verified_cdp", - "sort_box.list.button": "verified_cdp", + "settings.button.wrapper": "verified_cdp", "settings.header.container": "unverified", "settings.section.container": "verified_cdp", - "settings.button.wrapper": "verified_cdp", - "modal.track_credits.container": "verified_cdp", - "modal.widget_generator.container": "verified_cdp" + "sort_box.list.button": "verified_cdp", + "tracklist.column_header": "verified_cdp" }, + "schema_version": 2, + "source": { + "key": "1020097", + "method": "derived" + }, + "spotify_version": "1.2.99.317", "stale_leaves": [ "main.topbar.right.upgrade_button.wrapper" ], + "status": "verified", "unverified_leaves": [ - "main.topbar.right.upgrade_button.wrapper", "settings.header.container" - ], - "notes": [ - "Values were extracted from the live 1.2.99.317 client over CDP (port 9222), not guessed.", - "Spotify 1.2.99 replaced most hashed css-module classnames with readable names; encore build tag moved e-10451 -> e-10810.", - "Topbar merged into Root__globalNav; wrapper class oy96nRTKcIs7wm5K observed on [data-testid=global-nav-bar].", - "Playbar controls use encore legacy-button shapes; active state = legacy-button-primary.", - "Search category chips render through the encore chip factory (e-10810-chip-group* rules verified in CSS).", - "Track credits modal now ships readable main-trackCreditsModalV2-* classes.", - "upgrade_button has no rendered instance on this premium account; value taken from the encore legacy-button-primary factory shape (stale-marked, same policy as 1020097).", - "Historical extraction observed 25/36 leaves in the DOM and 11/36 only in live CSS. The headline statistics retain that extraction; the current candidate run is recorded separately below.", - "The settings overlay now maps KsnD09q3oqAjJX3e to x-settings-outputSectionHeader and fNaaQ0Cp8Yzy19j8 to x-settings-section. The old hashes belong to a concert-location dialog in the exact 1.2.99 archive.", - "Tertiary button leaves include both legacy-button and legacy-button-tertiary, plus generic icon-only padding where applicable. The playbar retains its small-size modifier; the medium modifier has no CSS rule.", - "The left button icon was observed by the current deep verifier and is no longer stale. Matching the generic primary-button classes does not establish the unrendered upgrade-button role, so that leaf remains stale." - ], - "verification_summary": { - "cdp_dom_probes": 47, - "css_classes_live": 4726, - "dom_hits": 25, - "css_only_hits": 11, - "misses": 0, - "scope": "Historical 2026-09-05 extraction; CSS presence is not DOM observation." - }, - "verification_runs": { - "macos_arm64_2026_09_29": { - "spotify_version": "1.2.99.317", - "os": "macOS", - "architecture": "arm64", - "client_state": "patched", - "cli_version": "3.0.0-beta.17", - "cli_source": "d74970010d22a9497bf84070a6ff75801a74657f", - "cli_path": "~/.spicetify/spicetify", - "daemon": "Stopped during isolated-client verification; no daemon runtime claim.", - "classmap_sha256": "d015b23c52199fe287ad60f1da8c34c168349d6dd3b9f6a66e874bda1740c2e6", - "deep_hits": 24, - "deep_total": 36, - "deep_navigation_succeeded": 7, - "deep_navigation_attempted": 8, - "limitations": [ - "Windows and Linux were not rerun.", - "The output-device Settings header was not rendered.", - "The candidate map was selected through SPICETIFY_CLASSMAPS_DIR; the published feed path was not exercised.", - "The deep verifier did not confirm context-menu activation.", - "The upgrade-button role and transient modal families were not exercised." - ], - "apply": "Passed using the candidate 1020099 classmap and corrected two-entry settings overlay.", - "live_workflows": [ - "Opened native Settings from the account menu; 14 correctly mapped sections render as grids with aligned labels and controls.", - "Opened Module Store through its topbar button. Home and Store retain circular 48px hit areas." - ], - "theme": "Existing local Ziro 0.1.6 override; no claim of published-theme validation." - } - } + ] } diff --git a/1020099/VERIFICATION.md b/1020099/VERIFICATION.md new file mode 100644 index 0000000..7a7a998 --- /dev/null +++ b/1020099/VERIFICATION.md @@ -0,0 +1,69 @@ +# Spotify 1.2.99.317 (1020099) + +Pipeline: runtime CDP extraction (Spotify --remote-debugging-port=9222) + live DOM/CSS verification + static xpui CSS cross-check. + +## Notes + +- Values were extracted from the live 1.2.99.317 client over CDP (port 9222), not guessed. +- The Encore build tag moved from e-10451 to e-10810. +- Topbar merged into Root__globalNav; wrapper class oy96nRTKcIs7wm5K observed on [data-testid=global-nav-bar]. +- Playbar controls use encore legacy-button shapes; active state = legacy-button-primary. +- Search category chips render through the encore chip factory (e-10810-chip-group* rules verified in CSS). +- The extraction ran on a patched client, so 17 leaves (navigation links, both modal families, settings containers, sort box, search container, context menu and the search expand icon) were first recorded as their css-map names. The stock 1.2.99.317 archive contains only the hashed classes. On 2026-09-30 those leaves were rewritten to the one stock hash per name present in that archive; the css-map stages each back to the same name. +- upgrade_button has no rendered instance on this premium account; value taken from the encore legacy-button-primary factory shape (stale-marked, same policy as 1020097). +- Historical extraction observed 25/36 leaves in the DOM and 11/36 only in live CSS. The headline statistics retain that extraction; the 2026-09-29 run is recorded under Verification runs. +- The settings overlay now maps KsnD09q3oqAjJX3e to x-settings-outputSectionHeader and fNaaQ0Cp8Yzy19j8 to x-settings-section. The old hashes belong to a concert-location dialog in the exact 1.2.99 archive. +- Tertiary button leaves include both legacy-button and legacy-button-tertiary, plus generic icon-only padding where applicable. The playbar retains its small-size modifier; the medium modifier has no CSS rule. +- The left button icon was observed by the current deep verifier and is no longer stale. Matching the generic primary-button classes does not establish the unrendered upgrade-button role, so that leaf remains stale. + +## Statistics at publication + +| Field | Value | +| --- | --- | +| leaves | 36 | +| inherited | 2 | +| static_present | 36 | +| verified_cdp | 25 | +| live_only | 0 | +| unresolved_missing | 0 | +| stale | 1 | +| cdp_hit_rate | 0.6944 | +| overlay_entries | 2 | + +## Verification summary at publication + +- CDP DOM probes: 47 +- CSS classes live: 4726 +- DOM hits: 25 +- CSS only hits: 11 +- Misses: 0 +- Scope: Historical 2026-09-05 extraction; CSS presence is not DOM observation. + +## Verification runs + +### macos_arm64_2026_09_29 + +- Spotify version: 1.2.99.317 +- OS: macOS +- Architecture: arm64 +- Client state: patched +- CLI version: 3.0.0-beta.17 +- CLI source: d74970010d22a9497bf84070a6ff75801a74657f +- CLI path: ~/.spicetify/spicetify +- Daemon: Stopped during isolated-client verification; no daemon runtime claim. +- Classmap SHA-256: `d015b23c52199fe287ad60f1da8c34c168349d6dd3b9f6a66e874bda1740c2e6` +- Deep hits: 24 +- Deep total: 36 +- Deep navigation succeeded: 7 +- Deep navigation attempted: 8 +- Limitations: + - Windows and Linux were not rerun. + - The output-device Settings header was not rendered. + - The candidate map was selected through SPICETIFY_CLASSMAPS_DIR; the published feed path was not exercised. + - The deep verifier did not confirm context-menu activation. + - The upgrade-button role and transient modal families were not exercised. +- Apply: Passed using the candidate 1020099 classmap and corrected two-entry settings overlay. +- Live workflows: + - Opened native Settings from the account menu; 14 correctly mapped sections render as grids with aligned labels and controls. + - Opened Module Store through its topbar button. Home and Store retain circular 48px hit areas. +- Theme: Existing local Ziro 0.1.6 override; no claim of published-theme validation. diff --git a/1030000/META.json b/1030000/META.json index 9d19945..a8fd6ec 100644 --- a/1030000/META.json +++ b/1030000/META.json @@ -1,159 +1,28 @@ { - "spotify_version": "1.3.0.277", "classmap_key": "1030000", - "status": "verified", "generated": "2026-09-17", - "inherited_from": null, - "pipeline": "exact stock-CSS signature migration from 1020097 + static target-CSS verification + deep CDP e2e on macOS and Windows + targeted live DOM/CSSOM probes", - "stats": { - "leaves": 36, - "inherited": 0, - "static_present": 7, - "verified_cdp": 24, - "verified_targeted": 12, - "live_only": 19, - "unresolved_missing": 0, - "stale": 0, - "cdp_hit_rate": 0.6667, - "overlay_entries": 291 - }, "required_paths": { + "context_menu.menu_item": "verified_cdp", + "main.navbar.link.wrapper": "verified_cdp", "main.playbar.buttons.button.wrapper": "verified_cdp", "main.topbar.wrapper": "verified_cdp", - "main.navbar.link.wrapper": "verified_cdp", - "tracklist.column_header": "verified_cdp", - "context_menu.menu_item": "verified_cdp", + "modal.track_credits.container": "verified_targeted", + "modal.widget_generator.container": "verified_targeted", "search_box.container": "verified_cdp", - "search_chips.wrapper": "verified_live_cssom", - "sort_box.list.button": "verified_cdp", - "settings.header.container": "verified_targeted_cdp", - "settings.section.container": "verified_targeted_cdp", + "search_chips.wrapper": "verified_targeted", "settings.button.wrapper": "verified_cdp", - "modal.track_credits.container": "verified_targeted_cdp", - "modal.widget_generator.container": "verified_targeted_cdp" - }, - "stale_leaves": [], - "unverified_leaves": [], - "notes": [ - "Spotify 1.3.0 is a CSS-module rehash, so this key was derived rather than inherited from a 1.2.x classmap.", - "The 280-entry css-map overlay was generated only from unique exact selector/declaration signatures shared by the stock 1.2.97 and 1.3.0 xpui stylesheets; 82 ambiguous signatures were excluded and 0 generated mappings conflicted with targeted live overrides.", - "Historical deep CDP runs observed 23/36 paths on stock macOS and 24/36 paths on the patched Windows 1.3.0.277 client. The Windows report records 7/8 successful navigation steps; context-menu activation was not confirmed.", - "Targeted CDP probes verified both modal families and the two settings container hashes that the deep verifier did not render.", - "The remaining search chip-group and Encore control literals are absent from the static xpui stylesheet because Encore loads them at runtime; exact e-10860 selectors were verified recursively in the live CSSOM and the corresponding chip/control instances were observed in the DOM.", - "Confirmed settings.section.container (HdwEUcX8xijMdU7doYPt -> x-settings-section) and settings.header.container (Hwax_pfCzrntLhnYXiqL -> x-settings-outputSectionHeader) bridge correctly across both platforms.", - "The source artifacts were Spotify's notarized 1.3.0.277 macOS DMG and Windows Desktop 1.3.0.277 stock installation.", - "The topbar, navigation-link and outer search-container leaves retain their original role-specific classes. The Windows-observed opacity, no-drag and inner-form helper classes coexist with them and are not substitutes for their layout or styling.", - "The headline statistics and targeted checks describe the historical verification runs. Current candidate coverage is recorded separately in verification_reports.macos_2026_09_29." - ], - "verification_summary": { - "deep_dom_hits": 24, - "targeted_live_hits": 12, - "target_static_hits": 7, - "target_static_misses_verified_live": 19, - "deep_navigation_attempted": 8, - "deep_navigation_succeeded": 7, - "css_overlay_conflicts": 0, - "css_overlay_ambiguous_excluded": 82 + "settings.header.container": "verified_targeted", + "settings.section.container": "verified_targeted", + "sort_box.list.button": "verified_cdp", + "tracklist.column_header": "verified_cdp" }, - "regression_checks": { - "topbar_right_button": { - "date": "2026-09-15", - "spotify_version": "1.3.0.277", - "path": "main.topbar.right.button_t.wrapper", - "class": "nR3ekhH3c1IU7dX9smcR", - "staged_class": "main-topBar-buddyFeed", - "before_hitbox_px": [ - 16, - 16 - ], - "after_hitbox_px": [ - 32, - 32 - ], - "native_hitbox_px": [ - 32, - 32 - ], - "gap_px": 8, - "verification": "Measured Bookmark, Full App Display, and Popup Lyrics after Rust apply and a client restart. Their hitboxes and gaps match the native notification and activity buttons. The unchanged 1.2.97 classmap resolves to the same semantic class, whose archived CSS also specifies 32px width and height." - }, - "root_containers": { - "date": "2026-09-15", - "spotify_version": "1.3.0.277", - "mappings": { - "bQetA_KiP9n0DQVcGa7m": "Root__globalNav", - "PIP22o58Crv8RXY4wB2o": "Root__nav-bar", - "itzKVxWhS4n59fp_KIVc": "Root__now-playing-bar" - }, - "verification": "Stock CSS and live child controls confirm the global-nav, left-sidebar, and now-playing-bar roles. After applying with Rust CLI 3.0.0-beta.17 on macOS Spotify 1.3.0.277, all three hooks exist and Text's Spicetify scheme paints them rgb(46, 40, 55). Navigation borders and spacing return; no Text CSS change was needed. Unthemed container rectangles, padding, borders, and backgrounds match the pre-apply measurements. Flow's player occupies its own 200px right column without overlapping the main view. Profile navigation, library sorting, the compact-library toggle and restoration, a Settings select, Bookmark, and the queue were exercised through pointer events. Module and native toolbar buttons retain 32px hitboxes and 8px gaps.", - "previous_version_check": { - "spotify_version": "1.2.94", - "method": "archived stock CSS inspection", - "verification": "The embedded CSS-map already maps qPaMr9Jzt0_Doy3C, vEHGULrufZMHBNrp, and YdGOYWQYr6kqh7KU to the same three hooks. Their stock declarations preserve the same grid areas, sizing, and player gutters as 1.3.0; black library and player container backgrounds are already present in 1.2.94. Older version maps remain unchanged. This is archived CSS evidence, not a live older-client check." - } - }, - "settings_toggle_input": { - "date": "2026-09-15", - "spotify_version": "1.3.0.277", - "class": "utdiMuyxdKvPowN1CIBs", - "staged_class": "x-toggle-input", - "verification": "The native checkbox uses opacity:0, pointer-events:none, and position:absolute in five stock stylesheets. The missing semantic hook left stdlib Settings rows showing both a native checkbox and their styled indicator. Reapply restores the shared input styling; live Spicetify Settings inputs have opacity 0 and absolute positioning while their toggle indicators remain visible. Regression coverage fails without the mapping." - }, - "playback_bar": { - "date": "2026-09-16", - "spotify_version": "1.3.0.277", - "mappings": { - "Ox6fJ7l6WeB1wD0MqHXb": "playback-bar", - "LppHcR524PsiPr4sz5IV": "playback-bar__progress-time-elapsed", - "TcCS0BhwDXw_s2HufffR": "main-playbackBarRemainingTime-container" - }, - "verification": "Stock CSS and live playback controls confirm the container, elapsed-time, and duration roles. Applied through Rust CLI 3.0.0-beta.17 on macOS with published Text 0.1.6. Text restores its full-width 16px seek track. Text 0.1.6 returns the absolutely positioned timestamp spans to normal flow and aligns the seek handle. Pointer seeking, ArrowRight seeking, and the duration/remaining-time toggle passed through UI input.", - "previous_version_check": "Archived Spotify 1.2.94 CSS maps o9SONbmdTWwKgbUo, _xaGcuWD6w4FNkGu, and jYB3Yggec0UDIsZh to the same hooks and preserves their container and timestamp roles. Older maps are unchanged. No older client was run live." - }, - "entity_header_backgrounds": { - "date": "2026-09-29", - "spotify_version": "1.3.0.277", - "mappings": { - "kMUtWcOMOZmCWTMAc_gu": "main-entityHeader-background", - "ELsCw_Koqs_Q2dLHtg7z": "main-entityHeader-overlay", - "M7ECcaA17LC8UqL2GKVD": "main-actionBarBackground-background", - "dqwQhIudKoD98eWJzj5E": "main-home-homeHeader" - }, - "verification": "Stock CSS confirms the roles: kMUtWc is the absolute full-size header layer that carries the inline colour, ELsCw adds the dark gradient on a second kMUtWc layer, M7ECcaA is the 232px z-index -1 gradient behind the action bar, and dqwQhIud is the Home header gradient hidden by its Jyc7 modifier. Spotify 1.3 dropped the separate backgroundColor modifier, so the colour layer maps to the shared background hook. Applied with SPICETIFY_CLASSMAPS_DIR through Rust CLI 3.0.0-beta.17 on macOS: on Burial (avatar header), Liked Songs and Home the readable classes are present, Ziro 0.1.5 hides the action-bar gradient and Home header, and Text renders its flat header. All 14 first-party themes reference these hooks and matched nothing on 1.3.0 before.", - "previous_version_check": "The base css-map maps older hashes to the same four hooks; dribbblish and matte select .main-entityHeader-background.main-entityHeader-overlay, matching the two-class overlay layer. No older client was run live." - } + "schema_version": 2, + "source": { + "key": "1020097", + "method": "derived" }, - "verification_reports": { - "windows_2026_09_16": { - "classmap_sha256": "879140c1dddb65b2ff14440c34abe3b2d166274a1309ce3214108e21c8430bc7", - "client_state": "patched", - "cdp": "https://github.com/user-attachments/files/32311568/1030000-cdp.json", - "static": "https://github.com/user-attachments/files/32311573/1030000-static.json", - "navigation_attempted": 8, - "navigation_succeeded": 7, - "note": "Historical evidence for the contributor map before restoring the topbar, navigation-link and outer-search-container roles." - }, - "macos_2026_09_29": { - "spotify_version": "1.3.0.277", - "os": "macOS", - "architecture": "arm64", - "client_state": "patched", - "cli_version": "3.0.0-beta.17", - "classmap_sha256": "f0a79a96c949b016686a4b4f2feaefee6540652135799133bfa3d1b1411f18d5", - "deep_hits": 17, - "deep_total": 36, - "deep_navigation_succeeded": 7, - "deep_navigation_attempted": 8, - "targeted": "Home is a centered circular 48px button using main-globalNav-navLink. Native Settings has 14 correctly mapped sections and 22 toggle inputs.", - "limitations": [ - "Windows and Linux were not rerun.", - "The output-device Settings header was not rendered.", - "The candidate map was selected through SPICETIFY_CLASSMAPS_DIR; the published feed path was not exercised.", - "The deep verifier did not confirm context-menu activation." - ], - "cli_source": "d74970010d22a9497bf84070a6ff75801a74657f", - "cli_path": "~/.spicetify/spicetify", - "daemon": "Stopped during isolated-client verification; no daemon runtime claim." - } - } + "spotify_version": "1.3.0.277", + "stale_leaves": [], + "status": "verified", + "unverified_leaves": [] } diff --git a/1030000/VERIFICATION.md b/1030000/VERIFICATION.md new file mode 100644 index 0000000..dbf01bf --- /dev/null +++ b/1030000/VERIFICATION.md @@ -0,0 +1,136 @@ +# Spotify 1.3.0.277 (1030000) + +Pipeline: exact stock-CSS signature migration from 1020097 + static target-CSS verification + deep CDP e2e on macOS and Windows + targeted live DOM/CSSOM probes. + +## Notes + +- Spotify 1.3.0 is a CSS-module rehash, so this key was derived rather than inherited from a 1.2.x classmap. +- The 280-entry css-map overlay was generated only from unique exact selector/declaration signatures shared by the stock 1.2.97 and 1.3.0 xpui stylesheets; 82 ambiguous signatures were excluded and 0 generated mappings conflicted with targeted live overrides. +- Historical deep CDP runs observed 23/36 paths on stock macOS and 24/36 paths on the patched Windows 1.3.0.277 client. The Windows report records 7/8 successful navigation steps; context-menu activation was not confirmed. +- Targeted CDP probes verified both modal families and the two settings container hashes that the deep verifier did not render. +- The remaining search chip-group and Encore control literals are absent from the static xpui stylesheet because Encore loads them at runtime; exact e-10860 selectors were verified recursively in the live CSSOM and the corresponding chip/control instances were observed in the DOM. +- Confirmed settings.section.container (HdwEUcX8xijMdU7doYPt -> x-settings-section) and settings.header.container (Hwax_pfCzrntLhnYXiqL -> x-settings-outputSectionHeader) bridge correctly across both platforms. +- The source artifacts were Spotify's notarized 1.3.0.277 macOS DMG and Windows Desktop 1.3.0.277 stock installation. +- The topbar, navigation-link and outer search-container leaves retain their original role-specific classes. The Windows-observed opacity, no-drag and inner-form helper classes coexist with them and are not substitutes for their layout or styling. +- The headline statistics and targeted checks describe the historical verification runs. Current candidate coverage is recorded under the macos_2026_09_29 run. +- On 2026-09-30 the modal, sort-box and context-menu leaves, which had been stored as their css-map names, were rewritten to their stock hashes from the 291-entry overlay. The css-map stages each back to the same name, and the map is again byte-identical to 1030001. + +## Statistics at publication + +| Field | Value | +| --- | --- | +| leaves | 36 | +| inherited | 0 | +| static_present | 7 | +| verified_cdp | 24 | +| verified_targeted | 12 | +| live_only | 19 | +| unresolved_missing | 0 | +| stale | 0 | +| cdp_hit_rate | 0.6667 | +| overlay_entries | 291 | + +## Verification summary at publication + +- Deep DOM hits: 24 +- Targeted live hits: 12 +- Target static hits: 7 +- Target static misses verified live: 19 +- Deep navigation attempted: 8 +- Deep navigation succeeded: 7 +- CSS overlay conflicts: 0 +- CSS overlay ambiguous excluded: 82 + +## Regression checks + +### topbar_right_button + +- Date: 2026-09-15 +- Spotify version: 1.3.0.277 +- Path: main.topbar.right.button_t.wrapper +- Class: nR3ekhH3c1IU7dX9smcR +- Staged class: main-topBar-buddyFeed +- Before hitbox (px): 16 x 16 +- After hitbox (px): 32 x 32 +- Native hitbox (px): 32 x 32 +- Gap (px): 8 +- Verification: Measured Bookmark, Full App Display, and Popup Lyrics after Rust apply and a client restart. Their hitboxes and gaps match the native notification and activity buttons. The unchanged 1.2.97 classmap resolves to the same semantic class, whose archived CSS also specifies 32px width and height. + +### root_containers + +- Date: 2026-09-15 +- Spotify version: 1.3.0.277 +- Mappings: + - `bQetA_KiP9n0DQVcGa7m`: Root__globalNav + - `PIP22o58Crv8RXY4wB2o`: Root__nav-bar + - `itzKVxWhS4n59fp_KIVc`: Root__now-playing-bar +- Verification: Stock CSS and live child controls confirm the global-nav, left-sidebar, and now-playing-bar roles. After applying with Rust CLI 3.0.0-beta.17 on macOS Spotify 1.3.0.277, all three hooks exist and Text's Spicetify scheme paints them rgb(46, 40, 55). Navigation borders and spacing return; no Text CSS change was needed. Unthemed container rectangles, padding, borders, and backgrounds match the pre-apply measurements. Flow's player occupies its own 200px right column without overlapping the main view. Profile navigation, library sorting, the compact-library toggle and restoration, a Settings select, Bookmark, and the queue were exercised through pointer events. Module and native toolbar buttons retain 32px hitboxes and 8px gaps. +- Previous version check: + - Spotify version: 1.2.94 + - Method: archived stock CSS inspection + - Verification: The embedded CSS-map already maps qPaMr9Jzt0_Doy3C, vEHGULrufZMHBNrp, and YdGOYWQYr6kqh7KU to the same three hooks. Their stock declarations preserve the same grid areas, sizing, and player gutters as 1.3.0; black library and player container backgrounds are already present in 1.2.94. Older version maps remain unchanged. This is archived CSS evidence, not a live older-client check. + +### settings_toggle_input + +- Date: 2026-09-15 +- Spotify version: 1.3.0.277 +- Class: utdiMuyxdKvPowN1CIBs +- Staged class: x-toggle-input +- Verification: The native checkbox uses opacity:0, pointer-events:none, and position:absolute in five stock stylesheets. The missing semantic hook left stdlib Settings rows showing both a native checkbox and their styled indicator. Reapply restores the shared input styling; live Spicetify Settings inputs have opacity 0 and absolute positioning while their toggle indicators remain visible. Regression coverage fails without the mapping. + +### playback_bar + +- Date: 2026-09-16 +- Spotify version: 1.3.0.277 +- Mappings: + - `Ox6fJ7l6WeB1wD0MqHXb`: playback-bar + - `LppHcR524PsiPr4sz5IV`: playback-bar__progress-time-elapsed + - `TcCS0BhwDXw_s2HufffR`: main-playbackBarRemainingTime-container +- Verification: Stock CSS and live playback controls confirm the container, elapsed-time, and duration roles. Applied through Rust CLI 3.0.0-beta.17 on macOS with published Text 0.1.6. Text restores its full-width 16px seek track. Text 0.1.6 returns the absolutely positioned timestamp spans to normal flow and aligns the seek handle. Pointer seeking, ArrowRight seeking, and the duration/remaining-time toggle passed through UI input. +- Previous version check: Archived Spotify 1.2.94 CSS maps o9SONbmdTWwKgbUo, _xaGcuWD6w4FNkGu, and jYB3Yggec0UDIsZh to the same hooks and preserves their container and timestamp roles. Older maps are unchanged. No older client was run live. + +### entity_header_backgrounds + +- Date: 2026-09-29 +- Spotify version: 1.3.0.277 +- Mappings: + - `kMUtWcOMOZmCWTMAc_gu`: main-entityHeader-background + - `ELsCw_Koqs_Q2dLHtg7z`: main-entityHeader-overlay + - `M7ECcaA17LC8UqL2GKVD`: main-actionBarBackground-background + - `dqwQhIudKoD98eWJzj5E`: main-home-homeHeader +- Verification: Stock CSS confirms the roles: kMUtWc is the absolute full-size header layer that carries the inline colour, ELsCw adds the dark gradient on a second kMUtWc layer, M7ECcaA is the 232px z-index -1 gradient behind the action bar, and dqwQhIud is the Home header gradient hidden by its Jyc7 modifier. Spotify 1.3 dropped the separate backgroundColor modifier, so the colour layer maps to the shared background hook. Applied with SPICETIFY_CLASSMAPS_DIR through Rust CLI 3.0.0-beta.17 on macOS: on Burial (avatar header), Liked Songs and Home the readable classes are present, Ziro 0.1.5 hides the action-bar gradient and Home header, and Text renders its flat header. All 14 first-party themes reference these hooks and matched nothing on 1.3.0 before. +- Previous version check: The base css-map maps older hashes to the same four hooks; dribbblish and matte select .main-entityHeader-background.main-entityHeader-overlay, matching the two-class overlay layer. No older client was run live. + +## Verification runs + +### windows_2026_09_16 + +- Classmap SHA-256: `879140c1dddb65b2ff14440c34abe3b2d166274a1309ce3214108e21c8430bc7` +- Client state: patched +- CDP: https://github.com/user-attachments/files/32311568/1030000-cdp.json +- Static: https://github.com/user-attachments/files/32311573/1030000-static.json +- Navigation attempted: 8 +- Navigation succeeded: 7 +- Note: Historical evidence for the contributor map before restoring the topbar, navigation-link and outer-search-container roles. + +### macos_2026_09_29 + +- Spotify version: 1.3.0.277 +- OS: macOS +- Architecture: arm64 +- Client state: patched +- CLI version: 3.0.0-beta.17 +- Classmap SHA-256: `f0a79a96c949b016686a4b4f2feaefee6540652135799133bfa3d1b1411f18d5` +- Deep hits: 17 +- Deep total: 36 +- Deep navigation succeeded: 7 +- Deep navigation attempted: 8 +- Targeted: Home is a centered circular 48px button using main-globalNav-navLink. Native Settings has 14 correctly mapped sections and 22 toggle inputs. +- Limitations: + - Windows and Linux were not rerun. + - The output-device Settings header was not rendered. + - The candidate map was selected through SPICETIFY_CLASSMAPS_DIR; the published feed path was not exercised. + - The deep verifier did not confirm context-menu activation. +- CLI source: d74970010d22a9497bf84070a6ff75801a74657f +- CLI path: ~/.spicetify/spicetify +- Daemon: Stopped during isolated-client verification; no daemon runtime claim. diff --git a/1030001/META.json b/1030001/META.json index 909384f..c1dde24 100644 --- a/1030001/META.json +++ b/1030001/META.json @@ -1,83 +1,30 @@ { - "spotify_version": "1.3.1.234", "classmap_key": "1030001", - "status": "verified", "generated": "2026-09-18", - "inherited_from": "1030000", - "pipeline": "inherit(1030000 -> 1030001) + static target-CSS verification + CDP e2e (deep)", - "stats": { - "leaves": 36, - "inherited": 36, - "static_present": 32, - "verified_cdp": 24, - "live_only": 3, - "unresolved_missing": 1, - "stale": 0, - "cdp_hit_rate": 0.6667, - "overlay_entries": 291 - }, "required_paths": { + "context_menu.menu_item": "verified_cdp", + "main.navbar.link.wrapper": "verified_cdp", "main.playbar.buttons.button.wrapper": "verified_cdp", "main.topbar.wrapper": "verified_cdp", - "main.navbar.link.wrapper": "verified_cdp", - "tracklist.column_header": "verified_cdp", - "context_menu.menu_item": "verified_cdp", + "modal.track_credits.container": "verified_static", + "modal.widget_generator.container": "verified_static", "search_box.container": "verified_cdp", - "search_chips.wrapper": "verified (inherited; present in target CSS)", - "sort_box.list.button": "verified_cdp", + "search_chips.wrapper": "verified_static", + "settings.button.wrapper": "verified_cdp", "settings.header.container": "unverified", "settings.section.container": "verified_cdp", - "settings.button.wrapper": "verified_cdp", - "modal.track_credits.container": "verified (inherited; present in target CSS)", - "modal.widget_generator.container": "verified (inherited; present in target CSS)" + "sort_box.list.button": "verified_cdp", + "tracklist.column_header": "verified_cdp" + }, + "schema_version": 2, + "source": { + "key": "1030000", + "method": "inherited" }, + "spotify_version": "1.3.1.234", "stale_leaves": [], + "status": "verified", "unverified_leaves": [ "settings.header.container" - ], - "notes": [ - "Classmap inherited byte-for-byte from 1030000; no migration guesses were accepted.", - "Static verification found 32/36 paths in the target CSS.", - "3 CSS-only misses were observed live and remain verified.", - "Deep CDP verification observed 24/36 paths on the routes and transient surfaces exercised by the verifier.", - "Unresolved new misses remain usable but are marked unverified; inherited stale paths stay blocked.", - "The CSS overlay includes the four entity-header, action-bar and Home background hooks added to 1030000 on 2026-09-29. Their selectors and layout declarations also occur in the signed Spotify 1.3.1.234 macOS ARM64 stock archive; the 36-leaf classmap remains unchanged.", - "The headline statistics describe the original promotion. The patched macOS candidate run and its narrower observed coverage are recorded separately in verification_runs." - ], - "verification_summary": { - "needs_manual_check": 32, - "missing_in_css": 4 - }, - "verification_runs": { - "macos_arm64_2026_09_29": { - "spotify_version": "1.3.1.234", - "client_state": "patched", - "cli_version": "3.0.0-beta.17", - "cli_source": "d74970010d22a9497bf84070a6ff75801a74657f", - "stock_spa_sha256": "97824b5bc802dcdf7b13b35c790da1d49dd8c5415294375f585f393de2eb4c61", - "apply": "Passed using the candidate 1030001 classmap and 291-entry CSS overlay.", - "live_workflows": [ - "Opened Module Store through its topbar button.", - "Opened native Settings from the account menu; 14 sections and 22 toggles matched.", - "Searched for Burial and opened its artist page through the search result. All three entity/action-bar background hooks are present; Ziro hides the color and action-bar layers.", - "Home header hook is present and hidden by Ziro." - ], - "theme": "Existing local Ziro 0.1.6 override; no claim of published-theme validation.", - "limitations": [ - "Windows and Linux were not rerun.", - "The output-device Settings header was not rendered.", - "The candidate map was selected through SPICETIFY_CLASSMAPS_DIR; the published feed path was not exercised.", - "The deep verifier did not confirm context-menu activation." - ], - "os": "macOS", - "architecture": "arm64", - "cli_path": "~/.spicetify/spicetify", - "daemon": "Stopped during isolated-client verification; no daemon runtime claim.", - "classmap_sha256": "17d9575c38a5a7cce9d6bb28665aff0c6e88b4ce4d94955bad297e19c97b33dc", - "deep_hits": 15, - "deep_total": 36, - "deep_navigation_succeeded": 7, - "deep_navigation_attempted": 8 - } - } + ] } diff --git a/1030001/VERIFICATION.md b/1030001/VERIFICATION.md new file mode 100644 index 0000000..fb80804 --- /dev/null +++ b/1030001/VERIFICATION.md @@ -0,0 +1,63 @@ +# Spotify 1.3.1.234 (1030001) + +Pipeline: inherit(1030000 -> 1030001) + static target-CSS verification + CDP e2e (deep). + +## Notes + +- Classmap inherited byte-for-byte from 1030000; no migration guesses were accepted. +- Static verification found 32/36 paths in the target CSS. +- 3 CSS-only misses were observed live and remain verified. +- Deep CDP verification observed 24/36 paths on the routes and transient surfaces exercised by the verifier. +- Unresolved new misses remain usable but are marked unverified; inherited stale paths stay blocked. +- The CSS overlay includes the four entity-header, action-bar and Home background hooks added to 1030000 on 2026-09-29. Their selectors and layout declarations also occur in the signed Spotify 1.3.1.234 macOS ARM64 stock archive; the 36-leaf classmap remains unchanged. +- The headline statistics describe the original promotion. The patched macOS candidate run and its narrower observed coverage are recorded under Verification runs. + +## Statistics at publication + +| Field | Value | +| --- | --- | +| leaves | 36 | +| inherited | 36 | +| static_present | 32 | +| verified_cdp | 24 | +| live_only | 3 | +| unresolved_missing | 1 | +| stale | 0 | +| cdp_hit_rate | 0.6667 | +| overlay_entries | 291 | + +## Verification summary at publication + +- Needs manual check: 32 +- Missing in CSS: 4 + +## Verification runs + +### macos_arm64_2026_09_29 + +- Spotify version: 1.3.1.234 +- Client state: patched +- CLI version: 3.0.0-beta.17 +- CLI source: d74970010d22a9497bf84070a6ff75801a74657f +- Stock SPA SHA-256: `97824b5bc802dcdf7b13b35c790da1d49dd8c5415294375f585f393de2eb4c61` +- Apply: Passed using the candidate 1030001 classmap and 291-entry CSS overlay. +- Live workflows: + - Opened Module Store through its topbar button. + - Opened native Settings from the account menu; 14 sections and 22 toggles matched. + - Searched for Burial and opened its artist page through the search result. All three entity/action-bar background hooks are present; Ziro hides the color and action-bar layers. + - Home header hook is present and hidden by Ziro. +- Theme: Existing local Ziro 0.1.6 override; no claim of published-theme validation. +- Limitations: + - Windows and Linux were not rerun. + - The output-device Settings header was not rendered. + - The candidate map was selected through SPICETIFY_CLASSMAPS_DIR; the published feed path was not exercised. + - The deep verifier did not confirm context-menu activation. +- OS: macOS +- Architecture: arm64 +- CLI path: ~/.spicetify/spicetify +- Daemon: Stopped during isolated-client verification; no daemon runtime claim. +- Classmap SHA-256: `17d9575c38a5a7cce9d6bb28665aff0c6e88b4ce4d94955bad297e19c97b33dc` +- Deep hits: 15 +- Deep total: 36 +- Deep navigation succeeded: 7 +- Deep navigation attempted: 8 diff --git a/index.json b/index.json index bd9644b..0a28181 100644 --- a/index.json +++ b/index.json @@ -29,7 +29,7 @@ }, "meta": { "file": "META.json", - "sha256": "0670e8c0d8e34b31612f986ba1bb3ab917c38fdbd4006255b4f27efdb48093ba" + "sha256": "638e8c8d0356a271a7a08eef0abf6dff43d326a33b30a19d8a18c3a55d7a2974" }, "spotifyVersion": "1.2.84.476", "status": "verified" @@ -45,7 +45,7 @@ }, "meta": { "file": "META.json", - "sha256": "6866bb9564e11aa019c5bc18f9185f2ee2f4ba4d696f9fb5e7905d5d0745a199" + "sha256": "d40d57191f05cc1cc8be1935fdf9b707030be31a6357650bab63484cd173dcdb" }, "spotifyVersion": "1.2.92.148", "status": "verified" @@ -61,7 +61,7 @@ }, "meta": { "file": "META.json", - "sha256": "b9924ef7604c1707e9e3732141fca4b552b63e1f2d3f4729aea83c2b360976ac" + "sha256": "8395c6de8c80b3cdf1619856d8e68f059b365934eb4eff4f5cc97edc72104ccf" }, "spotifyVersion": "1.2.94.583", "status": "verified" @@ -77,7 +77,7 @@ }, "meta": { "file": "META.json", - "sha256": "3da34efd30775fd5afb78607483d61afb0f7dfb1626695296c405d815d48cbf1" + "sha256": "34b677bcea4deaa22113e6885ba9b6018bf13dd6addc12b115aef166c21eec21" }, "spotifyVersion": "1.2.95.453", "status": "verified" @@ -93,7 +93,7 @@ }, "meta": { "file": "META.json", - "sha256": "5d5d57a74ebb2b2d7614258df1f6b81eac636cb3d138068e9dd1ff3595a539c4" + "sha256": "29d2c241233ba11a0c74fda59b770a1911d6d36ba8e9eb576196686fa7f74889" }, "spotifyVersion": "1.2.96.518", "status": "verified" @@ -109,7 +109,7 @@ }, "meta": { "file": "META.json", - "sha256": "773129d3306739588e90c0abe4cf32311f8ac39a1448bca2b3984cf80cfe2c68" + "sha256": "f45a5257e28d6b3f5c3d97fa10117678935d575f3b2f5741756a65c51e8747bc" }, "spotifyVersion": "1.2.97.270", "status": "verified" @@ -125,7 +125,7 @@ }, "meta": { "file": "META.json", - "sha256": "fa0334b0b336907d6a796e259c2607907bf4e1a028545c1a4e105621130bab87" + "sha256": "1cee8b708c0f7d15aaef43fd6f9ec3cfbb8ccdd51143b7d9cf461c9ce811a522" }, "spotifyVersion": "1.2.98.301", "status": "verified" @@ -141,7 +141,7 @@ }, "meta": { "file": "META.json", - "sha256": "11ef871fe8d029f94e138f442aede11190d068fa0b07febb776f28742d8f8a6d" + "sha256": "c80feef7120cb2fdf162be29221b41e47c75cb3056193a3d289beaed9956123b" }, "spotifyVersion": "1.2.99.317", "status": "verified" @@ -157,7 +157,7 @@ }, "meta": { "file": "META.json", - "sha256": "f3c6870c099a73b1ddeea9361ed829675c0dcceacdb262fd96c4f8fd3c2fba21" + "sha256": "8f36dbed5ee0d902786c2bba205b75a2b4a7311c87d120539e90e0cafab54b87" }, "spotifyVersion": "1.3.0.277", "status": "verified" @@ -173,7 +173,7 @@ }, "meta": { "file": "META.json", - "sha256": "ffd04eb6c95553b3c48cd3ebbc89fada6d98455215b167f813915a71963a6f3f" + "sha256": "1c1c8314557c066bbf360dc52b5f1be46e27e960de94bcfc999f4ba992a5d15f" }, "spotifyVersion": "1.3.1.234", "status": "verified" From 3e321e1a6d74e1422c5fa96f89e3fbbcd27bb0c5 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:23:41 +0200 Subject: [PATCH 06/20] feat(scripts): validate classmap keys in CI and promote in the schema 2 layout promote_inherited.py writes META.json and VERIFICATION.md and runs the validator on the new key before publishing it. --- .github/workflows/index.yml | 3 + scripts/build_index.py | 13 +- scripts/promote_inherited.py | 156 ++++++++------------ scripts/test_promote_inherited.py | 72 +++++++-- scripts/test_validate_classmaps.py | 105 +++++++++++++ scripts/validate_classmaps.py | 227 +++++++++++++++++++++++++++++ 6 files changed, 463 insertions(+), 113 deletions(-) create mode 100644 scripts/test_validate_classmaps.py create mode 100644 scripts/validate_classmaps.py diff --git a/.github/workflows/index.yml b/.github/workflows/index.yml index 1919704..d50e47e 100644 --- a/.github/workflows/index.yml +++ b/.github/workflows/index.yml @@ -16,6 +16,9 @@ jobs: - name: Verify index run: python3 scripts/build_index.py --check + - name: Validate classmap keys + run: python3 scripts/validate_classmaps.py + - name: Test classmap contracts and promotion run: python3 -m unittest discover -s scripts diff --git a/scripts/build_index.py b/scripts/build_index.py index b6bee3b..f360981 100644 --- a/scripts/build_index.py +++ b/scripts/build_index.py @@ -18,6 +18,8 @@ import sys from pathlib import Path +from validate_classmaps import render_json + ROOT = Path(__file__).resolve().parent.parent INDEX = ROOT / "index.json" KEY_DIR = re.compile(r"^\d+$") @@ -28,13 +30,8 @@ def sha256(path: Path) -> str: def classmap_file(key_dir: Path) -> Path | None: - """The classmap the CLI would pick: classmap.json, else the highest-sorting - classmap-*.json. Mirrors find_classmap_file in the Rust CLI.""" - direct = key_dir / "classmap.json" - if direct.is_file(): - return direct - hashed = sorted(key_dir.glob("classmap-*.json")) - return hashed[-1] if hashed else None + path = key_dir / "classmap.json" + return path if path.is_file() else None def entry_for(key_dir: Path) -> dict | None: @@ -84,7 +81,7 @@ def main() -> int: parser.add_argument("--check", action="store_true", help="fail if index.json is stale") args = parser.parse_args() - rendered = json.dumps(build(), indent=2, sort_keys=True) + "\n" + rendered = render_json(build()) if args.check: current = INDEX.read_text() if INDEX.is_file() else "" diff --git a/scripts/promote_inherited.py b/scripts/promote_inherited.py index 5b21b20..ec717ee 100644 --- a/scripts/promote_inherited.py +++ b/scripts/promote_inherited.py @@ -20,40 +20,13 @@ from datetime import date from pathlib import Path +from validate_classmaps import key_errors, leaf_values, render_json, version_to_key + ROOT = Path(__file__).resolve().parent.parent MIN_PROMOTION_HIT_RATE = 0.25 SHA256_RE = re.compile(r"[0-9a-f]{64}") -def version_to_key(version: str) -> str: - match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:\..*)?", version) - if not match: - raise ValueError(f"need major.minor.patch, got {version!r}") - major, minor, patch = (int(part) for part in match.groups()) - return f"{major}{minor:02d}{patch:04d}" - - -def classmap_file(key_dir: Path) -> Path: - direct = key_dir / "classmap.json" - if direct.is_file(): - return direct - candidates = sorted(key_dir.glob("classmap-*.json")) - if not candidates: - raise FileNotFoundError(f"no classmap in {key_dir}") - return candidates[-1] - - -def leaf_paths(node: object, parts: tuple[str, ...] = ()) -> set[str]: - if isinstance(node, dict): - paths: set[str] = set() - for key, value in node.items(): - paths |= leaf_paths(value, (*parts, key)) - return paths - if isinstance(node, str): - return {".".join(parts)} - raise ValueError(f"invalid classmap leaf at {'.'.join(parts)}") - - def report_rows( report: dict, name: str, @@ -75,17 +48,6 @@ def report_rows( return rows -def leaf_values(node: object, parts: tuple[str, ...] = ()) -> dict[str, str]: - if isinstance(node, dict): - values: dict[str, str] = {} - for key, value in node.items(): - values.update(leaf_values(value, (*parts, key))) - return values - if isinstance(node, str): - return {".".join(parts): node} - raise ValueError(f"invalid classmap leaf at {'.'.join(parts)}") - - def sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() @@ -183,36 +145,56 @@ def updated_required_paths( required[path] = "verified_cdp" elif path in unverified: required[path] = "unverified" - elif str(required[path]).startswith("verified"): - required[path] = "verified (inherited; present in target CSS)" + else: + required[path] = "verified_static" return required -def verification_notes( +def verification_markdown( source_key: str, + target_key: str, + spotify_version: str, paths: set[str], missing_in_target: set[str], live_hits: set[str], -) -> list[str]: + cdp_report: dict, + cdp_hit_rate: float, + overlay_entries: int, +) -> str: live_only = missing_in_target & live_hits - live_only_note = ( - "One CSS-only miss was observed live and remains verified." - if len(live_only) == 1 - else f"{len(live_only)} CSS-only misses were observed live and remain verified." - ) - return [ - f"Classmap inherited byte-for-byte from {source_key}; no migration guesses were accepted.", - ( - f"Static verification found {len(paths - missing_in_target)}/{len(paths)} paths " - "in the target CSS." - ), - live_only_note, + navigation = cdp_report["navigation"] + rows = { + "leaves": len(paths), + "static_present": len(paths - missing_in_target), + "verified_cdp": len(live_hits), + "live_only": len(live_only), + "unresolved_missing": len(missing_in_target - live_hits), + "cdp_hit_rate": cdp_hit_rate, + "overlay_entries": overlay_entries, + } + lines = [ + f"# Spotify {spotify_version} ({target_key})", + "", + f"Pipeline: inherit({source_key} -> {target_key}) + static target-CSS verification + CDP e2e (deep).", + "", + "## Notes", + "", + f"- Classmap inherited byte-for-byte from {source_key}; no migration guesses were accepted.", + f"- Static verification found {rows['static_present']}/{len(paths)} paths in the target CSS.", + f"- CSS-only misses observed live, which remain verified: {len(live_only)}.", ( - f"Deep CDP verification observed {len(live_hits)}/{len(paths)} paths on the " - "routes and transient surfaces exercised by the verifier." + f"- Deep CDP verification observed {len(live_hits)}/{len(paths)} paths with " + f"{navigation['succeeded']}/{navigation['attempted']} successful navigation steps." ), - "Unresolved new misses remain usable but are marked unverified; inherited stale paths stay blocked.", + "- Unresolved new misses remain usable but are marked unverified; inherited stale paths stay blocked.", + "", + "## Statistics at publication", + "", + "| Field | Value |", + "| --- | --- |", + *(f"| {field} | {value} |" for field, value in rows.items()), ] + return "\n".join(lines) + "\n" def build_meta( @@ -220,45 +202,23 @@ def build_meta( target_key: str, spotify_version: str, generated: str, - classmap: dict, source_meta: dict, - static_report: dict, - cdp_report: dict, + paths: set[str], missing_in_target: set[str], live_hits: set[str], - overlay_entries: int, - cdp_hit_rate: float, ) -> dict: - paths = leaf_paths(classmap) - live_only = missing_in_target & live_hits stale = sorted(set(source_meta.get("stale_leaves") or []) - live_hits) unverified = missing_in_target - live_hits - set(stale) return { - "spotify_version": spotify_version, + "schema_version": 2, "classmap_key": target_key, + "spotify_version": spotify_version, "status": "verified", "generated": generated, - "inherited_from": source_key, - "pipeline": ( - f"inherit({source_key} -> {target_key}) + static target-CSS verification " - "+ CDP e2e (deep)" - ), - "stats": { - "leaves": len(paths), - "inherited": len(paths), - "static_present": len(paths - missing_in_target), - "verified_cdp": len(live_hits), - "live_only": len(live_only), - "unresolved_missing": len(missing_in_target - live_hits), - "stale": len(stale), - "cdp_hit_rate": cdp_hit_rate, - "overlay_entries": overlay_entries, - }, + "source": {"method": "inherited", "key": source_key}, "required_paths": updated_required_paths(source_meta, unverified, live_hits), "stale_leaves": stale, "unverified_leaves": sorted(unverified), - "notes": verification_notes(source_key, paths, missing_in_target, live_hits), - "verification_summary": static_report.get("summary", {}), } @@ -278,7 +238,7 @@ def promote_inherited_release( raise FileExistsError(f"{target_key} already exists") source_dir = root / source_key - source_classmap = classmap_file(source_dir) + source_classmap = source_dir / "classmap.json" classmap = json.loads(source_classmap.read_text()) source_meta = json.loads((source_dir / "META.json").read_text()) classmap_digest = sha256(source_classmap) @@ -299,19 +259,27 @@ def promote_inherited_release( overlay = source_dir / "css-map.json" overlay_entries = len(json.loads(overlay.read_text())) if overlay.is_file() else 0 + paths = set(leaf_values(classmap)) meta = build_meta( source_key, target_key, spotify_version, generated, - classmap, source_meta, - static_report, - cdp_report, + paths, missing_in_target, live_hits, - overlay_entries, + ) + verification = verification_markdown( + source_key, + target_key, + spotify_version, + paths, + missing_in_target, + live_hits, + cdp_report, cdp_hit_rate, + overlay_entries, ) staging_dir = Path(tempfile.mkdtemp(prefix=f".{target_key}-", dir=root)) @@ -319,9 +287,11 @@ def promote_inherited_release( shutil.copy2(source_classmap, staging_dir / source_classmap.name) if overlay.is_file(): shutil.copy2(overlay, staging_dir / overlay.name) - (staging_dir / "META.json").write_text( - json.dumps(meta, indent=2, allow_nan=False) + "\n", encoding="utf-8", newline="\n" - ) + (staging_dir / "META.json").write_text(render_json(meta), encoding="utf-8", newline="\n") + (staging_dir / "VERIFICATION.md").write_text(verification, encoding="utf-8", newline="\n") + errors = key_errors(staging_dir, key=target_key, root=root) + if errors: + raise ValueError("promoted release fails validation: " + "; ".join(errors)) staging_dir.rename(target_dir) except Exception: shutil.rmtree(staging_dir, ignore_errors=True) diff --git a/scripts/test_promote_inherited.py b/scripts/test_promote_inherited.py index 74dd9a6..2e6ecc2 100644 --- a/scripts/test_promote_inherited.py +++ b/scripts/test_promote_inherited.py @@ -13,6 +13,7 @@ import promote_inherited as promote # noqa: E402 import build_index # noqa: E402 +from validate_classmaps import render_json # noqa: E402 class PromoteInheritedTests(unittest.TestCase): @@ -27,29 +28,33 @@ def setUp(self): "playbar": {"indicator": "indicatorHashCC"}, } } - (self.source / "classmap-source.json").write_text(json.dumps(self.classmap) + "\n") - (self.source / "css-map.json").write_text('{"customHash": "semantic-name"}\n') + (self.source / "classmap.json").write_text(render_json(self.classmap)) + (self.source / "css-map.json").write_text(render_json({"customHash": "semantic-name"})) (self.source / "META.json").write_text( - json.dumps( + render_json( { + "schema_version": 2, "spotify_version": "1.2.94.583", "classmap_key": "1020094", "status": "verified", + "generated": "2026-07-21", + "source": {"method": "derived", "key": None}, "stale_leaves": ["main.topbar.retired"], + "unverified_leaves": [], "required_paths": { "main.topbar.wrapper": "verified", "main.playbar.indicator": "verified", }, } ) - + "\n" ) + (self.source / "VERIFICATION.md").write_text("# Spotify 1.2.94.583 (1020094)\n") self.static_report = { "target": { "spotify_version": "1.2.96.518", "css_sha256": "a" * 64, "classmap_sha256": hashlib.sha256( - (self.source / "classmap-source.json").read_bytes() + (self.source / "classmap.json").read_bytes() ).hexdigest(), }, "summary": {"needs_manual_check": 1, "missing_in_css": 2}, @@ -109,7 +114,9 @@ def test_promotes_an_unchanged_map_and_records_new_stale_paths(self): ) self.assertEqual(target, self.root / "1020096") - self.assertEqual(json.loads((target / "classmap-source.json").read_text()), self.classmap) + self.assertEqual( + (target / "classmap.json").read_bytes(), (self.source / "classmap.json").read_bytes() + ) self.assertEqual( json.loads((target / "css-map.json").read_text()), {"customHash": "semantic-name"}, @@ -117,14 +124,15 @@ def test_promotes_an_unchanged_map_and_records_new_stale_paths(self): meta = json.loads((target / "META.json").read_text()) self.assertNotIn(b"\r", (target / "META.json").read_bytes()) self.assertEqual(meta["status"], "verified") - self.assertEqual(meta["inherited_from"], "1020094") + self.assertEqual(meta["source"], {"method": "inherited", "key": "1020094"}) self.assertEqual( meta["stale_leaves"], ["main.topbar.retired"], ) self.assertEqual(meta["unverified_leaves"], ["main.playbar.indicator"]) - self.assertEqual(meta["stats"]["static_present"], 1) - self.assertEqual(meta["stats"]["verified_cdp"], 1) + verification = (target / "VERIFICATION.md").read_text() + self.assertIn("| static_present | 1 |", verification) + self.assertIn("| verified_cdp | 1 |", verification) self.assertEqual(meta["required_paths"]["main.topbar.wrapper"], "verified_cdp") self.assertEqual(meta["required_paths"]["main.playbar.indicator"], "unverified") @@ -209,9 +217,9 @@ def test_live_hit_overrides_a_css_only_missing_result(self): self.assertEqual(meta["stale_leaves"], ["main.topbar.retired"]) self.assertEqual(meta["unverified_leaves"], []) self.assertEqual(meta["required_paths"]["main.playbar.indicator"], "verified_cdp") - self.assertEqual(meta["stats"]["live_only"], 1) - self.assertEqual(meta["stats"]["unresolved_missing"], 1) - self.assertTrue(any("One CSS-only miss was observed live" in note for note in meta["notes"])) + verification = (target / "VERIFICATION.md").read_text() + self.assertIn("| live_only | 1 |", verification) + self.assertIn("| unresolved_missing | 1 |", verification) def test_refuses_a_live_report_below_the_required_hit_rate(self): self.cdp_report["rows"][0]["hit"] = False @@ -400,6 +408,46 @@ def test_live_verification_recovers_an_inherited_stale_path(self): meta = json.loads((target / "META.json").read_text()) self.assertNotIn("main.topbar.retired", meta["stale_leaves"]) + def test_a_path_present_in_target_css_is_statically_verified(self): + source_meta = json.loads((self.source / "META.json").read_text()) + source_meta["required_paths"]["main.topbar.wrapper"] = "unverified" + source_meta["unverified_leaves"] = ["main.topbar.wrapper"] + (self.source / "META.json").write_text(render_json(source_meta)) + self.cdp_report["rows"][0]["hit"] = False + self.cdp_report["rows"][2]["hit"] = True + + target = promote.promote_inherited_release( + root=self.root, + source_key="1020094", + spotify_version="1.2.96.518", + static_report=self.static_report, + cdp_report=self.cdp_report, + generated="2026-08-12", + min_hit_rate=0.25, + ) + + meta = json.loads((target / "META.json").read_text()) + self.assertEqual(meta["required_paths"]["main.topbar.wrapper"], "verified_static") + self.assertEqual(meta["unverified_leaves"], []) + + def test_refuses_a_release_that_keeps_a_required_path_stale(self): + source_meta = json.loads((self.source / "META.json").read_text()) + source_meta["required_paths"]["main.topbar.retired"] = "verified" + (self.source / "META.json").write_text(render_json(source_meta)) + + with self.assertRaisesRegex(ValueError, "required path main.topbar.retired is stale"): + promote.promote_inherited_release( + root=self.root, + source_key="1020094", + spotify_version="1.2.96.518", + static_report=self.static_report, + cdp_report=self.cdp_report, + generated="2026-08-12", + min_hit_rate=0.25, + ) + self.assertFalse((self.root / "1020096").exists()) + self.assertEqual(list(self.root.glob(".1020096-*")), []) + def test_failed_copy_leaves_no_partial_target(self): with mock.patch.object(promote.shutil, "copy2", side_effect=OSError("disk full")): with self.assertRaisesRegex(OSError, "disk full"): diff --git a/scripts/test_validate_classmaps.py b/scripts/test_validate_classmaps.py new file mode 100644 index 0000000..fc7b370 --- /dev/null +++ b/scripts/test_validate_classmaps.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from validate_classmaps import key_errors, render_json + + +class KeyValidationTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + self.classmap = {"main": {"topbar": {"wrapper": "topbarHashAA", "retired": "retiredHashBB"}}} + self.parent = self.write_key( + "1020094", self.classmap, self.meta("1020094", "1.2.94.583", {"method": "derived", "key": None}) + ) + self.child_meta = self.meta("1020096", "1.2.96.518", {"method": "inherited", "key": "1020094"}) + self.child = self.write_key("1020096", self.classmap, self.child_meta) + + def tearDown(self): + self.temp_dir.cleanup() + + def meta(self, key: str, version: str, source: dict) -> dict: + return { + "schema_version": 2, + "classmap_key": key, + "spotify_version": version, + "status": "verified", + "generated": "2026-08-12", + "source": source, + "required_paths": {"main.topbar.wrapper": "verified_cdp"}, + "stale_leaves": ["main.topbar.retired"], + "unverified_leaves": [], + } + + def write_key(self, key: str, classmap: dict, meta: dict) -> Path: + key_dir = self.root / key + key_dir.mkdir(exist_ok=True) + (key_dir / "classmap.json").write_text(render_json(classmap)) + (key_dir / "META.json").write_text(render_json(meta)) + (key_dir / "VERIFICATION.md").write_text(f"# {key}\n") + return key_dir + + def errors(self, key_dir: Path) -> list[str]: + return key_errors(key_dir, root=self.root) + + def test_accepts_a_consistent_inherited_release(self): + self.assertEqual(self.errors(self.parent), []) + self.assertEqual(self.errors(self.child), []) + + def test_rejects_an_inherited_map_whose_bytes_differ_from_its_source(self): + changed = {"main": {"topbar": {"wrapper": "semantic-name", "retired": "retiredHashBB"}}} + self.write_key("1020096", changed, self.child_meta) + self.assertIn( + "classmap.json: inherited from 1020094 but its bytes differ", self.errors(self.child) + ) + + def test_rejects_a_leaf_listed_as_both_stale_and_unverified(self): + self.child_meta["unverified_leaves"] = ["main.topbar.retired"] + self.write_key("1020096", self.classmap, self.child_meta) + self.assertIn("META.json: main.topbar.retired is both stale and unverified", self.errors(self.child)) + + def test_rejects_a_required_status_that_disagrees_with_the_leaf_lists(self): + self.child_meta["required_paths"]["main.topbar.wrapper"] = "unverified" + self.write_key("1020096", self.classmap, self.child_meta) + self.assertIn( + "META.json: main.topbar.wrapper is unverified but unverified_leaves disagrees", + self.errors(self.child), + ) + + def test_rejects_free_text_path_statuses(self): + self.child_meta["required_paths"]["main.topbar.wrapper"] = "verified (present in target CSS)" + self.write_key("1020096", self.classmap, self.child_meta) + self.assertTrue(any("unknown status" in e for e in self.errors(self.child))) + + def test_rejects_empty_groups_and_padded_class_strings(self): + self.write_key( + "1020096", {"main": {"panel": {}, "topbar": {"wrapper": " a b"}}}, self.child_meta + ) + errors = self.errors(self.child) + self.assertIn("classmap.json: main.panel: empty group", errors) + self.assertIn( + "classmap.json: main.topbar.wrapper: classes must be single-space separated with no padding", + errors, + ) + + def test_rejects_non_canonical_json_and_stray_classmap_files(self): + (self.child / "classmap.json").write_text(json.dumps(self.classmap, indent="\t")) + (self.child / "classmap-19f856aefd5.json").write_text("{}\n") + errors = self.errors(self.child) + self.assertIn( + "classmap.json: not canonical (2-space indent, sorted keys, trailing newline)", errors + ) + self.assertIn("classmap-19f856aefd5.json: not part of the published layout", errors) + + def test_rejects_metadata_that_names_another_build(self): + self.child_meta["spotify_version"] = "1.2.97.270" + self.write_key("1020096", self.classmap, self.child_meta) + self.assertIn("META.json: spotify_version does not belong to 1020096", self.errors(self.child)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate_classmaps.py b/scripts/validate_classmaps.py new file mode 100644 index 0000000..fcc519f --- /dev/null +++ b/scripts/validate_classmaps.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Checks every key directory against the published layout: + + /classmap.json nested groups of class-name leaves (required) + /css-map.json flat hash -> semantic overlay (optional) + /META.json schema_version 2 metadata (optional for legacy keys) + /VERIFICATION.md verification history (required beside META.json) + +JSON files must be in canonical form (`render_json`). META claims are checked +against the files: leaf paths exist, the leaf lists are disjoint and agree with +`required_paths`, and an inherited map is byte-identical to its source. + + python3 scripts/validate_classmaps.py +""" + +from __future__ import annotations + +import json +import re +import sys +from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +KEY_DIR = re.compile(r"^\d{7}$") +META_FIELDS = { + "schema_version", + "classmap_key", + "spotify_version", + "status", + "generated", + "source", + "required_paths", + "stale_leaves", + "unverified_leaves", +} +STATUSES = {"verified", "unverified"} +SOURCE_METHODS = {"derived", "inherited"} +PATH_STATUSES = { + "verified_cdp", + "verified_targeted", + "verified_static", + "verified", + "unverified", +} +KNOWN_FILES = {"classmap.json", "css-map.json", "META.json", "VERIFICATION.md"} + + +def render_json(value: object) -> str: + return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False, allow_nan=False) + "\n" + + +def version_to_key(version: str) -> str: + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:\..*)?", version) + if not match: + raise ValueError(f"need major.minor.patch, got {version!r}") + major, minor, patch = (int(part) for part in match.groups()) + return f"{major}{minor:02d}{patch:04d}" + + +def leaf_values(node: object, parts: tuple[str, ...] = ()) -> dict[str, str]: + if isinstance(node, dict): + values: dict[str, str] = {} + for key, value in node.items(): + values.update(leaf_values(value, (*parts, key))) + return values + if isinstance(node, str): + return {".".join(parts): node} + raise ValueError(f"invalid classmap leaf at {'.'.join(parts)}") + + +def classmap_errors(node: object, parts: tuple[str, ...] = ()) -> list[str]: + where = ".".join(parts) or "" + if isinstance(node, str): + if not node or node != " ".join(node.split()): + return [f"{where}: classes must be single-space separated with no padding"] + return [] + if not isinstance(node, dict): + return [f"{where}: must be a group or a class string"] + if not node: + return [f"{where}: empty group"] + errors: list[str] = [] + for key, value in node.items(): + errors += classmap_errors(value, (*parts, key)) + return errors + + +def load_canonical(path: Path, errors: list[str]) -> object | None: + raw = path.read_bytes().decode("utf-8") + try: + value = json.loads(raw) + except json.JSONDecodeError as e: + errors.append(f"{path.name}: {e}") + return None + if raw != render_json(value): + errors.append(f"{path.name}: not canonical (2-space indent, sorted keys, trailing newline)") + return value + + +def string_list(meta: dict, field: str, leaves: dict[str, str], errors: list[str]) -> set[str]: + items = meta.get(field) + if not isinstance(items, list) or not all(isinstance(i, str) for i in items): + errors.append(f"META.json: {field} must be a list of paths") + return set() + if items != sorted(set(items)): + errors.append(f"META.json: {field} must be sorted and unique") + for path in items: + if path not in leaves: + errors.append(f"META.json: {field} names {path}, which is not a classmap leaf") + return set(items) + + +def meta_errors(meta: object, key: str, leaves: dict[str, str], root: Path, classmap: bytes) -> list[str]: + if not isinstance(meta, dict): + return ["META.json: must be an object"] + errors: list[str] = [] + fields = set(meta) + if fields != META_FIELDS: + errors.append( + f"META.json: fields must be exactly {sorted(META_FIELDS)}; " + f"missing={sorted(META_FIELDS - fields)}, extra={sorted(fields - META_FIELDS)}" + ) + if meta.get("schema_version") != 2: + errors.append("META.json: schema_version must be 2") + if meta.get("classmap_key") != key: + errors.append(f"META.json: classmap_key must be {key}") + try: + if version_to_key(str(meta.get("spotify_version", ""))) != key: + errors.append(f"META.json: spotify_version does not belong to {key}") + except ValueError as e: + errors.append(f"META.json: spotify_version: {e}") + if meta.get("status") not in STATUSES: + errors.append(f"META.json: status must be one of {sorted(STATUSES)}") + try: + date.fromisoformat(str(meta.get("generated"))) + except ValueError: + errors.append("META.json: generated must be an ISO date") + + source = meta.get("source") + if not isinstance(source, dict) or set(source) != {"method", "key"}: + errors.append("META.json: source must be {method, key}") + elif source["method"] not in SOURCE_METHODS: + errors.append(f"META.json: source.method must be one of {sorted(SOURCE_METHODS)}") + elif source["key"] is not None and not ( + isinstance(source["key"], str) and KEY_DIR.match(source["key"]) and source["key"] < key + ): + errors.append("META.json: source.key must be null or an older seven-digit key") + elif source["method"] == "inherited": + parent = root / str(source["key"]) / "classmap.json" + if source["key"] is None or not parent.is_file(): + errors.append("META.json: an inherited map needs an existing source.key") + elif parent.read_bytes() != classmap: + errors.append(f"classmap.json: inherited from {source['key']} but its bytes differ") + + stale = string_list(meta, "stale_leaves", leaves, errors) + unverified = string_list(meta, "unverified_leaves", leaves, errors) + for path in sorted(stale & unverified): + errors.append(f"META.json: {path} is both stale and unverified") + + required = meta.get("required_paths") + if not isinstance(required, dict): + errors.append("META.json: required_paths must be an object") + return errors + for path, status in required.items(): + if path not in leaves: + errors.append(f"META.json: required path {path} is not a classmap leaf") + if status not in PATH_STATUSES: + errors.append(f"META.json: required path {path} has unknown status {status!r}") + if path in stale: + errors.append(f"META.json: required path {path} is stale") + if (status == "unverified") != (path in unverified): + errors.append(f"META.json: {path} is {status} but unverified_leaves disagrees") + return errors + + +def key_errors(key_dir: Path, key: str | None = None, root: Path = ROOT) -> list[str]: + key = key or key_dir.name + errors: list[str] = [] + for extra in sorted(p.name for p in key_dir.iterdir() if p.name not in KNOWN_FILES): + errors.append(f"{extra}: not part of the published layout") + + classmap_path = key_dir / "classmap.json" + if not classmap_path.is_file(): + return errors + ["classmap.json: missing"] + classmap = load_canonical(classmap_path, errors) + if classmap is None: + return errors + tree_errors = classmap_errors(classmap) + errors += [f"classmap.json: {e}" for e in tree_errors] + if tree_errors: + return errors + leaves = leaf_values(classmap) + + overlay_path = key_dir / "css-map.json" + if overlay_path.is_file(): + overlay = load_canonical(overlay_path, errors) + if overlay is not None and not ( + isinstance(overlay, dict) + and all(isinstance(k, str) and isinstance(v, str) and k and v for k, v in overlay.items()) + ): + errors.append("css-map.json: must map class names to semantic names") + + meta_path = key_dir / "META.json" + if meta_path.is_file(): + meta = load_canonical(meta_path, errors) + if meta is not None: + errors += meta_errors(meta, key, leaves, root, classmap_path.read_bytes()) + if not (key_dir / "VERIFICATION.md").is_file(): + errors.append("VERIFICATION.md: required beside META.json") + return errors + + +def main() -> int: + failed = False + keys = sorted(p for p in ROOT.iterdir() if p.is_dir() and KEY_DIR.match(p.name)) + for key_dir in keys: + for error in key_errors(key_dir): + print(f"{key_dir.name}/{error}", file=sys.stderr) + failed = True + if failed: + return 1 + print(f"{len(keys)} classmap keys ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4b8bc52668f526bd11f6039630268410a76d1010 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:12:02 +0200 Subject: [PATCH 07/20] refactor(expose): record measured hits as data Per-build match counts move from prose notes into a hits object. Shipped CLIs ignore unknown patch fields. --- expose.json | 82 +++++++++++++++++++++++++++----------- index.json | 2 +- scripts/validate_expose.py | 14 ++++++- 3 files changed, 73 insertions(+), 25 deletions(-) diff --git a/expose.json b/expose.json index e10398f..d87a2e7 100644 --- a/expose.json +++ b/expose.json @@ -1,94 +1,130 @@ { - "$comment": "Apply-time source patches that expose Spotify internals on the Spicetify global, applied to the xpui-modules.js extracted from the v8 snapshot. Fetched by the CLI through index.json; the CLI embeds a copy as its offline baseline. `replace` is a capture-group template (${0} whole match, ${N} group N; `$$` for a literal dollar). `once` stops after the first match. `onMiss` is `warn` (a miss means a Spotify update moved the code) or `quiet` (a miss is expected on some supported builds). `note` records the measured hit count per build.", "version": 1, "patches": [ { "name": "Expose PlatformAPI", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "((?:setTitlebarHeight|registerFactory)[\\w(){}<>:.,&$!=;\"\"?!#%/\\- ]+)(\\{version:[a-zA-Z_\\$][\\w\\$]*,)", "replace": "${1}Spicetify._platform=${2}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } }, { "name": "Redux store", - "note": "1.2.84: 0, 1.2.96: 1. The store is created inside an IIFE invoked with the session/features/seoExperiment object; this anchors on the only `return (0,createStore)((0,combineReducers)({` in that IIFE and routes the created store through the global. 1.2.84 builds the store another way and has no seoExperiment key at all, so ReduxStore is not exposed there yet.", "pattern": "return(\\(0,[\\w$]+\\.[\\w$]+\\)\\(\\(0,[\\w$]+\\.[\\w$]+\\)\\(\\{(?s:.{0,1500}?)\\)\\}\\(\\{session:[\\w$]+,features:[\\w$]+,seoExperiment:[\\w$]+\\},\\{platform:)", "replace": "return Spicetify.Platform.ReduxStore=${1}", "once": true, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 0, + "1.2.96": 1 + }, + "note": "The store is created inside an IIFE invoked with the session/features/seoExperiment object; this anchors on the only `return (0,createStore)((0,combineReducers)({` in that IIFE and routes the created store through the global. 1.2.84 builds the store another way and has no seoExperiment key at all, so ReduxStore is not exposed there yet." }, { "name": "React Component: Platform Provider", - "note": "1.2.84: 0, 1.2.96: 2.", "pattern": "(,[$\\w]+=)((function\\([\\w$]{1}\\)\\{var [\\w$]+=[\\w$]+\\.platform,[\\w$]+=[\\w$]+\\.children,)|(\\(\\{platform:[\\w$]+,children:[\\w$]+\\}\\)=>\\{))", "replace": "${1}Spicetify.ReactComponent.PlatformProvider=${2}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 0, + "1.2.96": 2 + } }, { "name": "Spotify Custom Snackbar Interface", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "\\b\\w\\s*\\(\\)\\s*[^;,]*enqueueCustomSnackbar:\\s*(\\w)\\s*[^;]*;", "replace": "${0}Spicetify.Snackbar.enqueueCustomSnackbar=${1};", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } }, { "name": "Spotify Image Snackbar Interface", - "note": "1.2.84: 1, 1.2.96: 1. The enqueue arrow is returned through useCallback rather than assigned.", "pattern": "return(\\(0,[\\w$]+\\.useCallback\\)\\(\\(\\{message:[\\w$]+,\\s*imageSrc)", "replace": "return Spicetify.Snackbar.enqueueImageSnackbar=${1}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + }, + "note": "The enqueue arrow is returned through useCallback rather than assigned." }, { "name": "React Component: Navigation for navLinks", - "note": "1.2.84: 1, 1.2.96: 0. The wrapper finds Navigation through webpack at runtime (react-components.js), so a miss is covered.", "pattern": "(;const [\\w\\d]+=)((?:\\(0,[\\w\\d]+\\.memo\\))[\\(\\d,\\w\\.\\){:}=]+=[\\d\\w]+\\.[\\d\\w]+\\.getLocaleForURLPath\\(\\))", "replace": "${1}Spicetify.ReactComponent.Navigation=${2}", "once": true, - "onMiss": "quiet" + "onMiss": "quiet", + "hits": { + "1.2.84": 1, + "1.2.96": 0 + }, + "note": "The wrapper finds Navigation through webpack at runtime (react-components.js), so a miss is covered." }, { "name": "Context Menu V2", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "(\"Menu\".+?children:)([\\w$][\\w$\\d]*)", "replace": "${1}[Spicetify.ContextMenuV2.renderItems(),${2}].flat()", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } }, { "name": "Map styled-components classes", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "(\\w+ [\\w$_]+)=[\\w$_]+\\([\\w$_]+>>>0\\)", "replace": "${1}=Spicetify._getStyledClassName(arguments,this)", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } }, { "name": "Tippy.js", - "note": "1.2.84: 2, 1.2.96: 2.", "pattern": "([\\w\\$_]+)\\.setDefaultProps=", "replace": "Spicetify.Tippy=${1};${0}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 2, + "1.2.96": 2 + } }, { "name": "Flipper components", - "note": "1.2.84: 1, 1.2.96: 0. react-flip-toolkit left the client in 1.2.9x; nothing remains to expose there.", "pattern": "([\\w$]+)=((?:function|\\()([\\w$.,{}()= ]+(?:springConfig|overshootClamping)){2})", "replace": "${1}=Spicetify.ReactFlipToolkit.spring=${2}", "once": false, - "onMiss": "quiet" + "onMiss": "quiet", + "hits": { + "1.2.84": 1, + "1.2.96": 0 + }, + "note": "react-flip-toolkit left the client in 1.2.9x; nothing remains to expose there." }, { "name": "Snackbar", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "\\w+\\s*=\\s*\\w\\.call\\(this,[^)]+\\)\\s*\\|\\|\\s*this\\)\\.enqueueSnackbar", "replace": "Spicetify.Snackbar=${0}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } } ] } diff --git a/index.json b/index.json index 0a28181..e32620a 100644 --- a/index.json +++ b/index.json @@ -1,7 +1,7 @@ { "expose": { "file": "expose.json", - "sha256": "3c609aca27255ad637cae51c1355482ef02fe6c1e61156d3e82039e1fe6a372a" + "sha256": "9d6ceb358e7e7d7e4099e0e8bce13bd9d9825e69741411b0d235eada8a48c655" }, "keys": { "1020038": { diff --git a/scripts/validate_expose.py b/scripts/validate_expose.py index 441cc86..e6c6cc6 100755 --- a/scripts/validate_expose.py +++ b/scripts/validate_expose.py @@ -19,16 +19,20 @@ ROOT = Path(__file__).resolve().parent.parent EXPOSE = ROOT / "expose.json" TEMPLATE_REF = re.compile(r"\$\{(\d+)\}|\$(\d+)") +BUILD = re.compile(r"\d+\.\d+\.\d+") def main() -> int: try: - doc = json.loads(EXPOSE.read_text()) + raw = EXPOSE.read_text(encoding="utf-8") + doc = json.loads(raw) except (OSError, json.JSONDecodeError) as e: print(f"expose.json: {e}", file=sys.stderr) return 1 errors: list[str] = [] + if raw != json.dumps(doc, indent=2, ensure_ascii=False) + "\n": + errors.append("expose.json: not canonical (2-space indent, trailing newline)") patches = doc.get("patches") if not isinstance(patches, list) or not patches: errors.append("`patches` must be a non-empty list") @@ -69,6 +73,14 @@ def main() -> int: errors.append(f"{name}: `once` must be a boolean") if patch.get("onMiss", "warn") not in ("warn", "quiet"): errors.append(f"{name}: `onMiss` must be `warn` or `quiet`") + hits = patch.get("hits") + if not isinstance(hits, dict) or not hits or not all( + BUILD.fullmatch(build) and isinstance(n, int) and not isinstance(n, bool) and n >= 0 + for build, n in hits.items() + ): + errors.append(f"{name}: `hits` must map major.minor.patch builds to match counts") + if "note" in patch and (not isinstance(patch["note"], str) or not patch["note"]): + errors.append(f"{name}: `note` must be a non-empty string when present") for e in errors: print(e, file=sys.stderr) From 1effa3b5ec4b6e05e1ecbe9635c5b10abc0f6a4a Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:12:37 +0200 Subject: [PATCH 08/20] docs: describe the key layout, META schema and patch fields --- README.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ed067ff..042ae70 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,66 @@ must select an exact, auditable ABI for each client build. Patch releases that keep the same classes do not need a speculative migration: verify the previous map against the new client, then promote it as an inherited release. +## Key layout + +Each key directory holds the files below. `index.json` lists them with their +SHA-256 digests, and the CLI downloads only what the index names. + +| File | Required | Contents | +| --- | --- | --- | +| `classmap.json` | Yes | Nested groups whose leaves are Spotify's stock class strings. | +| `css-map.json` | No | Flat overlay from stock class to Spicetify name for this build. | +| `META.json` | For verified keys | The fields the CLI and tooling read, described below. | +| `VERIFICATION.md` | With `META.json` | Pipeline, notes, statistics, and verification runs. | + +Leaves store the class exactly as it appears in the stock client, never the +Spicetify name the css-map rewrites it to. The CLI rewrites staged modules in +the same css-map pass as the client, so both forms reach the same DOM class, +but only the stock form can be checked against a stock archive. Keys +1020038, 1020040, and 1020045 predate the pipeline and have no `META.json`. + +Every JSON file uses two-space indentation and ends with a newline. +`classmap.json`, `css-map.json`, `META.json`, and `index.json` also sort their +keys. `python3 scripts/validate_classmaps.py` enforces the layout, and CI runs +it on every pull request. + +## META.json + +`META.json` contains exactly these fields. Put anything else, such as notes, +counts, or run details, in `VERIFICATION.md`. + +| Field | Meaning | +| --- | --- | +| `schema_version` | Always `2`. | +| `classmap_key` | The directory name. | +| `spotify_version` | The full Spotify build the key was verified on. | +| `status` | `verified` or `unverified`. The CLI only trusts `verified`. | +| `generated` | ISO date of first publication. | +| `source` | `{"method": "inherited" \| "derived", "key": "" \| null}`. | +| `required_paths` | Status of each path the ecosystem depends on. | +| `stale_leaves` | Paths known to be wrong. The CLI refuses to resolve them. | +| `unverified_leaves` | Paths not observed on this build. They still resolve. | + +An `inherited` key's `classmap.json` must be byte-identical to its source +key's map. If you change a map, you must also change every key that inherits +from it, or record that key as `derived`. + +Each `required_paths` value is one of the following statuses: + +- `verified_cdp`: observed in the live DOM by the deep CDP verifier. +- `verified_targeted`: observed by a targeted live DOM or CSSOM probe. +- `verified_static`: present in the stock target CSS but not observed live. +- `verified`: verified before the method was recorded (1020092 and 1020094). +- `unverified`: not observed. The path must also be in `unverified_leaves`. + +A required path can't be stale. + +Digests recorded in `VERIFICATION.md` identify the exact file that was tested. +On September 30, 2026, every map was renamed to `classmap.json` and +reformatted, and the 1020099 and 1030000 leaves stored as Spicetify names were +rewritten to their stock classes. Those older digests therefore no longer +match the published bytes, but the classes the CLI stages are unchanged. + ## Unchanged patch release From the CLI repository, generate reports bound to the exact Spotify version, @@ -12,7 +72,7 @@ classmap digest, target CSS digest, and a deep live CDP run: ```sh python3 scripts/classmap_capture.py verify \ - --classmap ../classmaps/1020094/classmap-19f856aefd5.json \ + --classmap ../classmaps/1020094/classmap.json \ --css-map css-map.json \ --target-spa "/path/to/stock/xpui.spa" \ --target-version 1.2.96.518 \ @@ -20,7 +80,7 @@ python3 scripts/classmap_capture.py verify \ node scripts/classmap_cdp_verify.mjs \ --port 9229 --mode both --deep \ - --classmap ../classmaps/1020094/classmap-19f856aefd5.json \ + --classmap ../classmaps/1020094/classmap.json \ --css-map css-map.json \ --out /tmp/1020096-cdp.json ``` @@ -37,8 +97,9 @@ python3 scripts/promote_inherited.py \ ``` The promoter refuses mismatched versions, maps, leaf values, shallow CDP runs, -inconsistent summaries, low hit rates, and existing targets. It copies through -a temporary directory and rolls back both the release and `index.json` if +inconsistent summaries, low hit rates, and existing targets. It writes +`META.json` and `VERIFICATION.md`, validates the new key, copies through a +temporary directory, and rolls back both the release and `index.json` if publication preparation fails. Newly absent paths are marked `unverified`; only paths already known to be stale remain blocked by the CLI. @@ -50,8 +111,8 @@ publishing it. ## Exposure patches `expose.json` is the set of regex rewrites the CLI applies to the extracted -`xpui-modules.js` so `Spicetify.Platform`, `Spicetify.Snackbar` and friends -exist (`module/expose.rs` in the CLI repository documents the fields). It is +`xpui-modules.js` so `Spicetify.Platform`, `Spicetify.Snackbar`, and the +other globals exist. It is one file for every build: the CLI fetches it through `index.json` beside the classmaps, verifies its digest, and falls back to the copy embedded in the binary when it cannot. A Spotify update that reshapes the minified code is @@ -68,7 +129,19 @@ SPICETIFY_EXPOSE_BUNDLES=/path/1.2.84.js:/path/1.2.96.js \ cargo test -p spicetify expose_hits_on_real_bundles -- --ignored --nocapture ``` -Record the hit counts in the patch's `note`, then: +Each patch has these fields: + +| Field | Meaning | +| --- | --- | +| `name` | Unique label that `apply` reports. | +| `pattern` | Regex matched against `xpui-modules.js`. | +| `replace` | Capture-group template: `${0}` is the whole match, `${N}` is group N, and `$$` is a literal dollar. | +| `once` | Stop after the first match. | +| `onMiss` | `warn` or `quiet`, described below. | +| `hits` | Measured match count per `major.minor.patch` build. | +| `note` | Optional explanation that doesn't fit the other fields. | + +Record the hit counts in the patch's `hits`, then: ```sh python3 scripts/validate_expose.py From 46555b764172f3e6b8419ae731824eb2f7f94208 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:18:24 +0200 Subject: [PATCH 09/20] fix(scripts): index the same key directories the validator checks --- scripts/build_index.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/build_index.py b/scripts/build_index.py index f360981..8f6ff6c 100644 --- a/scripts/build_index.py +++ b/scripts/build_index.py @@ -14,15 +14,13 @@ import argparse import hashlib import json -import re import sys from pathlib import Path -from validate_classmaps import render_json +from validate_classmaps import KEY_DIR, render_json ROOT = Path(__file__).resolve().parent.parent INDEX = ROOT / "index.json" -KEY_DIR = re.compile(r"^\d+$") def sha256(path: Path) -> str: From 314bbcfb318e9e73ec40a20263cf85b5621ca90c Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:22:23 +0200 Subject: [PATCH 10/20] feat(scripts): reject Spicetify names, cross-minor inheritance and ambiguous META values --- scripts/test_validate_classmaps.py | 40 ++++++++++++++++++++++++++++++ scripts/validate_classmaps.py | 21 ++++++++++++++-- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/scripts/test_validate_classmaps.py b/scripts/test_validate_classmaps.py index fc7b370..5bd1a1d 100644 --- a/scripts/test_validate_classmaps.py +++ b/scripts/test_validate_classmaps.py @@ -100,6 +100,46 @@ def test_rejects_metadata_that_names_another_build(self): self.write_key("1020096", self.classmap, self.child_meta) self.assertIn("META.json: spotify_version does not belong to 1020096", self.errors(self.child)) + def test_rejects_a_leaf_stored_as_its_spicetify_name(self): + (self.child / "css-map.json").write_text(render_json({"topbarHashAA": "Root__globalNav"})) + named = {"main": {"topbar": {"wrapper": "Root__globalNav", "retired": "main-topBar-retired"}}} + self.write_key("1020094", named, self.parent_meta()) + self.write_key("1020096", named, self.child_meta) + errors = self.errors(self.child) + self.assertIn( + "classmap.json: main.topbar.wrapper stores the Spicetify name Root__globalNav, not the stock class", + errors, + ) + self.assertIn( + "classmap.json: main.topbar.retired stores the Spicetify name main-topBar-retired, not the stock class", + errors, + ) + + def test_rejects_inheritance_across_a_minor_version(self): + meta = self.meta("1030004", "1.3.4.100", {"method": "inherited", "key": "1020094"}) + key_dir = self.write_key("1030004", self.classmap, meta) + self.assertIn( + "META.json: an inherited map must come from the same major.minor family", + self.errors(key_dir), + ) + + def test_rejects_a_compact_or_week_date(self): + for generated in ("20260812", "2026-W33-3"): + with self.subTest(generated=generated): + self.child_meta["generated"] = generated + self.write_key("1020096", self.classmap, self.child_meta) + self.assertIn("META.json: generated must be a YYYY-MM-DD date", self.errors(self.child)) + + def test_rejects_group_keys_that_would_collide_as_dotted_paths(self): + self.write_key("1020096", {"main": {"topbar.wrapper": "a", "topbar": {"wrapper": "b"}}}, self.child_meta) + self.assertIn( + "classmap.json: main: group key 'topbar.wrapper' must be non-empty and contain no dots", + self.errors(self.child), + ) + + def parent_meta(self) -> dict: + return self.meta("1020094", "1.2.94.583", {"method": "derived", "key": None}) + if __name__ == "__main__": unittest.main() diff --git a/scripts/validate_classmaps.py b/scripts/validate_classmaps.py index fcc519f..2c60311 100644 --- a/scripts/validate_classmaps.py +++ b/scripts/validate_classmaps.py @@ -43,6 +43,8 @@ "verified", "unverified", } +ISO_DATE = re.compile(r"\d{4}-\d{2}-\d{2}") +SPICETIFY_NAME = re.compile(r"(main|x)-[A-Za-z0-9]+-") KNOWN_FILES = {"classmap.json", "css-map.json", "META.json", "VERIFICATION.md"} @@ -81,6 +83,9 @@ def classmap_errors(node: object, parts: tuple[str, ...] = ()) -> list[str]: return [f"{where}: empty group"] errors: list[str] = [] for key, value in node.items(): + if not key or "." in key: + errors.append(f"{where}: group key {key!r} must be non-empty and contain no dots") + continue errors += classmap_errors(value, (*parts, key)) return errors @@ -131,10 +136,13 @@ def meta_errors(meta: object, key: str, leaves: dict[str, str], root: Path, clas errors.append(f"META.json: spotify_version: {e}") if meta.get("status") not in STATUSES: errors.append(f"META.json: status must be one of {sorted(STATUSES)}") + generated = str(meta.get("generated")) try: - date.fromisoformat(str(meta.get("generated"))) + if not ISO_DATE.fullmatch(generated): + raise ValueError(generated) + date.fromisoformat(generated) except ValueError: - errors.append("META.json: generated must be an ISO date") + errors.append("META.json: generated must be a YYYY-MM-DD date") source = meta.get("source") if not isinstance(source, dict) or set(source) != {"method", "key"}: @@ -149,6 +157,8 @@ def meta_errors(meta: object, key: str, leaves: dict[str, str], root: Path, clas parent = root / str(source["key"]) / "classmap.json" if source["key"] is None or not parent.is_file(): errors.append("META.json: an inherited map needs an existing source.key") + elif source["key"][:3] != key[:3]: + errors.append("META.json: an inherited map must come from the same major.minor family") elif parent.read_bytes() != classmap: errors.append(f"classmap.json: inherited from {source['key']} but its bytes differ") @@ -191,6 +201,7 @@ def key_errors(key_dir: Path, key: str | None = None, root: Path = ROOT) -> list return errors leaves = leaf_values(classmap) + semantic: set[str] = set() overlay_path = key_dir / "css-map.json" if overlay_path.is_file(): overlay = load_canonical(overlay_path, errors) @@ -199,6 +210,12 @@ def key_errors(key_dir: Path, key: str | None = None, root: Path = ROOT) -> list and all(isinstance(k, str) and isinstance(v, str) and k and v for k, v in overlay.items()) ): errors.append("css-map.json: must map class names to semantic names") + elif isinstance(overlay, dict): + semantic = set(overlay.values()) + for path, value in leaves.items(): + for token in value.split(): + if token in semantic or SPICETIFY_NAME.match(token): + errors.append(f"classmap.json: {path} stores the Spicetify name {token}, not the stock class") meta_path = key_dir / "META.json" if meta_path.is_file(): From f869d959889b1a86bc0a4e0f1871291dbd4f865f Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:22:46 +0200 Subject: [PATCH 11/20] fix(scripts): keep source-unverified paths unverified until observed live --- README.md | 13 +++++++------ scripts/promote_inherited.py | 3 ++- scripts/test_promote_inherited.py | 26 +++++++++++++++++++++++--- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 042ae70..dec1cc9 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,10 @@ The promoter refuses mismatched versions, maps, leaf values, shallow CDP runs, inconsistent summaries, low hit rates, and existing targets. It writes `META.json` and `VERIFICATION.md`, validates the new key, copies through a temporary directory, and rolls back both the release and `index.json` if -publication preparation fails. Newly absent paths are marked `unverified`; -only paths already known to be stale remain blocked by the CLI. +publication preparation fails. Newly absent paths are marked `unverified`, +and paths the source already marked `unverified` stay that way until the deep +CDP run observes them. Only paths already known to be stale remain blocked by +the CLI. If migration proposes changed hashes, do not use inheritance. Run the full capture pipeline from the CLI repository and verify each changed leaf before @@ -112,10 +114,9 @@ publishing it. `expose.json` is the set of regex rewrites the CLI applies to the extracted `xpui-modules.js` so `Spicetify.Platform`, `Spicetify.Snackbar`, and the -other globals exist. It is -one file for every build: the CLI fetches it through `index.json` beside the -classmaps, verifies its digest, and falls back to the copy embedded in the -binary when it cannot. A Spotify update that reshapes the minified code is +other globals exist. It is one file for every build: the CLI fetches it +through `index.json` beside the classmaps, verifies its digest, and falls back +to the copy embedded in the binary when it cannot. A Spotify update that reshapes the minified code is answered here, with a data commit, instead of a CLI release. To change a pattern, measure it against real bundles first. From the CLI diff --git a/scripts/promote_inherited.py b/scripts/promote_inherited.py index ec717ee..4936ef0 100644 --- a/scripts/promote_inherited.py +++ b/scripts/promote_inherited.py @@ -208,7 +208,8 @@ def build_meta( live_hits: set[str], ) -> dict: stale = sorted(set(source_meta.get("stale_leaves") or []) - live_hits) - unverified = missing_in_target - live_hits - set(stale) + doubted = missing_in_target | set(source_meta.get("unverified_leaves") or []) + unverified = doubted - live_hits - set(stale) return { "schema_version": 2, "classmap_key": target_key, diff --git a/scripts/test_promote_inherited.py b/scripts/test_promote_inherited.py index 2e6ecc2..657f653 100644 --- a/scripts/test_promote_inherited.py +++ b/scripts/test_promote_inherited.py @@ -408,7 +408,7 @@ def test_live_verification_recovers_an_inherited_stale_path(self): meta = json.loads((target / "META.json").read_text()) self.assertNotIn("main.topbar.retired", meta["stale_leaves"]) - def test_a_path_present_in_target_css_is_statically_verified(self): + def test_a_source_unverified_path_stays_unverified_without_a_live_hit(self): source_meta = json.loads((self.source / "META.json").read_text()) source_meta["required_paths"]["main.topbar.wrapper"] = "unverified" source_meta["unverified_leaves"] = ["main.topbar.wrapper"] @@ -427,8 +427,28 @@ def test_a_path_present_in_target_css_is_statically_verified(self): ) meta = json.loads((target / "META.json").read_text()) - self.assertEqual(meta["required_paths"]["main.topbar.wrapper"], "verified_static") - self.assertEqual(meta["unverified_leaves"], []) + self.assertEqual(meta["required_paths"]["main.topbar.wrapper"], "unverified") + self.assertEqual(meta["unverified_leaves"], ["main.topbar.wrapper"]) + + def test_a_live_hit_clears_a_source_unverified_path(self): + source_meta = json.loads((self.source / "META.json").read_text()) + source_meta["required_paths"]["main.topbar.wrapper"] = "unverified" + source_meta["unverified_leaves"] = ["main.topbar.wrapper"] + (self.source / "META.json").write_text(render_json(source_meta)) + + target = promote.promote_inherited_release( + root=self.root, + source_key="1020094", + spotify_version="1.2.96.518", + static_report=self.static_report, + cdp_report=self.cdp_report, + generated="2026-08-12", + min_hit_rate=0.25, + ) + + meta = json.loads((target / "META.json").read_text()) + self.assertEqual(meta["required_paths"]["main.topbar.wrapper"], "verified_cdp") + self.assertEqual(meta["unverified_leaves"], ["main.playbar.indicator"]) def test_refuses_a_release_that_keeps_a_required_path_stale(self): source_meta = json.loads((self.source / "META.json").read_text()) From 66e8778d986e1888bc8b9aebcd501d0a985c6832 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:32:15 +0200 Subject: [PATCH 12/20] fix(scripts): write index.json on Python 3.9 --- scripts/build_index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build_index.py b/scripts/build_index.py index 8f6ff6c..9114ce3 100644 --- a/scripts/build_index.py +++ b/scripts/build_index.py @@ -89,7 +89,7 @@ def main() -> int: print("index.json is up to date") return 0 - INDEX.write_text(rendered, encoding="utf-8", newline="\n") + INDEX.write_bytes(rendered.encode("utf-8")) print(f"wrote {INDEX.relative_to(ROOT)} ({len(build()['keys'])} keys)") return 0 From 3896b66060d0751f6b5e9349867639870d53d084 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:32:20 +0200 Subject: [PATCH 13/20] feat(scripts): publish derived keys as well as inherited ones publish_key.py replaces promote_inherited.py. A derived key takes a candidate map and overlay, with --stale and --note for the decisions the reports can't make, and gets the same report checks, generated META.json and VERIFICATION.md, validation and index rollback. --- .../{promote_inherited.py => publish_key.py} | 292 +++++++++++------- ...omote_inherited.py => test_publish_key.py} | 195 ++++++++---- 2 files changed, 313 insertions(+), 174 deletions(-) rename scripts/{promote_inherited.py => publish_key.py} (52%) rename scripts/{test_promote_inherited.py => test_publish_key.py} (76%) diff --git a/scripts/promote_inherited.py b/scripts/publish_key.py similarity index 52% rename from scripts/promote_inherited.py rename to scripts/publish_key.py index 4936ef0..9f90ff1 100644 --- a/scripts/promote_inherited.py +++ b/scripts/publish_key.py @@ -1,9 +1,23 @@ #!/usr/bin/env python3 -"""Promote a verified classmap to an unchanged Spotify patch release. +"""Publish a classmap key from the CLI's static and deep CDP verification reports. -The command deliberately consumes reports produced by the CLI's static and -CDP verifiers. It will not copy a map when the live report belongs to another -Spotify build, the hit-rate gate failed, or either report omits classmap paths. +An inherited key copies an older key's map and overlay byte-for-byte: + + python3 scripts/publish_key.py --inherit-from 1020094 \\ + --spotify-version 1.2.96.518 \\ + --static-report static.json --cdp-report cdp.json + +A derived key publishes a new candidate map: + + python3 scripts/publish_key.py --classmap candidate.json --css-map overlay.json \\ + --derived-from 1030001 --spotify-version 1.3.2.100 \\ + --static-report static.json --cdp-report cdp.json \\ + --stale main.topbar.right.upgrade_button.wrapper \\ + --note "The upgrade button has no rendered instance on a premium account." + +Both reports must be bound to the published map and Spotify build. The command +writes classmap.json, css-map.json, META.json and VERIFICATION.md, validates the +new key, and rebuilds index.json, rolling everything back if a step fails. """ from __future__ import annotations @@ -20,13 +34,21 @@ from datetime import date from pathlib import Path -from validate_classmaps import key_errors, leaf_values, render_json, version_to_key +from validate_classmaps import KEY_DIR, key_errors, leaf_values, render_json, version_to_key ROOT = Path(__file__).resolve().parent.parent -MIN_PROMOTION_HIT_RATE = 0.25 +MIN_HIT_RATE = 0.25 SHA256_RE = re.compile(r"[0-9a-f]{64}") +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def write_text(path: Path, text: str) -> None: + path.write_bytes(text.encode("utf-8")) + + def report_rows( report: dict, name: str, @@ -48,21 +70,8 @@ def report_rows( return rows -def sha256(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def validate_release( - source_key: str, - target_key: str, - spotify_version: str, - classmap: dict, - source_meta: dict, - static_report: dict, - cdp_report: dict, - min_hit_rate: float, -) -> tuple[set[str], set[str], float]: - if not re.fullmatch(r"\d{7}", source_key) or not re.fullmatch(r"\d{7}", target_key): +def check_source(source_key: str, target_key: str, source_meta: dict, inherited: bool) -> None: + if not KEY_DIR.fullmatch(source_key): raise ValueError("classmap keys must contain exactly seven digits") if source_meta.get("status") != "verified": raise ValueError(f"source {source_key} is not verified") @@ -70,17 +79,34 @@ def validate_release( raise ValueError(f"source metadata key does not match {source_key}") if version_to_key(str(source_meta.get("spotify_version", ""))) != source_key: raise ValueError(f"source metadata version does not match {source_key}") - if source_key[:3] != target_key[:3] or int(target_key) <= int(source_key): + if int(target_key) <= int(source_key): + raise ValueError(f"{target_key} is not newer than {source_key}") + if inherited and source_key[:3] != target_key[:3]: raise ValueError(f"{target_key} is not a newer patch in the {source_key[:3]} family") + +def check_reports( + spotify_version: str, + classmap: dict, + classmap_digest: str, + static_report: dict, + cdp_report: dict, + min_hit_rate: float, +) -> tuple[set[str], set[str], float]: + """Returns the paths missing from the target CSS, the live hits, and the hit rate.""" + if static_report.get("target", {}).get("classmap_sha256") != classmap_digest: + raise ValueError("static report classmap digest does not match the published map") + if cdp_report.get("classmap", {}).get("sha256") != classmap_digest: + raise ValueError("CDP report classmap digest does not match the published map") + user_agent = cdp_report.get("cdp", {}).get("browser", {}).get("User-Agent", "") version_match = re.search(r"(?:^|\s)Spotify/([0-9.]+)(?=\s|$)", user_agent) if not version_match or version_match.group(1) != spotify_version: raise ValueError(f"CDP report does not match Spotify {spotify_version}: {user_agent!r}") if cdp_report.get("deep") is not True: - raise ValueError("promotion requires a deep CDP verification report") + raise ValueError("publication requires a deep CDP verification report") if cdp_report.get("mode") != "both": - raise ValueError("promotion requires CDP mode 'both'") + raise ValueError("publication requires CDP mode 'both'") navigation = cdp_report.get("navigation", {}) attempted = navigation.get("attempted") succeeded = navigation.get("succeeded") @@ -96,9 +122,9 @@ def validate_release( ): raise ValueError("CDP navigation ledger is inconsistent") if attempted < 8 or succeeded < 4: - raise ValueError("promotion requires meaningful deep navigation coverage") - if not math.isfinite(min_hit_rate) or not MIN_PROMOTION_HIT_RATE <= min_hit_rate <= 1: - raise ValueError(f"minimum hit rate must be finite and at least {MIN_PROMOTION_HIT_RATE}") + raise ValueError("publication requires meaningful deep navigation coverage") + if not math.isfinite(min_hit_rate) or not MIN_HIT_RATE <= min_hit_rate <= 1: + raise ValueError(f"minimum hit rate must be finite and at least {MIN_HIT_RATE}") static_target = static_report.get("target", {}) if static_target.get("spotify_version") != spotify_version: @@ -136,30 +162,32 @@ def validate_release( return missing_in_target, live_hits, round(hit_rate, 4) -def updated_required_paths( - source_meta: dict, unverified: set[str], live_hits: set[str] +def required_statuses( + required: dict, unverified: set[str], live_hits: set[str] ) -> dict[str, str]: - required = dict(source_meta.get("required_paths") or {}) + statuses = {} for path in required: if path in live_hits: - required[path] = "verified_cdp" + statuses[path] = "verified_cdp" elif path in unverified: - required[path] = "unverified" + statuses[path] = "unverified" else: - required[path] = "verified_static" - return required + statuses[path] = "verified_static" + return statuses def verification_markdown( - source_key: str, target_key: str, spotify_version: str, + method: str, + source_key: str | None, paths: set[str], missing_in_target: set[str], live_hits: set[str], cdp_report: dict, cdp_hit_rate: float, overlay_entries: int, + notes: list[str], ) -> str: live_only = missing_in_target & live_hits navigation = cdp_report["navigation"] @@ -172,21 +200,28 @@ def verification_markdown( "cdp_hit_rate": cdp_hit_rate, "overlay_entries": overlay_entries, } + if method == "inherited": + step = f"inherit({source_key} -> {target_key})" + origin = [f"- Classmap inherited byte-for-byte from {source_key}; no migration guesses were accepted."] + else: + step = f"derive({source_key or 'stock CSS'} -> {target_key})" + origin = [f"- Classmap derived from {source_key}." if source_key else "- Classmap derived from the stock CSS."] lines = [ f"# Spotify {spotify_version} ({target_key})", "", - f"Pipeline: inherit({source_key} -> {target_key}) + static target-CSS verification + CDP e2e (deep).", + f"Pipeline: {step} + static target-CSS verification + CDP e2e (deep).", "", "## Notes", "", - f"- Classmap inherited byte-for-byte from {source_key}; no migration guesses were accepted.", + *origin, f"- Static verification found {rows['static_present']}/{len(paths)} paths in the target CSS.", f"- CSS-only misses observed live, which remain verified: {len(live_only)}.", ( f"- Deep CDP verification observed {len(live_hits)}/{len(paths)} paths with " f"{navigation['succeeded']}/{navigation['attempted']} successful navigation steps." ), - "- Unresolved new misses remain usable but are marked unverified; inherited stale paths stay blocked.", + "- Unresolved new misses remain usable but are marked unverified; stale paths stay blocked.", + *(f"- {note}" for note in notes), "", "## Statistics at publication", "", @@ -197,102 +232,112 @@ def verification_markdown( return "\n".join(lines) + "\n" -def build_meta( - source_key: str, - target_key: str, - spotify_version: str, - generated: str, - source_meta: dict, - paths: set[str], - missing_in_target: set[str], - live_hits: set[str], -) -> dict: - stale = sorted(set(source_meta.get("stale_leaves") or []) - live_hits) - doubted = missing_in_target | set(source_meta.get("unverified_leaves") or []) - unverified = doubted - live_hits - set(stale) - return { - "schema_version": 2, - "classmap_key": target_key, - "spotify_version": spotify_version, - "status": "verified", - "generated": generated, - "source": {"method": "inherited", "key": source_key}, - "required_paths": updated_required_paths(source_meta, unverified, live_hits), - "stale_leaves": stale, - "unverified_leaves": sorted(unverified), - } +def newest_verified_key(root: Path, below: str) -> str | None: + keys = sorted( + p.name + for p in root.iterdir() + if p.is_dir() and KEY_DIR.fullmatch(p.name) and p.name < below and (p / "META.json").is_file() + ) + return keys[-1] if keys else None -def promote_inherited_release( +def publish_release( *, root: Path, - source_key: str, spotify_version: str, static_report: dict, cdp_report: dict, generated: str, min_hit_rate: float, + inherit_from: str | None = None, + classmap_path: Path | None = None, + overlay_path: Path | None = None, + derived_from: str | None = None, + required_from: str | None = None, + stale: tuple[str, ...] = (), + notes: tuple[str, ...] = (), ) -> Path: + if (inherit_from is None) == (classmap_path is None): + raise ValueError("pass exactly one of --inherit-from or --classmap") + if inherit_from is not None and (overlay_path or derived_from or stale): + raise ValueError("an inherited key takes its map, overlay and stale paths from its source") + target_key = version_to_key(spotify_version) target_dir = root / target_key if target_dir.exists(): raise FileExistsError(f"{target_key} already exists") - source_dir = root / source_key - source_classmap = source_dir / "classmap.json" - classmap = json.loads(source_classmap.read_text()) - source_meta = json.loads((source_dir / "META.json").read_text()) - classmap_digest = sha256(source_classmap) - if static_report.get("target", {}).get("classmap_sha256") != classmap_digest: - raise ValueError("static report classmap digest does not match source") - if cdp_report.get("classmap", {}).get("sha256") != classmap_digest: - raise ValueError("CDP report classmap digest does not match source") - missing_in_target, live_hits, cdp_hit_rate = validate_release( - source_key, - target_key, - spotify_version, - classmap, - source_meta, - static_report, - cdp_report, - min_hit_rate, + source_key = inherit_from or derived_from + source_meta: dict = {} + if source_key is not None: + source_meta = json.loads((root / source_key / "META.json").read_text(encoding="utf-8")) + check_source(source_key, target_key, source_meta, inherited=inherit_from is not None) + + if inherit_from is not None: + classmap_bytes = (root / inherit_from / "classmap.json").read_bytes() + source_overlay = root / inherit_from / "css-map.json" + overlay = json.loads(source_overlay.read_text(encoding="utf-8")) if source_overlay.is_file() else None + else: + classmap_bytes = classmap_path.read_bytes() + overlay = json.loads(overlay_path.read_text(encoding="utf-8")) if overlay_path else None + classmap = json.loads(classmap_bytes) + + missing_in_target, live_hits, cdp_hit_rate = check_reports( + spotify_version, classmap, sha256(classmap_bytes), static_report, cdp_report, min_hit_rate ) - overlay = source_dir / "css-map.json" - overlay_entries = len(json.loads(overlay.read_text())) if overlay.is_file() else 0 - paths = set(leaf_values(classmap)) - meta = build_meta( - source_key, - target_key, - spotify_version, - generated, - source_meta, - paths, - missing_in_target, - live_hits, - ) + if inherit_from is not None: + stale_paths = sorted(set(source_meta.get("stale_leaves") or []) - live_hits) + doubted = missing_in_target | set(source_meta.get("unverified_leaves") or []) + required = source_meta.get("required_paths") or {} + else: + stale_paths = sorted(set(stale)) + doubted = set(missing_in_target) + required_key = required_from or derived_from or newest_verified_key(root, target_key) + required = {} + if required_key is not None: + required_meta = json.loads((root / required_key / "META.json").read_text(encoding="utf-8")) + required = required_meta.get("required_paths") or {} + unverified = doubted - live_hits - set(stale_paths) + + meta = { + "schema_version": 2, + "classmap_key": target_key, + "spotify_version": spotify_version, + "status": "verified", + "generated": generated, + "source": {"method": "inherited" if inherit_from else "derived", "key": source_key}, + "required_paths": required_statuses(required, unverified, live_hits), + "stale_leaves": stale_paths, + "unverified_leaves": sorted(unverified), + } verification = verification_markdown( - source_key, target_key, spotify_version, - paths, + meta["source"]["method"], + source_key, + set(leaf_values(classmap)), missing_in_target, live_hits, cdp_report, cdp_hit_rate, - overlay_entries, + len(overlay or {}), + list(notes), ) staging_dir = Path(tempfile.mkdtemp(prefix=f".{target_key}-", dir=root)) try: - shutil.copy2(source_classmap, staging_dir / source_classmap.name) - if overlay.is_file(): - shutil.copy2(overlay, staging_dir / overlay.name) - (staging_dir / "META.json").write_text(render_json(meta), encoding="utf-8", newline="\n") - (staging_dir / "VERIFICATION.md").write_text(verification, encoding="utf-8", newline="\n") + if inherit_from is not None: + (staging_dir / "classmap.json").write_bytes(classmap_bytes) + else: + write_text(staging_dir / "classmap.json", render_json(classmap)) + if overlay is not None: + write_text(staging_dir / "css-map.json", render_json(overlay)) + write_text(staging_dir / "META.json", render_json(meta)) + write_text(staging_dir / "VERIFICATION.md", verification) errors = key_errors(staging_dir, key=target_key, root=root) if errors: - raise ValueError("promoted release fails validation: " + "; ".join(errors)) + raise ValueError("published key fails validation: " + "; ".join(errors)) staging_dir.rename(target_dir) except Exception: shutil.rmtree(staging_dir, ignore_errors=True) @@ -300,11 +345,11 @@ def promote_inherited_release( return target_dir -def publish_inherited_release(**kwargs) -> Path: +def publish_and_index(**kwargs) -> Path: root = kwargs["root"] index_path = root / "index.json" previous_index = index_path.read_bytes() if index_path.is_file() else None - target = promote_inherited_release(**kwargs) + target = publish_release(**kwargs) try: subprocess.run([sys.executable, str(root / "scripts/build_index.py")], check=True) except Exception: @@ -318,26 +363,45 @@ def publish_inherited_release(**kwargs) -> Path: def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--from-key", required=True) + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + origin = parser.add_mutually_exclusive_group(required=True) + origin.add_argument("--inherit-from", metavar="KEY", help="copy this key's map and overlay") + origin.add_argument("--classmap", type=Path, help="candidate map for a derived key") + parser.add_argument("--css-map", type=Path, help="overlay for a derived key") + parser.add_argument("--derived-from", metavar="KEY", help="key the candidate was migrated from") + parser.add_argument( + "--required-paths-from", + metavar="KEY", + help="key whose required_paths to track (default: --derived-from, else the newest key)", + ) + parser.add_argument("--stale", action="append", default=[], metavar="PATH", help="mark a leaf stale") + parser.add_argument("--note", action="append", default=[], help="add a line to VERIFICATION.md") parser.add_argument("--spotify-version", required=True) parser.add_argument("--static-report", type=Path, required=True) parser.add_argument("--cdp-report", type=Path, required=True) parser.add_argument("--root", type=Path, default=ROOT) parser.add_argument("--generated", default=date.today().isoformat()) - parser.add_argument("--min-hit-rate", type=float, default=0.25) + parser.add_argument("--min-hit-rate", type=float, default=MIN_HIT_RATE) args = parser.parse_args() - target = publish_inherited_release( + target = publish_and_index( root=args.root, - source_key=args.from_key, spotify_version=args.spotify_version, - static_report=json.loads(args.static_report.read_text()), - cdp_report=json.loads(args.cdp_report.read_text()), + static_report=json.loads(args.static_report.read_text(encoding="utf-8")), + cdp_report=json.loads(args.cdp_report.read_text(encoding="utf-8")), generated=args.generated, min_hit_rate=args.min_hit_rate, + inherit_from=args.inherit_from, + classmap_path=args.classmap, + overlay_path=args.css_map, + derived_from=args.derived_from, + required_from=args.required_paths_from, + stale=tuple(args.stale), + notes=tuple(args.note), ) - print(f"promoted {args.from_key} to {target.name}: {target}") + print(f"published {target.name}: {target}") return 0 diff --git a/scripts/test_promote_inherited.py b/scripts/test_publish_key.py similarity index 76% rename from scripts/test_promote_inherited.py rename to scripts/test_publish_key.py index 657f653..e216550 100644 --- a/scripts/test_promote_inherited.py +++ b/scripts/test_publish_key.py @@ -11,12 +11,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) -import promote_inherited as promote # noqa: E402 +import publish_key as publish # noqa: E402 import build_index # noqa: E402 from validate_classmaps import render_json # noqa: E402 -class PromoteInheritedTests(unittest.TestCase): +class PublishKeyTests(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.TemporaryDirectory() self.root = Path(self.temp_dir.name) @@ -103,9 +103,9 @@ def tearDown(self): self.temp_dir.cleanup() def test_promotes_an_unchanged_map_and_records_new_stale_paths(self): - target = promote.promote_inherited_release( + target = publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -138,12 +138,10 @@ def test_promotes_an_unchanged_map_and_records_new_stale_paths(self): def test_generated_index_uses_lf_and_hashes_published_bytes(self): index_path = self.root / "index.json" - with ( - mock.patch.object(build_index, "ROOT", self.root), - mock.patch.object(build_index, "INDEX", index_path), - mock.patch.object(sys, "argv", ["build_index.py"]), - ): - self.assertEqual(build_index.main(), 0) + with mock.patch.object(build_index, "ROOT", self.root): + with mock.patch.object(build_index, "INDEX", index_path): + with mock.patch.object(sys, "argv", ["build_index.py"]): + self.assertEqual(build_index.main(), 0) index_bytes = index_path.read_bytes() self.assertNotIn(b"\r", index_bytes) entry = json.loads(index_bytes)["keys"]["1020094"] @@ -157,9 +155,9 @@ def test_refuses_a_report_from_another_spotify_version(self): self.cdp_report["cdp"]["browser"]["User-Agent"] = "Spotify/1.2.95.453 Chrome/146" with self.assertRaisesRegex(ValueError, "does not match Spotify 1.2.96.518"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -171,9 +169,9 @@ def test_refuses_a_spotify_version_that_only_has_the_requested_prefix(self): self.cdp_report["cdp"]["browser"]["User-Agent"] = "Spotify/1.2.96.5189 Chrome/146" with self.assertRaisesRegex(ValueError, "does not match Spotify 1.2.96.518"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -187,9 +185,9 @@ def test_refuses_source_metadata_that_does_not_match_the_source_key(self): (self.source / "META.json").write_text(json.dumps(source_meta) + "\n") with self.assertRaisesRegex(ValueError, "source metadata key"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -203,9 +201,9 @@ def test_live_hit_overrides_a_css_only_missing_result(self): row["hit"] = True self.cdp_report["summary"].update({"hits": 2, "hitRate": 0.6667}) - target = promote.promote_inherited_release( + target = publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -226,9 +224,9 @@ def test_refuses_a_live_report_below_the_required_hit_rate(self): self.cdp_report["summary"].update({"hits": 0, "hitRate": 0.0}) with self.assertRaisesRegex(ValueError, "below required 0.2500"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -240,9 +238,9 @@ def test_refuses_a_summary_that_disagrees_with_rows(self): self.cdp_report["rows"][0]["hit"] = False with self.assertRaisesRegex(ValueError, "summary does not match rows"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -253,9 +251,9 @@ def test_refuses_a_summary_that_disagrees_with_rows(self): def test_refuses_non_finite_rates_and_bypass_thresholds(self): self.cdp_report["summary"]["hitRate"] = float("nan") with self.assertRaisesRegex(ValueError, "finite"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -264,9 +262,9 @@ def test_refuses_non_finite_rates_and_bypass_thresholds(self): ) self.cdp_report["summary"]["hitRate"] = 0.3333 with self.assertRaisesRegex(ValueError, "at least 0.25"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -277,9 +275,9 @@ def test_refuses_non_finite_rates_and_bypass_thresholds(self): def test_refuses_shallow_or_mismatched_reports(self): self.cdp_report["deep"] = False with self.assertRaisesRegex(ValueError, "deep CDP"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -290,9 +288,9 @@ def test_refuses_shallow_or_mismatched_reports(self): def test_refuses_missing_mode_or_failed_deep_navigation(self): del self.cdp_report["mode"] with self.assertRaisesRegex(ValueError, "mode 'both'"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -306,9 +304,9 @@ def test_refuses_missing_mode_or_failed_deep_navigation(self): "failed": [f"step-{index}" for index in range(8)], } with self.assertRaisesRegex(ValueError, "deep navigation coverage"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -319,9 +317,9 @@ def test_refuses_missing_mode_or_failed_deep_navigation(self): def test_refuses_inconsistent_navigation_ledger(self): self.cdp_report["navigation"] = {"attempted": 8, "succeeded": 9, "failed": []} with self.assertRaisesRegex(ValueError, "navigation ledger"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -330,9 +328,9 @@ def test_refuses_inconsistent_navigation_ledger(self): ) self.cdp_report["navigation"] = {"attempted": 8, "succeeded": 7, "failed": []} with self.assertRaisesRegex(ValueError, "navigation ledger"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -342,9 +340,9 @@ def test_refuses_inconsistent_navigation_ledger(self): def test_refuses_static_summary_that_disagrees_with_rows(self): self.static_report["summary"] = {"missing_in_css": 0, "needs_manual_check": 3} with self.assertRaisesRegex(ValueError, "static summary does not match rows"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -354,9 +352,9 @@ def test_refuses_static_summary_that_disagrees_with_rows(self): self.cdp_report["deep"] = True self.static_report["target"]["spotify_version"] = "1.2.95.453" with self.assertRaisesRegex(ValueError, "static report does not match"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -367,9 +365,9 @@ def test_refuses_static_summary_that_disagrees_with_rows(self): def test_refuses_report_leaf_or_digest_mismatch(self): self.cdp_report["rows"][0]["hash"] = "anotherHashZZ" with self.assertRaisesRegex(ValueError, "does not match classmap"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -379,9 +377,9 @@ def test_refuses_report_leaf_or_digest_mismatch(self): self.cdp_report["rows"][0]["hash"] = "topbarHashAA" self.cdp_report["classmap"]["sha256"] = "b" * 64 with self.assertRaisesRegex(ValueError, "classmap digest"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -395,9 +393,9 @@ def test_live_verification_recovers_an_inherited_stale_path(self): row["hit"] = True self.cdp_report["summary"].update({"hits": 2, "hitRate": 0.6667}) - target = promote.promote_inherited_release( + target = publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -416,9 +414,9 @@ def test_a_source_unverified_path_stays_unverified_without_a_live_hit(self): self.cdp_report["rows"][0]["hit"] = False self.cdp_report["rows"][2]["hit"] = True - target = promote.promote_inherited_release( + target = publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -436,9 +434,9 @@ def test_a_live_hit_clears_a_source_unverified_path(self): source_meta["unverified_leaves"] = ["main.topbar.wrapper"] (self.source / "META.json").write_text(render_json(source_meta)) - target = promote.promote_inherited_release( + target = publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -456,9 +454,9 @@ def test_refuses_a_release_that_keeps_a_required_path_stale(self): (self.source / "META.json").write_text(render_json(source_meta)) with self.assertRaisesRegex(ValueError, "required path main.topbar.retired is stale"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -469,11 +467,11 @@ def test_refuses_a_release_that_keeps_a_required_path_stale(self): self.assertEqual(list(self.root.glob(".1020096-*")), []) def test_failed_copy_leaves_no_partial_target(self): - with mock.patch.object(promote.shutil, "copy2", side_effect=OSError("disk full")): + with mock.patch.object(publish, "write_text", side_effect=OSError("disk full")): with self.assertRaisesRegex(OSError, "disk full"): - promote.promote_inherited_release( + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -487,14 +485,14 @@ def test_index_failure_rolls_back_target_and_index(self): index = self.root / "index.json" index.write_text('{"old": true}\n') with mock.patch.object( - promote.subprocess, + publish.subprocess, "run", side_effect=subprocess.CalledProcessError(1, ["build_index.py"]), ): with self.assertRaises(subprocess.CalledProcessError): - promote.publish_inherited_release( + publish.publish_and_index( root=self.root, - source_key="1020094", + inherit_from="1020094", spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, @@ -508,9 +506,86 @@ def test_refuses_to_overwrite_an_existing_target(self): (self.root / "1020096").mkdir() with self.assertRaisesRegex(FileExistsError, "1020096 already exists"): - promote.promote_inherited_release( + publish.publish_release( + root=self.root, + inherit_from="1020094", + spotify_version="1.2.96.518", + static_report=self.static_report, + cdp_report=self.cdp_report, + generated="2026-08-12", + min_hit_rate=0.25, + ) + + def bind_reports_to(self, candidate: Path) -> None: + digest = hashlib.sha256(candidate.read_bytes()).hexdigest() + self.static_report["target"]["classmap_sha256"] = digest + self.cdp_report["classmap"]["sha256"] = digest + + def test_publishes_a_derived_key_from_a_candidate_map(self): + candidate = self.root / "candidate.json" + candidate.write_text(json.dumps(self.classmap, indent="\t")) + overlay = self.root / "overlay.json" + overlay.write_text('{"topbarHashAA":"Root__globalNav"}') + self.bind_reports_to(candidate) + + target = publish.publish_release( + root=self.root, + classmap_path=candidate, + overlay_path=overlay, + derived_from="1020094", + stale=("main.topbar.retired",), + notes=("The retired leaf has no rendered instance.",), + spotify_version="1.2.96.518", + static_report=self.static_report, + cdp_report=self.cdp_report, + generated="2026-08-12", + min_hit_rate=0.25, + ) + + self.assertEqual((target / "classmap.json").read_text(), render_json(self.classmap)) + self.assertEqual( + (target / "css-map.json").read_text(), render_json({"topbarHashAA": "Root__globalNav"}) + ) + meta = json.loads((target / "META.json").read_text()) + self.assertEqual(meta["source"], {"method": "derived", "key": "1020094"}) + self.assertEqual(meta["stale_leaves"], ["main.topbar.retired"]) + self.assertEqual(meta["unverified_leaves"], ["main.playbar.indicator"]) + self.assertEqual( + meta["required_paths"], + {"main.topbar.wrapper": "verified_cdp", "main.playbar.indicator": "unverified"}, + ) + verification = (target / "VERIFICATION.md").read_text() + self.assertIn("- Classmap derived from 1020094.", verification) + self.assertIn("- The retired leaf has no rendered instance.", verification) + + def test_refuses_a_derived_candidate_that_stores_spicetify_names(self): + candidate = self.root / "candidate.json" + candidate.write_text(render_json(self.classmap)) + overlay = self.root / "overlay.json" + overlay.write_text(render_json({"someHash": "topbarHashAA"})) + self.bind_reports_to(candidate) + + with self.assertRaisesRegex(ValueError, "stores the Spicetify name topbarHashAA"): + publish.publish_release( + root=self.root, + classmap_path=candidate, + overlay_path=overlay, + derived_from="1020094", + stale=("main.topbar.retired",), + spotify_version="1.2.96.518", + static_report=self.static_report, + cdp_report=self.cdp_report, + generated="2026-08-12", + min_hit_rate=0.25, + ) + self.assertFalse((self.root / "1020096").exists()) + + def test_refuses_stale_paths_for_an_inherited_key(self): + with self.assertRaisesRegex(ValueError, "takes its map, overlay and stale paths"): + publish.publish_release( root=self.root, - source_key="1020094", + inherit_from="1020094", + stale=("main.topbar.wrapper",), spotify_version="1.2.96.518", static_report=self.static_report, cdp_report=self.cdp_report, From 48f01d9eba84e769030feecef95eb53cb99e994d Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:32:24 +0200 Subject: [PATCH 14/20] feat(scripts): run every check through check.py, with --fix to format --- .github/workflows/index.yml | 13 ++------ scripts/check.py | 60 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 scripts/check.py diff --git a/.github/workflows/index.yml b/.github/workflows/index.yml index d50e47e..c50bcb6 100644 --- a/.github/workflows/index.yml +++ b/.github/workflows/index.yml @@ -13,14 +13,5 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Verify index - run: python3 scripts/build_index.py --check - - - name: Validate classmap keys - run: python3 scripts/validate_classmaps.py - - - name: Test classmap contracts and promotion - run: python3 -m unittest discover -s scripts - - - name: Validate exposure patches - run: python3 scripts/validate_expose.py + - name: Run classmap checks + run: python3 scripts/check.py diff --git a/scripts/check.py b/scripts/check.py new file mode 100644 index 0000000..07e9608 --- /dev/null +++ b/scripts/check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Runs every check CI runs. With --fix, first rewrites the JSON files in +canonical form and rebuilds index.json. + + python3 scripts/check.py + python3 scripts/check.py --fix +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +from validate_classmaps import KEY_DIR, ROOT, render_json + +SCRIPTS = Path(__file__).resolve().parent + + +def fix() -> None: + for key_dir in sorted(p for p in ROOT.iterdir() if p.is_dir() and KEY_DIR.match(p.name)): + for name in ("classmap.json", "css-map.json", "META.json"): + path = key_dir / name + if path.is_file(): + path.write_bytes(render_json(json.loads(path.read_text(encoding="utf-8"))).encode("utf-8")) + expose = ROOT / "expose.json" + doc = json.loads(expose.read_text(encoding="utf-8")) + expose.write_bytes((json.dumps(doc, indent=2, ensure_ascii=False) + "\n").encode("utf-8")) + subprocess.run([sys.executable, str(SCRIPTS / "build_index.py")], check=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--fix", action="store_true", help="format JSON and rebuild index.json first") + args = parser.parse_args() + if args.fix: + fix() + + steps = [ + ["build_index.py", "--check"], + ["validate_classmaps.py"], + ["validate_expose.py"], + ["-m", "unittest", "discover", "-s", str(SCRIPTS)], + ] + failed = [] + for step in steps: + command = [sys.executable, *(step if step[0] == "-m" else [str(SCRIPTS / step[0]), *step[1:]])] + if subprocess.run(command, cwd=ROOT).returncode != 0: + failed.append(" ".join(step)) + if failed: + print(f"failed: {', '.join(failed)}", file=sys.stderr) + return 1 + print("all checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From e38f8d0332541ebf3a15f6852dcca62a13fd71e1 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:32:32 +0200 Subject: [PATCH 15/20] docs: describe publishing, checks and agent conventions --- AGENTS.md | 27 ++++++++++++ README.md | 126 ++++++++++++++++++++++++++++++++---------------------- 2 files changed, 103 insertions(+), 50 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5e37ad4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# Agent guide + +The README is the reference for the key layout, the META schema, and the +publishing steps. This file holds what the README and the scripts don't make +obvious. + +- Generate every published file. `scripts/publish_key.py` writes a new key's + `META.json` and `VERIFICATION.md`, `scripts/check.py --fix` formats JSON and + rebuilds `index.json`, and `scripts/check.py` is done when it prints + `all checks passed`. To change an existing key, edit its `classmap.json`, + `css-map.json` or `META.json` fields, then run `check.py --fix`. +- Leaves hold the class exactly as the stock Spotify archive ships it. A class + observed on a patched client has already been renamed by the css-map, so + translate it back to the one stock hash the effective css-map (the CLI's + `css-map.json` plus this key's overlay) rewrites to that name and the stock + archive contains. The validator rejects names it recognises. +- An inherited key's map is byte-identical to its source. When you fix a map, + apply the same fix to every key that inherits from it, or republish those + keys as derived. +- Record run evidence in `VERIFICATION.md`, and keep reports, archives and + scratch files outside the key directories. The validator rejects any file + outside the published layout. +- The scripts use only the Python 3.9 standard library, so the system + `python3` on macOS runs them. +- Changing `expose.json` changes its published digest. The CLI's release + workflow compares its embedded copy against that digest, so open a matching + spicetify/cli pull request that copies the file. diff --git a/README.md b/README.md index dec1cc9..9c0f53c 100644 --- a/README.md +++ b/README.md @@ -65,50 +65,76 @@ reformatted, and the 1020099 and 1030000 leaves stored as Spicetify names were rewritten to their stock classes. Those older digests therefore no longer match the published bytes, but the classes the CLI stages are unchanged. -## Unchanged patch release - -From the CLI repository, generate reports bound to the exact Spotify version, -classmap digest, target CSS digest, and a deep live CDP run: - -```sh -python3 scripts/classmap_capture.py verify \ - --classmap ../classmaps/1020094/classmap.json \ - --css-map css-map.json \ - --target-spa "/path/to/stock/xpui.spa" \ - --target-version 1.2.96.518 \ - --out /tmp/1020096-static.json - -node scripts/classmap_cdp_verify.mjs \ - --port 9229 --mode both --deep \ - --classmap ../classmaps/1020094/classmap.json \ - --css-map css-map.json \ - --out /tmp/1020096-cdp.json -``` - -Inspect the reports. If no replacements are justified, promote the inherited -map from this repository: - -```sh -python3 scripts/promote_inherited.py \ - --from-key 1020094 \ - --spotify-version 1.2.96.518 \ - --static-report /tmp/1020096-static.json \ - --cdp-report /tmp/1020096-cdp.json -``` - -The promoter refuses mismatched versions, maps, leaf values, shallow CDP runs, -inconsistent summaries, low hit rates, and existing targets. It writes -`META.json` and `VERIFICATION.md`, validates the new key, copies through a -temporary directory, and rolls back both the release and `index.json` if -publication preparation fails. Newly absent paths are marked `unverified`, -and paths the source already marked `unverified` stay that way until the deep -CDP run observes them. Only paths already known to be stale remain blocked by -the CLI. - -If migration proposes changed hashes, do not use inheritance. Run the full -capture pipeline from the CLI repository and verify each changed leaf before -publishing it. - +## Publishing a key + +You never write `META.json`, `VERIFICATION.md`, or `index.json` by hand. +`scripts/publish_key.py` generates them from two CLI verification reports, +formats the map and overlay, validates the new key, and rebuilds the index. +It refuses mismatched versions, maps, leaf values, shallow CDP runs, +inconsistent summaries, low hit rates, and existing targets. If any step +fails, it rolls back both the key and `index.json`. + +To publish a key, follow these steps: + +1. From the CLI repository, verify the candidate map against the stock client + of the new build. For an unchanged patch release, the candidate is the + previous key's `classmap.json`. For a derived key, it's the output of + `classmap_capture.py migrate` or your edited copy. + + ```sh + python3 scripts/classmap_capture.py verify \ + --classmap ../classmaps/1020094/classmap.json \ + --css-map css-map.json \ + --target-spa "/path/to/stock/xpui.spa" \ + --target-version 1.2.96.518 \ + --out /tmp/1020096-static.json + + node scripts/classmap_cdp_verify.mjs \ + --port 9229 --mode both --deep \ + --classmap ../classmaps/1020094/classmap.json \ + --css-map css-map.json \ + --out /tmp/1020096-cdp.json + ``` + +2. Inspect the reports. If no leaf needs a new class, publish an inherited key + from this repository: + + ```sh + python3 scripts/publish_key.py --inherit-from 1020094 \ + --spotify-version 1.2.96.518 \ + --static-report /tmp/1020096-static.json \ + --cdp-report /tmp/1020096-cdp.json + ``` + + Otherwise, publish the candidate as a derived key. Pass each decision the + reports can't make as a flag: `--stale PATH` for a leaf whose class is known + to be wrong, and `--note TEXT` for anything a reviewer needs to know. + + ```sh + python3 scripts/publish_key.py --classmap /tmp/1030002.json \ + --css-map /tmp/1030002-css-map.json --derived-from 1030001 \ + --spotify-version 1.3.2.100 \ + --static-report /tmp/1030002-static.json \ + --cdp-report /tmp/1030002-cdp.json \ + --stale main.topbar.right.upgrade_button.wrapper \ + --note "The upgrade button has no rendered instance on a premium account." + ``` + +3. Run `python3 scripts/check.py` and open a pull request. + +The publisher marks a path `unverified` when it's missing from the target CSS +and wasn't observed live. An inherited key also keeps its source's +`unverified` paths until the deep CDP run observes them, and its stale paths +until a live hit clears them. A derived key tracks the `required_paths` of +`--derived-from`, or of the newest key when you don't pass one. + +## Checks + +`python3 scripts/check.py` runs everything CI runs: the index check, the key +validator, the exposure patch validator, and the unit tests. The scripts need +only Python 3.9 or later and the standard library. Run +`python3 scripts/check.py --fix` to rewrite every JSON file in canonical form +and rebuild `index.json` before checking. ## Exposure patches @@ -116,8 +142,9 @@ publishing it. `xpui-modules.js` so `Spicetify.Platform`, `Spicetify.Snackbar`, and the other globals exist. It is one file for every build: the CLI fetches it through `index.json` beside the classmaps, verifies its digest, and falls back -to the copy embedded in the binary when it cannot. A Spotify update that reshapes the minified code is -answered here, with a data commit, instead of a CLI release. +to the copy embedded in the binary when it cannot. A Spotify update that +reshapes the minified code is answered here, with a data commit, instead of a +CLI release. To change a pattern, measure it against real bundles first. From the CLI repository, with the unpatched `xpui-modules.js` of each build you care about @@ -145,12 +172,11 @@ Each patch has these fields: Record the hit counts in the patch's `hits`, then: ```sh -python3 scripts/validate_expose.py -python3 scripts/build_index.py +python3 scripts/check.py --fix ``` -Both run in CI. `onMiss: quiet` is for a patch that is expected to miss on -some supported builds; a `warn` miss is reported by `apply` as +CI runs the same checks. `onMiss: quiet` is for a patch that is expected to +miss on some supported builds; a `warn` miss is reported by `apply` as `api exposure patches that did not match`. Keep the CLI repository's own `expose.json` in step with this one when cutting a release: it is only the offline baseline, but a stale one degrades a first run without network. From ee9bab727a2679d7656f9952526e30314131664c Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:39:20 +0200 Subject: [PATCH 16/20] refactor(scripts): port the classmap tooling to TypeScript Node 24 runs the scripts directly, pnpm check runs tsc and every data check, and CI installs the pinned toolchain. The JSON renderer is byte-identical to the Python one for every published file. --- .github/workflows/index.yml | 14 +- .gitignore | 2 +- package.json | 19 + pnpm-lock.yaml | 240 +++++++++++ scripts/build-index.ts | 72 ++++ scripts/build_index.py | 98 ----- scripts/check.py | 60 --- scripts/check.ts | 47 +++ scripts/classmap-contracts.test.mts | 51 +++ scripts/lib.ts | 62 +++ scripts/publish-key.test.mts | 321 +++++++++++++++ scripts/publish-key.ts | 398 ++++++++++++++++++ scripts/publish_key.py | 409 ------------------- scripts/test_classmap_contracts.py | 62 --- scripts/test_publish_key.py | 598 ---------------------------- scripts/test_validate_classmaps.py | 145 ------- scripts/validate-classmaps.test.mts | 136 +++++++ scripts/validate-classmaps.ts | 229 +++++++++++ scripts/validate-expose.ts | 104 +++++ scripts/validate_classmaps.py | 244 ------------ scripts/validate_expose.py | 94 ----- tsconfig.json | 15 + 22 files changed, 1708 insertions(+), 1712 deletions(-) create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 scripts/build-index.ts delete mode 100644 scripts/build_index.py delete mode 100644 scripts/check.py create mode 100644 scripts/check.ts create mode 100644 scripts/classmap-contracts.test.mts create mode 100644 scripts/lib.ts create mode 100644 scripts/publish-key.test.mts create mode 100644 scripts/publish-key.ts delete mode 100644 scripts/publish_key.py delete mode 100644 scripts/test_classmap_contracts.py delete mode 100644 scripts/test_publish_key.py delete mode 100644 scripts/test_validate_classmaps.py create mode 100644 scripts/validate-classmaps.test.mts create mode 100644 scripts/validate-classmaps.ts create mode 100644 scripts/validate-expose.ts delete mode 100644 scripts/validate_classmaps.py delete mode 100755 scripts/validate_expose.py create mode 100644 tsconfig.json diff --git a/.github/workflows/index.yml b/.github/workflows/index.yml index c50bcb6..3ac079f 100644 --- a/.github/workflows/index.yml +++ b/.github/workflows/index.yml @@ -13,5 +13,17 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Run classmap checks - run: python3 scripts/check.py + run: pnpm check diff --git a/.gitignore b/.gitignore index c18dd8d..c2658d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -__pycache__/ +node_modules/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..9a2ae9f --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "spicetify-classmaps", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "packageManager": "pnpm@10.0.0", + "scripts": { + "check": "tsc && node scripts/check.ts", + "fix": "node scripts/check.ts --fix", + "test": "node --test 'scripts/*.test.mts'", + "publish-key": "node scripts/publish-key.ts" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "typescript": "^7.0.2" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..a1c5e43 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,240 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^26.1.1 + version: 26.6.3 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + +packages: + + '@types/node@26.6.3': + resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + +snapshots: + + '@types/node@26.6.3': + dependencies: + undici-types: 8.9.0 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.9.0: {} diff --git a/scripts/build-index.ts b/scripts/build-index.ts new file mode 100644 index 0000000..5186d88 --- /dev/null +++ b/scripts/build-index.ts @@ -0,0 +1,72 @@ +// Regenerates index.json, the manifest the CLI fetches to resolve a classmap. +// Output is deterministic (no timestamps) so --check is a plain comparison. +// +// node scripts/build-index.ts # rewrite index.json +// node scripts/build-index.ts --check # fail if it is out of date + +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { keyDirs, renderJson, ROOT, sha256, writeText } from "./lib.ts"; + +type FileRef = { file: string; sha256: string }; +type Entry = { + classmap: FileRef; + cssMapOverlay?: FileRef; + meta?: FileRef; + spotifyVersion?: string; + status?: string; +}; + +function fileRef(dir: string, name: string): FileRef { + return { file: name, sha256: sha256(readFileSync(path.join(dir, name))) }; +} + +function entryFor(keyDir: string): Entry | null { + if (!existsSync(path.join(keyDir, "classmap.json"))) return null; + const entry: Entry = { classmap: fileRef(keyDir, "classmap.json") }; + if (existsSync(path.join(keyDir, "css-map.json"))) entry.cssMapOverlay = fileRef(keyDir, "css-map.json"); + if (existsSync(path.join(keyDir, "META.json"))) { + entry.meta = fileRef(keyDir, "META.json"); + const meta = JSON.parse(readFileSync(path.join(keyDir, "META.json"), "utf8")); + if ("spotify_version" in meta) entry.spotifyVersion = meta.spotify_version; + if ("status" in meta) entry.status = meta.status; + } + return entry; +} + +export function buildIndex(root = ROOT): Record { + const keys: Record = {}; + for (const key of keyDirs(root)) { + const entry = entryFor(path.join(root, key)); + if (entry) keys[key] = entry; + else console.error(`skipping ${key}: no classmap file`); + } + const index: Record = { version: 1, keys }; + // The exposure patch set is one file for every build, so it sits beside the + // keys rather than under one; older CLIs ignore the entry. + if (existsSync(path.join(root, "expose.json"))) index.expose = fileRef(root, "expose.json"); + return index; +} + +export function writeIndex(root = ROOT): void { + writeText(path.join(root, "index.json"), renderJson(buildIndex(root))); +} + +export function indexIsCurrent(root = ROOT): boolean { + const file = path.join(root, "index.json"); + return existsSync(file) && readFileSync(file, "utf8") === renderJson(buildIndex(root)); +} + +if (import.meta.main) { + if (process.argv.includes("--check")) { + if (indexIsCurrent()) console.log("index.json is up to date"); + else { + console.error("index.json is out of date; run node scripts/build-index.ts"); + process.exitCode = 1; + } + } else { + writeIndex(); + console.log("wrote index.json"); + } +} diff --git a/scripts/build_index.py b/scripts/build_index.py deleted file mode 100644 index 9114ce3..0000000 --- a/scripts/build_index.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python3 -"""Regenerates index.json, the manifest the CLI fetches to resolve a classmap. - -Run after adding or updating a classmap directory: - - python3 scripts/build_index.py # rewrite index.json - python3 scripts/build_index.py --check # fail if it is out of date - -Output is deterministic (no timestamps) so --check is a plain diff. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import sys -from pathlib import Path - -from validate_classmaps import KEY_DIR, render_json - -ROOT = Path(__file__).resolve().parent.parent -INDEX = ROOT / "index.json" - - -def sha256(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def classmap_file(key_dir: Path) -> Path | None: - path = key_dir / "classmap.json" - return path if path.is_file() else None - - -def entry_for(key_dir: Path) -> dict | None: - classmap = classmap_file(key_dir) - if classmap is None: - return None - - entry: dict = {"classmap": {"file": classmap.name, "sha256": sha256(classmap)}} - - for name, field in (("META.json", "meta"), ("css-map.json", "cssMapOverlay")): - path = key_dir / name - if path.is_file(): - entry[field] = {"file": name, "sha256": sha256(path)} - - meta_path = key_dir / "META.json" - if meta_path.is_file(): - try: - meta = json.loads(meta_path.read_text()) - except json.JSONDecodeError: - meta = {} - for src, dst in (("spotify_version", "spotifyVersion"), ("status", "status")): - if src in meta: - entry[dst] = meta[src] - - return entry - - -def build() -> dict: - keys = {} - for key_dir in sorted(p for p in ROOT.iterdir() if p.is_dir() and KEY_DIR.match(p.name)): - entry = entry_for(key_dir) - if entry is None: - print(f"skipping {key_dir.name}: no classmap file", file=sys.stderr) - continue - keys[key_dir.name] = entry - index = {"version": 1, "keys": keys} - # The exposure patch set is one file for every build, so it sits beside the - # keys rather than under one; older CLIs ignore the entry. - expose = ROOT / "expose.json" - if expose.is_file(): - index["expose"] = {"file": expose.name, "sha256": sha256(expose)} - return index - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--check", action="store_true", help="fail if index.json is stale") - args = parser.parse_args() - - rendered = render_json(build()) - - if args.check: - current = INDEX.read_text() if INDEX.is_file() else "" - if current != rendered: - print("index.json is out of date; run scripts/build_index.py", file=sys.stderr) - return 1 - print("index.json is up to date") - return 0 - - INDEX.write_bytes(rendered.encode("utf-8")) - print(f"wrote {INDEX.relative_to(ROOT)} ({len(build()['keys'])} keys)") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/check.py b/scripts/check.py deleted file mode 100644 index 07e9608..0000000 --- a/scripts/check.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 -"""Runs every check CI runs. With --fix, first rewrites the JSON files in -canonical form and rebuilds index.json. - - python3 scripts/check.py - python3 scripts/check.py --fix -""" - -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -from pathlib import Path - -from validate_classmaps import KEY_DIR, ROOT, render_json - -SCRIPTS = Path(__file__).resolve().parent - - -def fix() -> None: - for key_dir in sorted(p for p in ROOT.iterdir() if p.is_dir() and KEY_DIR.match(p.name)): - for name in ("classmap.json", "css-map.json", "META.json"): - path = key_dir / name - if path.is_file(): - path.write_bytes(render_json(json.loads(path.read_text(encoding="utf-8"))).encode("utf-8")) - expose = ROOT / "expose.json" - doc = json.loads(expose.read_text(encoding="utf-8")) - expose.write_bytes((json.dumps(doc, indent=2, ensure_ascii=False) + "\n").encode("utf-8")) - subprocess.run([sys.executable, str(SCRIPTS / "build_index.py")], check=True) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--fix", action="store_true", help="format JSON and rebuild index.json first") - args = parser.parse_args() - if args.fix: - fix() - - steps = [ - ["build_index.py", "--check"], - ["validate_classmaps.py"], - ["validate_expose.py"], - ["-m", "unittest", "discover", "-s", str(SCRIPTS)], - ] - failed = [] - for step in steps: - command = [sys.executable, *(step if step[0] == "-m" else [str(SCRIPTS / step[0]), *step[1:]])] - if subprocess.run(command, cwd=ROOT).returncode != 0: - failed.append(" ".join(step)) - if failed: - print(f"failed: {', '.join(failed)}", file=sys.stderr) - return 1 - print("all checks passed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/check.ts b/scripts/check.ts new file mode 100644 index 0000000..2255790 --- /dev/null +++ b/scripts/check.ts @@ -0,0 +1,47 @@ +// Runs every data check CI runs, then the unit tests. With --fix, first +// rewrites the JSON files in canonical form and rebuilds index.json. +// +// node scripts/check.ts +// node scripts/check.ts --fix + +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { indexIsCurrent, writeIndex } from "./build-index.ts"; +import { keyDirs, renderJson, ROOT, writeText } from "./lib.ts"; +import { validateClassmaps } from "./validate-classmaps.ts"; +import { renderExpose, validateExpose } from "./validate-expose.ts"; + +function fix(): void { + for (const key of keyDirs(ROOT)) { + for (const name of ["classmap.json", "css-map.json", "META.json"]) { + const file = path.join(ROOT, key, name); + if (existsSync(file)) writeText(file, renderJson(JSON.parse(readFileSync(file, "utf8")))); + } + } + const expose = path.join(ROOT, "expose.json"); + writeText(expose, renderExpose(JSON.parse(readFileSync(expose, "utf8")))); + writeIndex(); +} + +function main(): number { + if (process.argv.includes("--fix")) fix(); + const failed: string[] = []; + if (!indexIsCurrent()) { + console.error("index.json is out of date; run node scripts/check.ts --fix"); + failed.push("index"); + } + if (validateClassmaps() !== 0) failed.push("classmaps"); + if (validateExpose() !== 0) failed.push("expose"); + const tests = spawnSync(process.execPath, ["--test", "scripts/*.test.mts"], { cwd: ROOT, stdio: "inherit" }); + if (tests.status !== 0) failed.push("tests"); + if (failed.length) { + console.error(`failed: ${failed.join(", ")}`); + return 1; + } + console.log("all checks passed"); + return 0; +} + +process.exitCode = main(); diff --git a/scripts/classmap-contracts.test.mts b/scripts/classmap-contracts.test.mts new file mode 100644 index 0000000..45d84a6 --- /dev/null +++ b/scripts/classmap-contracts.test.mts @@ -0,0 +1,51 @@ +// Checks classmap roles after the CSS-map rewrite used during staging. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; + +import { ROOT } from "./lib.ts"; + +const load = (file: string) => JSON.parse(readFileSync(path.join(ROOT, "1030000", file), "utf8")); + +function assertUniqueHooks(expected: Record): void { + const overlay: Record = load("css-map.json"); + for (const [source, target] of Object.entries(expected)) { + assert.equal(overlay[source], target); + assert.equal(Object.values(overlay).filter((v) => v === target).length, 1, target); + } +} + +test("Spotify 1.3.0 topbar buttons keep the native hitbox after staging", () => { + const overlay: Record = load("css-map.json"); + const wrapper: string = load("classmap.json").main.topbar.right.button_t.wrapper; + const staged = wrapper.split(" ").map((token) => overlay[token] ?? token); + assert.ok( + staged.includes("main-topBar-buddyFeed"), + "The toolbar wrapper must retain the native button hitbox, not just a generic Encore class that exists in the DOM.", + ); + assert.ok(!staged.some((token) => token.includes("--condensed-")), "Mapped classes must not strip the icon-only button padding."); +}); + +test("Spotify 1.3.0 restores the seek bar and time label hooks", () => { + // Roles checked against the stock CSS and live playback controls. + assertUniqueHooks({ + Ox6fJ7l6WeB1wD0MqHXb: "playback-bar", + LppHcR524PsiPr4sz5IV: "playback-bar__progress-time-elapsed", + TcCS0BhwDXw_s2HufffR: "main-playbackBarRemainingTime-container", + }); +}); + +test("the settings toggle hides the native input behind its indicator", () => { + assert.equal(load("css-map.json").utdiMuyxdKvPowN1CIBs, "x-toggle-input"); +}); + +test("Spotify 1.3.0 restores the three layout container hooks", () => { + // Verified against stock grid-area declarations and live child controls. + assertUniqueHooks({ + bQetA_KiP9n0DQVcGa7m: "Root__globalNav", + PIP22o58Crv8RXY4wB2o: "Root__nav-bar", + itzKVxWhS4n59fp_KIVc: "Root__now-playing-bar", + }); +}); diff --git a/scripts/lib.ts b/scripts/lib.ts new file mode 100644 index 0000000..4bfc273 --- /dev/null +++ b/scripts/lib.ts @@ -0,0 +1,62 @@ +import { createHash } from "node:crypto"; +import { readdirSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +export const ROOT = path.resolve(import.meta.dirname, ".."); +export const KEY_DIR = /^\d{7}$/; + +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; +export type Classmap = { [key: string]: Classmap | string }; + +function sortKeys(value: Json): Json { + if (Array.isArray(value)) return value.map(sortKeys); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sortKeys(value[key])]), + ); + } + return value; +} + +/** Canonical form: 2-space indent, sorted keys, trailing newline. */ +export function renderJson(value: unknown): string { + return `${JSON.stringify(sortKeys(value as Json), null, 2)}\n`; +} + +export function writeText(file: string, text: string): void { + writeFileSync(file, text, "utf8"); +} + +export function sha256(data: string | Buffer): string { + return createHash("sha256").update(data).digest("hex"); +} + +export function versionToKey(version: string): string { + const match = /^(\d+)\.(\d+)\.(\d+)(?:\..*)?$/.exec(version); + if (!match) throw new Error(`need major.minor.patch, got ${JSON.stringify(version)}`); + const [major, minor, patch] = match.slice(1).map(Number); + return `${major}${String(minor).padStart(2, "0")}${String(patch).padStart(4, "0")}`; +} + +export function leafValues(node: Classmap | string, parts: string[] = []): Map { + const values = new Map(); + if (typeof node === "string") { + values.set(parts.join("."), node); + return values; + } + if (!node || typeof node !== "object" || Array.isArray(node)) { + throw new Error(`invalid classmap leaf at ${parts.join(".")}`); + } + for (const [key, value] of Object.entries(node)) { + for (const [leaf, cls] of leafValues(value, [...parts, key])) values.set(leaf, cls); + } + return values; +} + +export function keyDirs(root: string): string[] { + return readdirSync(root) + .filter((name) => KEY_DIR.test(name) && statSync(path.join(root, name)).isDirectory()) + .sort(); +} diff --git a/scripts/publish-key.test.mts b/scripts/publish-key.test.mts new file mode 100644 index 0000000..381aaa3 --- /dev/null +++ b/scripts/publish-key.test.mts @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, test } from "node:test"; + +import { writeIndex } from "./build-index.ts"; +import { renderJson, sha256 } from "./lib.ts"; +import { type PublishOptions, publishAndIndex, publishRelease } from "./publish-key.ts"; + +const classmap = { + main: { + playbar: { indicator: "indicatorHashCC" }, + topbar: { retired: "retiredHashBB", wrapper: "topbarHashAA" }, + }, +}; +let root: string; +let source: string; +let staticReport: Record; +let cdpReport: Record; + +beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), "classmaps-")); + source = path.join(root, "1020094"); + mkdirSync(source); + writeFileSync(path.join(source, "classmap.json"), renderJson(classmap)); + writeFileSync(path.join(source, "css-map.json"), renderJson({ customHash: "semantic-name" })); + writeSourceMeta({}); + writeFileSync(path.join(source, "VERIFICATION.md"), "# Spotify 1.2.94.583 (1020094)\n"); + const digest = sha256(readFileSync(path.join(source, "classmap.json"))); + staticReport = { + target: { spotify_version: "1.2.96.518", css_sha256: "a".repeat(64), classmap_sha256: digest }, + summary: { needs_manual_check: 1, missing_in_css: 2 }, + rows: [ + { path: "main.topbar.wrapper", class: "topbarHashAA", in_target_css: true, verdict: "needs_manual_check" }, + { path: "main.topbar.retired", class: "retiredHashBB", in_target_css: false, verdict: "missing_in_css" }, + { path: "main.playbar.indicator", class: "indicatorHashCC", in_target_css: false, verdict: "missing_in_css" }, + ], + }; + cdpReport = { + cdp: { browser: { "User-Agent": "Spotify/1.2.96.518 Chrome/146" } }, + classmap: { sha256: digest }, + mode: "both", + deep: true, + navigation: { attempted: 8, succeeded: 8, failed: [] }, + summary: { total: 3, hits: 1, hitRate: 0.3333 }, + rows: [ + { path: "main.topbar.wrapper", hash: "topbarHashAA", hit: true }, + { path: "main.topbar.retired", hash: "retiredHashBB", hit: false }, + { path: "main.playbar.indicator", hash: "indicatorHashCC", hit: false }, + ], + }; +}); + +afterEach(() => rmSync(root, { recursive: true, force: true })); + +function writeSourceMeta(changes: Record): void { + const meta = { + schema_version: 2, + spotify_version: "1.2.94.583", + classmap_key: "1020094", + status: "verified", + generated: "2026-07-21", + source: { method: "derived", key: null }, + stale_leaves: ["main.topbar.retired"], + unverified_leaves: [], + required_paths: { "main.playbar.indicator": "verified", "main.topbar.wrapper": "verified" }, + ...changes, + }; + writeFileSync(path.join(source, "META.json"), renderJson(meta)); +} + +function options(changes: Partial = {}): PublishOptions { + return { + root, + inheritFrom: "1020094", + spotifyVersion: "1.2.96.518", + staticReport, + cdpReport, + generated: "2026-08-12", + minHitRate: 0.25, + ...changes, + }; +} + +const readMeta = (target: string) => JSON.parse(readFileSync(path.join(target, "META.json"), "utf8")); +const readVerification = (target: string) => readFileSync(path.join(target, "VERIFICATION.md"), "utf8"); +const stagingDirs = () => readdirSync(root).filter((name) => name.startsWith(".1020096-")); + +function setHit(leaf: string, hit: boolean, summary: Record): void { + cdpReport.rows.find((row: { path: string }) => row.path === leaf).hit = hit; + Object.assign(cdpReport.summary, summary); +} + +function bindReportsTo(file: string): void { + const digest = sha256(readFileSync(file)); + staticReport.target.classmap_sha256 = digest; + cdpReport.classmap.sha256 = digest; +} + +test("promotes an unchanged map and records new stale paths", () => { + const target = publishRelease(options()); + assert.equal(target, path.join(root, "1020096")); + assert.deepEqual(readFileSync(path.join(target, "classmap.json")), readFileSync(path.join(source, "classmap.json"))); + assert.deepEqual(JSON.parse(readFileSync(path.join(target, "css-map.json"), "utf8")), { customHash: "semantic-name" }); + assert.ok(!readFileSync(path.join(target, "META.json")).includes("\r")); + const meta = readMeta(target); + assert.equal(meta.status, "verified"); + assert.deepEqual(meta.source, { method: "inherited", key: "1020094" }); + assert.deepEqual(meta.stale_leaves, ["main.topbar.retired"]); + assert.deepEqual(meta.unverified_leaves, ["main.playbar.indicator"]); + assert.ok(readVerification(target).includes("| static_present | 1 |")); + assert.ok(readVerification(target).includes("| verified_cdp | 1 |")); + assert.equal(meta.required_paths["main.topbar.wrapper"], "verified_cdp"); + assert.equal(meta.required_paths["main.playbar.indicator"], "unverified"); +}); + +test("the generated index uses LF and hashes the published bytes", () => { + writeIndex(root); + const bytes = readFileSync(path.join(root, "index.json")); + assert.ok(!bytes.includes("\r")); + const entry = JSON.parse(bytes.toString()).keys["1020094"]; + for (const field of ["classmap", "cssMapOverlay", "meta"]) { + assert.equal(entry[field].sha256, sha256(readFileSync(path.join(source, entry[field].file)))); + } +}); + +test("refuses a report from another Spotify version", () => { + cdpReport.cdp.browser["User-Agent"] = "Spotify/1.2.95.453 Chrome/146"; + assert.throws(() => publishRelease(options()), /does not match Spotify 1\.2\.96\.518/); +}); + +test("refuses a Spotify version that only has the requested prefix", () => { + cdpReport.cdp.browser["User-Agent"] = "Spotify/1.2.96.5189 Chrome/146"; + assert.throws(() => publishRelease(options()), /does not match Spotify 1\.2\.96\.518/); +}); + +test("refuses source metadata that does not match the source key", () => { + writeSourceMeta({ classmap_key: "1020092" }); + assert.throws(() => publishRelease(options()), /source metadata key/); +}); + +test("a live hit overrides a CSS-only missing result", () => { + setHit("main.playbar.indicator", true, { hits: 2, hitRate: 0.6667 }); + const target = publishRelease(options()); + const meta = readMeta(target); + assert.deepEqual(meta.stale_leaves, ["main.topbar.retired"]); + assert.deepEqual(meta.unverified_leaves, []); + assert.equal(meta.required_paths["main.playbar.indicator"], "verified_cdp"); + assert.ok(readVerification(target).includes("| live_only | 1 |")); + assert.ok(readVerification(target).includes("| unresolved_missing | 1 |")); +}); + +test("refuses a live report below the required hit rate", () => { + setHit("main.topbar.wrapper", false, { hits: 0, hitRate: 0 }); + assert.throws(() => publishRelease(options()), /below required 0\.2500/); +}); + +test("refuses a summary that disagrees with rows", () => { + cdpReport.rows[0].hit = false; + assert.throws(() => publishRelease(options()), /summary does not match rows/); +}); + +test("refuses non-finite rates and bypass thresholds", () => { + cdpReport.summary.hitRate = Number.NaN; + assert.throws(() => publishRelease(options()), /finite/); + cdpReport.summary.hitRate = 0.3333; + assert.throws(() => publishRelease(options({ minHitRate: 0 })), /at least 0\.25/); +}); + +test("refuses a shallow CDP run", () => { + cdpReport.deep = false; + assert.throws(() => publishRelease(options()), /deep CDP/); +}); + +test("refuses a missing mode or failed deep navigation", () => { + delete cdpReport.mode; + assert.throws(() => publishRelease(options()), /mode 'both'/); + cdpReport.mode = "both"; + cdpReport.navigation = { attempted: 8, succeeded: 0, failed: Array.from({ length: 8 }, (_, i) => `step-${i}`) }; + assert.throws(() => publishRelease(options()), /deep navigation coverage/); +}); + +test("refuses an inconsistent navigation ledger", () => { + cdpReport.navigation = { attempted: 8, succeeded: 9, failed: [] }; + assert.throws(() => publishRelease(options()), /navigation ledger/); + cdpReport.navigation = { attempted: 8, succeeded: 7, failed: [] }; + assert.throws(() => publishRelease(options()), /navigation ledger/); +}); + +test("refuses a static report that disagrees with its rows or build", () => { + staticReport.summary = { missing_in_css: 0, needs_manual_check: 3 }; + assert.throws(() => publishRelease(options()), /static summary does not match rows/); + staticReport.summary = { needs_manual_check: 1, missing_in_css: 2 }; + staticReport.target.spotify_version = "1.2.95.453"; + assert.throws(() => publishRelease(options()), /static report does not match/); +}); + +test("refuses a report leaf or digest mismatch", () => { + cdpReport.rows[0].hash = "anotherHashZZ"; + assert.throws(() => publishRelease(options()), /does not match classmap/); + cdpReport.rows[0].hash = "topbarHashAA"; + cdpReport.classmap.sha256 = "b".repeat(64); + assert.throws(() => publishRelease(options()), /classmap digest/); +}); + +test("live verification recovers an inherited stale path", () => { + setHit("main.topbar.retired", true, { hits: 2, hitRate: 0.6667 }); + assert.ok(!readMeta(publishRelease(options())).stale_leaves.includes("main.topbar.retired")); +}); + +test("a source-unverified path stays unverified without a live hit", () => { + writeSourceMeta({ + required_paths: { "main.playbar.indicator": "verified", "main.topbar.wrapper": "unverified" }, + unverified_leaves: ["main.topbar.wrapper"], + }); + setHit("main.topbar.wrapper", false, {}); + setHit("main.playbar.indicator", true, {}); + const meta = readMeta(publishRelease(options())); + assert.equal(meta.required_paths["main.topbar.wrapper"], "unverified"); + assert.deepEqual(meta.unverified_leaves, ["main.topbar.wrapper"]); +}); + +test("a live hit clears a source-unverified path", () => { + writeSourceMeta({ + required_paths: { "main.playbar.indicator": "verified", "main.topbar.wrapper": "unverified" }, + unverified_leaves: ["main.topbar.wrapper"], + }); + const meta = readMeta(publishRelease(options())); + assert.equal(meta.required_paths["main.topbar.wrapper"], "verified_cdp"); + assert.deepEqual(meta.unverified_leaves, ["main.playbar.indicator"]); +}); + +test("refuses a release that keeps a required path stale, leaving no staging files", () => { + writeSourceMeta({ + required_paths: { + "main.playbar.indicator": "verified", + "main.topbar.retired": "verified", + "main.topbar.wrapper": "verified", + }, + }); + assert.throws(() => publishRelease(options()), /required path main\.topbar\.retired is stale/); + assert.ok(!existsSync(path.join(root, "1020096"))); + assert.deepEqual(stagingDirs(), []); +}); + +test("an index failure rolls back the key and the index", () => { + writeFileSync(path.join(root, "index.json"), '{"old": true}\n'); + mkdirSync(path.join(root, "1020090")); + writeFileSync(path.join(root, "1020090", "classmap.json"), "{}\n"); + writeFileSync(path.join(root, "1020090", "META.json"), "not json"); + assert.throws(() => publishAndIndex(options()), SyntaxError); + assert.ok(!existsSync(path.join(root, "1020096"))); + assert.equal(readFileSync(path.join(root, "index.json"), "utf8"), '{"old": true}\n'); +}); + +test("refuses to overwrite an existing target", () => { + mkdirSync(path.join(root, "1020096")); + assert.throws(() => publishRelease(options()), /1020096 already exists/); +}); + +test("publishes a derived key from a candidate map", () => { + const candidate = path.join(root, "candidate.json"); + writeFileSync(candidate, JSON.stringify(classmap, null, "\t")); + const overlay = path.join(root, "overlay.json"); + writeFileSync(overlay, '{"topbarHashAA":"Root__globalNav"}'); + bindReportsTo(candidate); + + const target = publishRelease( + options({ + inheritFrom: undefined, + classmapPath: candidate, + overlayPath: overlay, + derivedFrom: "1020094", + stale: ["main.topbar.retired"], + notes: ["The retired leaf has no rendered instance."], + }), + ); + + assert.equal(readFileSync(path.join(target, "classmap.json"), "utf8"), renderJson(classmap)); + assert.equal(readFileSync(path.join(target, "css-map.json"), "utf8"), renderJson({ topbarHashAA: "Root__globalNav" })); + const meta = readMeta(target); + assert.deepEqual(meta.source, { method: "derived", key: "1020094" }); + assert.deepEqual(meta.stale_leaves, ["main.topbar.retired"]); + assert.deepEqual(meta.unverified_leaves, ["main.playbar.indicator"]); + assert.deepEqual(meta.required_paths, { + "main.playbar.indicator": "unverified", + "main.topbar.wrapper": "verified_cdp", + }); + assert.ok(readVerification(target).includes("- Classmap derived from 1020094.")); + assert.ok(readVerification(target).includes("- The retired leaf has no rendered instance.")); +}); + +test("refuses a derived candidate that stores Spicetify names", () => { + const candidate = path.join(root, "candidate.json"); + writeFileSync(candidate, renderJson(classmap)); + const overlay = path.join(root, "overlay.json"); + writeFileSync(overlay, renderJson({ someHash: "topbarHashAA" })); + bindReportsTo(candidate); + assert.throws( + () => + publishRelease( + options({ + inheritFrom: undefined, + classmapPath: candidate, + overlayPath: overlay, + derivedFrom: "1020094", + stale: ["main.topbar.retired"], + }), + ), + /stores the Spicetify name topbarHashAA/, + ); + assert.ok(!existsSync(path.join(root, "1020096"))); +}); + +test("refuses stale paths for an inherited key", () => { + assert.throws( + () => publishRelease(options({ stale: ["main.topbar.wrapper"] })), + /takes its map, overlay and stale paths/, + ); +}); diff --git a/scripts/publish-key.ts b/scripts/publish-key.ts new file mode 100644 index 0000000..b1f964b --- /dev/null +++ b/scripts/publish-key.ts @@ -0,0 +1,398 @@ +// Publishes a classmap key from the CLI's static and deep CDP verification reports. +// +// An inherited key copies an older key's map and overlay byte-for-byte: +// +// node scripts/publish-key.ts --inherit-from 1020094 \ +// --spotify-version 1.2.96.518 \ +// --static-report static.json --cdp-report cdp.json +// +// A derived key publishes a new candidate map: +// +// node scripts/publish-key.ts --classmap candidate.json --css-map overlay.json \ +// --derived-from 1030001 --spotify-version 1.3.2.100 \ +// --static-report static.json --cdp-report cdp.json \ +// --stale main.topbar.right.upgrade_button.wrapper \ +// --note "The upgrade button has no rendered instance on a premium account." +// +// Both reports must be bound to the published map and Spotify build. The +// command writes classmap.json, css-map.json, META.json and VERIFICATION.md, +// validates the new key, and rebuilds index.json, rolling everything back if a +// step fails. + +import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { parseArgs } from "node:util"; + +import { writeIndex } from "./build-index.ts"; +import { type Classmap, KEY_DIR, keyDirs, leafValues, renderJson, ROOT, sha256, versionToKey, writeText } from "./lib.ts"; +import { keyErrors } from "./validate-classmaps.ts"; + +export const MIN_HIT_RATE = 0.25; +const SHA256_RE = /^[0-9a-f]{64}$/; + +type Report = Record; +type Meta = { + classmap_key?: string; + required_paths?: Record; + spotify_version?: string; + stale_leaves?: string[]; + status?: string; + unverified_leaves?: string[]; +}; + +export type PublishOptions = { + root: string; + spotifyVersion: string; + staticReport: Report; + cdpReport: Report; + generated: string; + minHitRate: number; + inheritFrom?: string; + classmapPath?: string; + overlayPath?: string; + derivedFrom?: string; + requiredFrom?: string; + stale?: string[]; + notes?: string[]; +}; + +function reportRows(report: Report, name: string, expected: Map, field: string): Map { + const rows = new Map(); + for (const row of report.rows ?? []) if (row.path) rows.set(row.path, row); + const missing = [...expected.keys()].filter((p) => !rows.has(p)).sort(); + const extra = [...rows.keys()].filter((p) => !expected.has(p)).sort(); + if (missing.length || extra.length) { + throw new Error( + `${name} paths do not match classmap; missing=${JSON.stringify(missing)}, extra=${JSON.stringify(extra)}`, + ); + } + const mismatched = [...expected].filter(([p, v]) => rows.get(p)?.[field] !== v).map(([p]) => p); + if (mismatched.length) throw new Error(`${name} does not match classmap at ${JSON.stringify(mismatched.sort())}`); + return rows; +} + +function checkSource(sourceKey: string, targetKey: string, meta: Meta, inherited: boolean): void { + if (!KEY_DIR.test(sourceKey)) throw new Error("classmap keys must contain exactly seven digits"); + if (meta.status !== "verified") throw new Error(`source ${sourceKey} is not verified`); + if (meta.classmap_key !== sourceKey) throw new Error(`source metadata key does not match ${sourceKey}`); + if (versionToKey(String(meta.spotify_version ?? "")) !== sourceKey) { + throw new Error(`source metadata version does not match ${sourceKey}`); + } + if (Number(targetKey) <= Number(sourceKey)) throw new Error(`${targetKey} is not newer than ${sourceKey}`); + if (inherited && sourceKey.slice(0, 3) !== targetKey.slice(0, 3)) { + throw new Error(`${targetKey} is not a newer patch in the ${sourceKey.slice(0, 3)} family`); + } +} + +/** Returns the paths missing from the target CSS, the live hits, and the hit rate. */ +function checkReports( + spotifyVersion: string, + classmap: Classmap, + digest: string, + staticReport: Report, + cdpReport: Report, + minHitRate: number, +): { missing: Set; live: Set; hitRate: number } { + if (staticReport.target?.classmap_sha256 !== digest) { + throw new Error("static report classmap digest does not match the published map"); + } + if (cdpReport.classmap?.sha256 !== digest) throw new Error("CDP report classmap digest does not match the published map"); + + const userAgent: string = cdpReport.cdp?.browser?.["User-Agent"] ?? ""; + const version = /(?:^|\s)Spotify\/([0-9.]+)(?=\s|$)/.exec(userAgent); + if (!version || version[1] !== spotifyVersion) { + throw new Error(`CDP report does not match Spotify ${spotifyVersion}: ${JSON.stringify(userAgent)}`); + } + if (cdpReport.deep !== true) throw new Error("publication requires a deep CDP verification report"); + if (cdpReport.mode !== "both") throw new Error("publication requires CDP mode 'both'"); + const { attempted, succeeded, failed } = cdpReport.navigation ?? {}; + if ( + !Number.isInteger(attempted) || + !Number.isInteger(succeeded) || + !Array.isArray(failed) || + attempted < 0 || + succeeded < 0 || + succeeded > attempted || + failed.length !== attempted - succeeded + ) { + throw new Error("CDP navigation ledger is inconsistent"); + } + if (attempted < 8 || succeeded < 4) throw new Error("publication requires meaningful deep navigation coverage"); + if (!Number.isFinite(minHitRate) || minHitRate < MIN_HIT_RATE || minHitRate > 1) { + throw new Error(`minimum hit rate must be finite and at least ${MIN_HIT_RATE}`); + } + + if (staticReport.target?.spotify_version !== spotifyVersion) { + throw new Error(`static report does not match Spotify ${spotifyVersion}`); + } + if (!SHA256_RE.test(String(staticReport.target?.css_sha256 ?? ""))) { + throw new Error("static report has no valid target CSS digest"); + } + + const values = leafValues(classmap); + const staticRows = reportRows(staticReport, "static report", values, "class"); + const cdpRows = reportRows(cdpReport, "CDP report", values, "hash"); + const live = new Set([...cdpRows].filter(([, row]) => row.hit === true).map(([p]) => p)); + const total = cdpRows.size; + const hitRate = total ? live.size / total : 0; + const summary = cdpReport.summary ?? {}; + if (typeof summary.hitRate !== "number" || !Number.isFinite(summary.hitRate)) { + throw new Error("CDP summary hit rate must be finite"); + } + if (summary.total !== total || summary.hits !== live.size || Math.abs(summary.hitRate - round4(hitRate)) > 0.00005) { + throw new Error("CDP summary does not match rows"); + } + if (hitRate < minHitRate) { + throw new Error(`CDP hit rate ${hitRate.toFixed(4)} is below required ${minHitRate.toFixed(4)}`); + } + + const missing = new Set([...staticRows].filter(([, row]) => !row.in_target_css).map(([p]) => p)); + const staticSummary: Record = {}; + for (const row of staticRows.values()) { + const verdict = String(row.verdict); + staticSummary[verdict] = (staticSummary[verdict] ?? 0) + 1; + } + if (renderJson(staticReport.summary ?? null) !== renderJson(staticSummary)) { + throw new Error("static summary does not match rows"); + } + return { missing, live, hitRate: round4(hitRate) }; +} + +function round4(n: number): number { + return Math.round(n * 10000) / 10000; +} + +function requiredStatuses(required: Record, unverified: Set, live: Set) { + const statuses: Record = {}; + for (const leaf of Object.keys(required)) { + statuses[leaf] = live.has(leaf) ? "verified_cdp" : unverified.has(leaf) ? "unverified" : "verified_static"; + } + return statuses; +} + +function verificationMarkdown(o: { + targetKey: string; + spotifyVersion: string; + method: string; + sourceKey: string | null; + leaves: number; + missing: Set; + live: Set; + navigation: { attempted: number; succeeded: number }; + hitRate: number; + overlayEntries: number; + notes: string[]; +}): string { + const liveOnly = [...o.missing].filter((p) => o.live.has(p)).length; + const staticPresent = o.leaves - o.missing.size; + const rows: [string, number][] = [ + ["leaves", o.leaves], + ["static_present", staticPresent], + ["verified_cdp", o.live.size], + ["live_only", liveOnly], + ["unresolved_missing", o.missing.size - liveOnly], + ["cdp_hit_rate", o.hitRate], + ["overlay_entries", o.overlayEntries], + ]; + const inherited = o.method === "inherited"; + const step = inherited ? `inherit(${o.sourceKey} -> ${o.targetKey})` : `derive(${o.sourceKey ?? "stock CSS"} -> ${o.targetKey})`; + const origin = inherited + ? `- Classmap inherited byte-for-byte from ${o.sourceKey}; no migration guesses were accepted.` + : o.sourceKey + ? `- Classmap derived from ${o.sourceKey}.` + : "- Classmap derived from the stock CSS."; + return [ + `# Spotify ${o.spotifyVersion} (${o.targetKey})`, + "", + `Pipeline: ${step} + static target-CSS verification + CDP e2e (deep).`, + "", + "## Notes", + "", + origin, + `- Static verification found ${staticPresent}/${o.leaves} paths in the target CSS.`, + `- CSS-only misses observed live, which remain verified: ${liveOnly}.`, + `- Deep CDP verification observed ${o.live.size}/${o.leaves} paths with ` + + `${o.navigation.succeeded}/${o.navigation.attempted} successful navigation steps.`, + "- Unresolved new misses remain usable but are marked unverified; stale paths stay blocked.", + ...o.notes.map((note) => `- ${note}`), + "", + "## Statistics at publication", + "", + "| Field | Value |", + "| --- | --- |", + ...rows.map(([field, value]) => `| ${field} | ${value} |`), + "", + ].join("\n"); +} + +function readMeta(root: string, key: string): Meta { + return JSON.parse(readFileSync(path.join(root, key, "META.json"), "utf8")); +} + +function newestKeyWithMeta(root: string, below: string): string | undefined { + return keyDirs(root) + .filter((key) => key < below && existsSync(path.join(root, key, "META.json"))) + .at(-1); +} + +export function publishRelease(o: PublishOptions): string { + if ((o.inheritFrom === undefined) === (o.classmapPath === undefined)) { + throw new Error("pass exactly one of --inherit-from or --classmap"); + } + if (o.inheritFrom !== undefined && (o.overlayPath || o.derivedFrom || o.stale?.length)) { + throw new Error("an inherited key takes its map, overlay and stale paths from its source"); + } + + const targetKey = versionToKey(o.spotifyVersion); + const targetDir = path.join(o.root, targetKey); + if (existsSync(targetDir)) throw new Error(`${targetKey} already exists`); + + const sourceKey = o.inheritFrom ?? o.derivedFrom ?? null; + const sourceMeta: Meta = sourceKey ? readMeta(o.root, sourceKey) : {}; + if (sourceKey) checkSource(sourceKey, targetKey, sourceMeta, o.inheritFrom !== undefined); + + let classmapBytes: Buffer; + let overlay: Record | undefined; + if (o.inheritFrom !== undefined) { + classmapBytes = readFileSync(path.join(o.root, o.inheritFrom, "classmap.json")); + const sourceOverlay = path.join(o.root, o.inheritFrom, "css-map.json"); + if (existsSync(sourceOverlay)) overlay = JSON.parse(readFileSync(sourceOverlay, "utf8")); + } else { + classmapBytes = readFileSync(o.classmapPath as string); + if (o.overlayPath) overlay = JSON.parse(readFileSync(o.overlayPath, "utf8")); + } + const classmap: Classmap = JSON.parse(classmapBytes.toString("utf8")); + + const { missing, live, hitRate } = checkReports( + o.spotifyVersion, + classmap, + sha256(classmapBytes), + o.staticReport, + o.cdpReport, + o.minHitRate, + ); + + let stale: string[]; + let doubted: Set; + let required: Record; + if (o.inheritFrom !== undefined) { + stale = (sourceMeta.stale_leaves ?? []).filter((p) => !live.has(p)).sort(); + doubted = new Set([...missing, ...(sourceMeta.unverified_leaves ?? [])]); + required = sourceMeta.required_paths ?? {}; + } else { + stale = [...new Set(o.stale ?? [])].sort(); + doubted = new Set(missing); + const requiredKey = o.requiredFrom ?? o.derivedFrom ?? newestKeyWithMeta(o.root, targetKey); + required = requiredKey ? (readMeta(o.root, requiredKey).required_paths ?? {}) : {}; + } + const unverified = new Set([...doubted].filter((p) => !live.has(p) && !stale.includes(p))); + + const method = o.inheritFrom !== undefined ? "inherited" : "derived"; + const meta = { + schema_version: 2, + classmap_key: targetKey, + spotify_version: o.spotifyVersion, + status: "verified", + generated: o.generated, + source: { method, key: sourceKey }, + required_paths: requiredStatuses(required, unverified, live), + stale_leaves: stale, + unverified_leaves: [...unverified].sort(), + }; + const verification = verificationMarkdown({ + targetKey, + spotifyVersion: o.spotifyVersion, + method, + sourceKey, + leaves: leafValues(classmap).size, + missing, + live, + navigation: o.cdpReport.navigation, + hitRate, + overlayEntries: overlay ? Object.keys(overlay).length : 0, + notes: o.notes ?? [], + }); + + const staging = mkdtempSync(path.join(o.root, `.${targetKey}-`)); + try { + if (o.inheritFrom !== undefined) writeFileSync(path.join(staging, "classmap.json"), classmapBytes); + else writeText(path.join(staging, "classmap.json"), renderJson(classmap)); + if (overlay) writeText(path.join(staging, "css-map.json"), renderJson(overlay)); + writeText(path.join(staging, "META.json"), renderJson(meta)); + writeText(path.join(staging, "VERIFICATION.md"), verification); + const errors = keyErrors(staging, targetKey, o.root); + if (errors.length) throw new Error(`published key fails validation: ${errors.join("; ")}`); + renameSync(staging, targetDir); + } catch (e) { + rmSync(staging, { recursive: true, force: true }); + throw e; + } + return targetDir; +} + +export function publishAndIndex(o: PublishOptions): string { + const indexPath = path.join(o.root, "index.json"); + const previous = existsSync(indexPath) ? readFileSync(indexPath) : undefined; + const target = publishRelease(o); + try { + writeIndex(o.root); + } catch (e) { + rmSync(target, { recursive: true, force: true }); + if (previous === undefined) rmSync(indexPath, { force: true }); + else writeFileSync(indexPath, previous); + throw e; + } + return target; +} + +function main(): number { + const { values } = parseArgs({ + options: { + "inherit-from": { type: "string" }, + classmap: { type: "string" }, + "css-map": { type: "string" }, + "derived-from": { type: "string" }, + "required-paths-from": { type: "string" }, + stale: { type: "string", multiple: true, default: [] }, + note: { type: "string", multiple: true, default: [] }, + "spotify-version": { type: "string" }, + "static-report": { type: "string" }, + "cdp-report": { type: "string" }, + root: { type: "string", default: ROOT }, + generated: { type: "string", default: new Date().toISOString().slice(0, 10) }, + "min-hit-rate": { type: "string", default: String(MIN_HIT_RATE) }, + help: { type: "boolean", short: "h" }, + }, + }); + if (values.help) { + console.log(readFileSync(import.meta.filename, "utf8").split("\n\n")[0].replace(/^\/\/ ?/gm, "")); + return 0; + } + try { + for (const flag of ["spotify-version", "static-report", "cdp-report"] as const) { + if (!values[flag]) throw new Error(`--${flag} is required`); + } + const target = publishAndIndex({ + root: values.root, + spotifyVersion: values["spotify-version"] as string, + staticReport: JSON.parse(readFileSync(values["static-report"] as string, "utf8")), + cdpReport: JSON.parse(readFileSync(values["cdp-report"] as string, "utf8")), + generated: values.generated, + minHitRate: Number(values["min-hit-rate"]), + inheritFrom: values["inherit-from"], + classmapPath: values.classmap, + overlayPath: values["css-map"], + derivedFrom: values["derived-from"], + requiredFrom: values["required-paths-from"], + stale: values.stale, + notes: values.note, + }); + console.log(`published ${path.basename(target)}: ${target}`); + return 0; + } catch (e) { + console.error(`error: ${(e as Error).message}`); + return 1; + } +} + +if (import.meta.main) process.exitCode = main(); diff --git a/scripts/publish_key.py b/scripts/publish_key.py deleted file mode 100644 index 9f90ff1..0000000 --- a/scripts/publish_key.py +++ /dev/null @@ -1,409 +0,0 @@ -#!/usr/bin/env python3 -"""Publish a classmap key from the CLI's static and deep CDP verification reports. - -An inherited key copies an older key's map and overlay byte-for-byte: - - python3 scripts/publish_key.py --inherit-from 1020094 \\ - --spotify-version 1.2.96.518 \\ - --static-report static.json --cdp-report cdp.json - -A derived key publishes a new candidate map: - - python3 scripts/publish_key.py --classmap candidate.json --css-map overlay.json \\ - --derived-from 1030001 --spotify-version 1.3.2.100 \\ - --static-report static.json --cdp-report cdp.json \\ - --stale main.topbar.right.upgrade_button.wrapper \\ - --note "The upgrade button has no rendered instance on a premium account." - -Both reports must be bound to the published map and Spotify build. The command -writes classmap.json, css-map.json, META.json and VERIFICATION.md, validates the -new key, and rebuilds index.json, rolling everything back if a step fails. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import math -import re -import shutil -import subprocess -import sys -import tempfile -from datetime import date -from pathlib import Path - -from validate_classmaps import KEY_DIR, key_errors, leaf_values, render_json, version_to_key - -ROOT = Path(__file__).resolve().parent.parent -MIN_HIT_RATE = 0.25 -SHA256_RE = re.compile(r"[0-9a-f]{64}") - - -def sha256(data: bytes) -> str: - return hashlib.sha256(data).hexdigest() - - -def write_text(path: Path, text: str) -> None: - path.write_bytes(text.encode("utf-8")) - - -def report_rows( - report: dict, - name: str, - expected_values: dict[str, str], - value_field: str, -) -> dict[str, dict]: - rows = {row.get("path"): row for row in report.get("rows", []) if row.get("path")} - missing = expected_values.keys() - rows.keys() - extra = rows.keys() - expected_values.keys() - if missing or extra: - raise ValueError( - f"{name} paths do not match classmap; missing={sorted(missing)}, extra={sorted(extra)}" - ) - mismatched = [ - path for path, value in expected_values.items() if rows[path].get(value_field) != value - ] - if mismatched: - raise ValueError(f"{name} does not match classmap at {sorted(mismatched)}") - return rows - - -def check_source(source_key: str, target_key: str, source_meta: dict, inherited: bool) -> None: - if not KEY_DIR.fullmatch(source_key): - raise ValueError("classmap keys must contain exactly seven digits") - if source_meta.get("status") != "verified": - raise ValueError(f"source {source_key} is not verified") - if source_meta.get("classmap_key") != source_key: - raise ValueError(f"source metadata key does not match {source_key}") - if version_to_key(str(source_meta.get("spotify_version", ""))) != source_key: - raise ValueError(f"source metadata version does not match {source_key}") - if int(target_key) <= int(source_key): - raise ValueError(f"{target_key} is not newer than {source_key}") - if inherited and source_key[:3] != target_key[:3]: - raise ValueError(f"{target_key} is not a newer patch in the {source_key[:3]} family") - - -def check_reports( - spotify_version: str, - classmap: dict, - classmap_digest: str, - static_report: dict, - cdp_report: dict, - min_hit_rate: float, -) -> tuple[set[str], set[str], float]: - """Returns the paths missing from the target CSS, the live hits, and the hit rate.""" - if static_report.get("target", {}).get("classmap_sha256") != classmap_digest: - raise ValueError("static report classmap digest does not match the published map") - if cdp_report.get("classmap", {}).get("sha256") != classmap_digest: - raise ValueError("CDP report classmap digest does not match the published map") - - user_agent = cdp_report.get("cdp", {}).get("browser", {}).get("User-Agent", "") - version_match = re.search(r"(?:^|\s)Spotify/([0-9.]+)(?=\s|$)", user_agent) - if not version_match or version_match.group(1) != spotify_version: - raise ValueError(f"CDP report does not match Spotify {spotify_version}: {user_agent!r}") - if cdp_report.get("deep") is not True: - raise ValueError("publication requires a deep CDP verification report") - if cdp_report.get("mode") != "both": - raise ValueError("publication requires CDP mode 'both'") - navigation = cdp_report.get("navigation", {}) - attempted = navigation.get("attempted") - succeeded = navigation.get("succeeded") - failed = navigation.get("failed") - if ( - not isinstance(attempted, int) - or not isinstance(succeeded, int) - or not isinstance(failed, list) - or attempted < 0 - or succeeded < 0 - or succeeded > attempted - or len(failed) != attempted - succeeded - ): - raise ValueError("CDP navigation ledger is inconsistent") - if attempted < 8 or succeeded < 4: - raise ValueError("publication requires meaningful deep navigation coverage") - if not math.isfinite(min_hit_rate) or not MIN_HIT_RATE <= min_hit_rate <= 1: - raise ValueError(f"minimum hit rate must be finite and at least {MIN_HIT_RATE}") - - static_target = static_report.get("target", {}) - if static_target.get("spotify_version") != spotify_version: - raise ValueError(f"static report does not match Spotify {spotify_version}") - if not SHA256_RE.fullmatch(str(static_target.get("css_sha256", ""))): - raise ValueError("static report has no valid target CSS digest") - - values = leaf_values(classmap) - static_rows = report_rows(static_report, "static report", values, "class") - cdp_rows = report_rows(cdp_report, "CDP report", values, "hash") - live_hits = {path for path, row in cdp_rows.items() if row.get("hit") is True} - total = len(cdp_rows) - hits = len(live_hits) - hit_rate = hits / total if total else 0.0 - summary = cdp_report.get("summary", {}) - reported_rate = summary.get("hitRate") - if not isinstance(reported_rate, (int, float)) or not math.isfinite(reported_rate): - raise ValueError("CDP summary hit rate must be finite") - if ( - summary.get("total") != total - or summary.get("hits") != hits - or abs(float(reported_rate) - round(hit_rate, 4)) > 0.00005 - ): - raise ValueError("CDP summary does not match rows") - if hit_rate < min_hit_rate: - raise ValueError(f"CDP hit rate {hit_rate:.4f} is below required {min_hit_rate:.4f}") - - missing_in_target = {path for path, row in static_rows.items() if not row.get("in_target_css")} - static_summary: dict[str, int] = {} - for row in static_rows.values(): - verdict = str(row.get("verdict")) - static_summary[verdict] = static_summary.get(verdict, 0) + 1 - if static_report.get("summary") != static_summary: - raise ValueError("static summary does not match rows") - return missing_in_target, live_hits, round(hit_rate, 4) - - -def required_statuses( - required: dict, unverified: set[str], live_hits: set[str] -) -> dict[str, str]: - statuses = {} - for path in required: - if path in live_hits: - statuses[path] = "verified_cdp" - elif path in unverified: - statuses[path] = "unverified" - else: - statuses[path] = "verified_static" - return statuses - - -def verification_markdown( - target_key: str, - spotify_version: str, - method: str, - source_key: str | None, - paths: set[str], - missing_in_target: set[str], - live_hits: set[str], - cdp_report: dict, - cdp_hit_rate: float, - overlay_entries: int, - notes: list[str], -) -> str: - live_only = missing_in_target & live_hits - navigation = cdp_report["navigation"] - rows = { - "leaves": len(paths), - "static_present": len(paths - missing_in_target), - "verified_cdp": len(live_hits), - "live_only": len(live_only), - "unresolved_missing": len(missing_in_target - live_hits), - "cdp_hit_rate": cdp_hit_rate, - "overlay_entries": overlay_entries, - } - if method == "inherited": - step = f"inherit({source_key} -> {target_key})" - origin = [f"- Classmap inherited byte-for-byte from {source_key}; no migration guesses were accepted."] - else: - step = f"derive({source_key or 'stock CSS'} -> {target_key})" - origin = [f"- Classmap derived from {source_key}." if source_key else "- Classmap derived from the stock CSS."] - lines = [ - f"# Spotify {spotify_version} ({target_key})", - "", - f"Pipeline: {step} + static target-CSS verification + CDP e2e (deep).", - "", - "## Notes", - "", - *origin, - f"- Static verification found {rows['static_present']}/{len(paths)} paths in the target CSS.", - f"- CSS-only misses observed live, which remain verified: {len(live_only)}.", - ( - f"- Deep CDP verification observed {len(live_hits)}/{len(paths)} paths with " - f"{navigation['succeeded']}/{navigation['attempted']} successful navigation steps." - ), - "- Unresolved new misses remain usable but are marked unverified; stale paths stay blocked.", - *(f"- {note}" for note in notes), - "", - "## Statistics at publication", - "", - "| Field | Value |", - "| --- | --- |", - *(f"| {field} | {value} |" for field, value in rows.items()), - ] - return "\n".join(lines) + "\n" - - -def newest_verified_key(root: Path, below: str) -> str | None: - keys = sorted( - p.name - for p in root.iterdir() - if p.is_dir() and KEY_DIR.fullmatch(p.name) and p.name < below and (p / "META.json").is_file() - ) - return keys[-1] if keys else None - - -def publish_release( - *, - root: Path, - spotify_version: str, - static_report: dict, - cdp_report: dict, - generated: str, - min_hit_rate: float, - inherit_from: str | None = None, - classmap_path: Path | None = None, - overlay_path: Path | None = None, - derived_from: str | None = None, - required_from: str | None = None, - stale: tuple[str, ...] = (), - notes: tuple[str, ...] = (), -) -> Path: - if (inherit_from is None) == (classmap_path is None): - raise ValueError("pass exactly one of --inherit-from or --classmap") - if inherit_from is not None and (overlay_path or derived_from or stale): - raise ValueError("an inherited key takes its map, overlay and stale paths from its source") - - target_key = version_to_key(spotify_version) - target_dir = root / target_key - if target_dir.exists(): - raise FileExistsError(f"{target_key} already exists") - - source_key = inherit_from or derived_from - source_meta: dict = {} - if source_key is not None: - source_meta = json.loads((root / source_key / "META.json").read_text(encoding="utf-8")) - check_source(source_key, target_key, source_meta, inherited=inherit_from is not None) - - if inherit_from is not None: - classmap_bytes = (root / inherit_from / "classmap.json").read_bytes() - source_overlay = root / inherit_from / "css-map.json" - overlay = json.loads(source_overlay.read_text(encoding="utf-8")) if source_overlay.is_file() else None - else: - classmap_bytes = classmap_path.read_bytes() - overlay = json.loads(overlay_path.read_text(encoding="utf-8")) if overlay_path else None - classmap = json.loads(classmap_bytes) - - missing_in_target, live_hits, cdp_hit_rate = check_reports( - spotify_version, classmap, sha256(classmap_bytes), static_report, cdp_report, min_hit_rate - ) - - if inherit_from is not None: - stale_paths = sorted(set(source_meta.get("stale_leaves") or []) - live_hits) - doubted = missing_in_target | set(source_meta.get("unverified_leaves") or []) - required = source_meta.get("required_paths") or {} - else: - stale_paths = sorted(set(stale)) - doubted = set(missing_in_target) - required_key = required_from or derived_from or newest_verified_key(root, target_key) - required = {} - if required_key is not None: - required_meta = json.loads((root / required_key / "META.json").read_text(encoding="utf-8")) - required = required_meta.get("required_paths") or {} - unverified = doubted - live_hits - set(stale_paths) - - meta = { - "schema_version": 2, - "classmap_key": target_key, - "spotify_version": spotify_version, - "status": "verified", - "generated": generated, - "source": {"method": "inherited" if inherit_from else "derived", "key": source_key}, - "required_paths": required_statuses(required, unverified, live_hits), - "stale_leaves": stale_paths, - "unverified_leaves": sorted(unverified), - } - verification = verification_markdown( - target_key, - spotify_version, - meta["source"]["method"], - source_key, - set(leaf_values(classmap)), - missing_in_target, - live_hits, - cdp_report, - cdp_hit_rate, - len(overlay or {}), - list(notes), - ) - - staging_dir = Path(tempfile.mkdtemp(prefix=f".{target_key}-", dir=root)) - try: - if inherit_from is not None: - (staging_dir / "classmap.json").write_bytes(classmap_bytes) - else: - write_text(staging_dir / "classmap.json", render_json(classmap)) - if overlay is not None: - write_text(staging_dir / "css-map.json", render_json(overlay)) - write_text(staging_dir / "META.json", render_json(meta)) - write_text(staging_dir / "VERIFICATION.md", verification) - errors = key_errors(staging_dir, key=target_key, root=root) - if errors: - raise ValueError("published key fails validation: " + "; ".join(errors)) - staging_dir.rename(target_dir) - except Exception: - shutil.rmtree(staging_dir, ignore_errors=True) - raise - return target_dir - - -def publish_and_index(**kwargs) -> Path: - root = kwargs["root"] - index_path = root / "index.json" - previous_index = index_path.read_bytes() if index_path.is_file() else None - target = publish_release(**kwargs) - try: - subprocess.run([sys.executable, str(root / "scripts/build_index.py")], check=True) - except Exception: - shutil.rmtree(target, ignore_errors=True) - if previous_index is None: - index_path.unlink(missing_ok=True) - else: - index_path.write_bytes(previous_index) - raise - return target - - -def main() -> int: - parser = argparse.ArgumentParser( - description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter - ) - origin = parser.add_mutually_exclusive_group(required=True) - origin.add_argument("--inherit-from", metavar="KEY", help="copy this key's map and overlay") - origin.add_argument("--classmap", type=Path, help="candidate map for a derived key") - parser.add_argument("--css-map", type=Path, help="overlay for a derived key") - parser.add_argument("--derived-from", metavar="KEY", help="key the candidate was migrated from") - parser.add_argument( - "--required-paths-from", - metavar="KEY", - help="key whose required_paths to track (default: --derived-from, else the newest key)", - ) - parser.add_argument("--stale", action="append", default=[], metavar="PATH", help="mark a leaf stale") - parser.add_argument("--note", action="append", default=[], help="add a line to VERIFICATION.md") - parser.add_argument("--spotify-version", required=True) - parser.add_argument("--static-report", type=Path, required=True) - parser.add_argument("--cdp-report", type=Path, required=True) - parser.add_argument("--root", type=Path, default=ROOT) - parser.add_argument("--generated", default=date.today().isoformat()) - parser.add_argument("--min-hit-rate", type=float, default=MIN_HIT_RATE) - args = parser.parse_args() - - target = publish_and_index( - root=args.root, - spotify_version=args.spotify_version, - static_report=json.loads(args.static_report.read_text(encoding="utf-8")), - cdp_report=json.loads(args.cdp_report.read_text(encoding="utf-8")), - generated=args.generated, - min_hit_rate=args.min_hit_rate, - inherit_from=args.inherit_from, - classmap_path=args.classmap, - overlay_path=args.css_map, - derived_from=args.derived_from, - required_from=args.required_paths_from, - stale=tuple(args.stale), - notes=tuple(args.note), - ) - print(f"published {target.name}: {target}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/test_classmap_contracts.py b/scripts/test_classmap_contracts.py deleted file mode 100644 index 653db18..0000000 --- a/scripts/test_classmap_contracts.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Check classmap roles after the CSS-map rewrite used during staging.""" - -import json -import unittest - -from build_index import ROOT, classmap_file - - -class TopbarButtonContractTests(unittest.TestCase): - def test_spotify_130_topbar_buttons_keep_the_native_hitbox_after_staging(self): - key = ROOT / "1030000" - mapping = json.loads(classmap_file(key).read_text()) - overlay = json.loads((key / "css-map.json").read_text()) - wrapper = mapping["main"]["topbar"]["right"]["button_t"]["wrapper"] - staged = [overlay.get(token, token) for token in wrapper.split()] - self.assertIn( - "main-topBar-buddyFeed", staged, - "The toolbar wrapper must retain the native button hitbox, " - "not just a generic Encore class that exists in the DOM.", - ) - self.assertFalse( - any("--condensed-" in token for token in staged), - "Mapped classes must not strip the icon-only button padding.", - ) - - -class PlaybackBarContractTests(unittest.TestCase): - def test_spotify_130_restores_seek_bar_and_time_label_hooks(self): - overlay = json.loads((ROOT / "1030000" / "css-map.json").read_text()) - # Roles checked against the stock CSS and live playback controls. - expected = { - "Ox6fJ7l6WeB1wD0MqHXb": "playback-bar", - "LppHcR524PsiPr4sz5IV": "playback-bar__progress-time-elapsed", - "TcCS0BhwDXw_s2HufffR": "main-playbackBarRemainingTime-container", - } - for source, target in expected.items(): - with self.subTest(control=target): - self.assertEqual(overlay.get(source), target) - self.assertEqual(list(overlay.values()).count(target), 1) - - -class RootContainerContractTests(unittest.TestCase): - def test_settings_toggle_hides_the_native_input_behind_its_indicator(self): - overlay = json.loads((ROOT / "1030000" / "css-map.json").read_text()) - self.assertEqual(overlay.get("utdiMuyxdKvPowN1CIBs"), "x-toggle-input") - - def test_spotify_130_restores_the_three_layout_container_hooks(self): - overlay = json.loads((ROOT / "1030000" / "css-map.json").read_text()) - # Verified against stock grid-area declarations and live child controls. - expected = { - "bQetA_KiP9n0DQVcGa7m": "Root__globalNav", - "PIP22o58Crv8RXY4wB2o": "Root__nav-bar", - "itzKVxWhS4n59fp_KIVc": "Root__now-playing-bar", - } - for source, target in expected.items(): - with self.subTest(container=target): - self.assertEqual(overlay.get(source), target) - self.assertEqual(list(overlay.values()).count(target), 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_publish_key.py b/scripts/test_publish_key.py deleted file mode 100644 index e216550..0000000 --- a/scripts/test_publish_key.py +++ /dev/null @@ -1,598 +0,0 @@ -from __future__ import annotations - -import json -import hashlib -import sys -import subprocess -import tempfile -import unittest -from pathlib import Path -from unittest import mock - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -import publish_key as publish # noqa: E402 -import build_index # noqa: E402 -from validate_classmaps import render_json # noqa: E402 - - -class PublishKeyTests(unittest.TestCase): - def setUp(self): - self.temp_dir = tempfile.TemporaryDirectory() - self.root = Path(self.temp_dir.name) - self.source = self.root / "1020094" - self.source.mkdir() - self.classmap = { - "main": { - "topbar": {"wrapper": "topbarHashAA", "retired": "retiredHashBB"}, - "playbar": {"indicator": "indicatorHashCC"}, - } - } - (self.source / "classmap.json").write_text(render_json(self.classmap)) - (self.source / "css-map.json").write_text(render_json({"customHash": "semantic-name"})) - (self.source / "META.json").write_text( - render_json( - { - "schema_version": 2, - "spotify_version": "1.2.94.583", - "classmap_key": "1020094", - "status": "verified", - "generated": "2026-07-21", - "source": {"method": "derived", "key": None}, - "stale_leaves": ["main.topbar.retired"], - "unverified_leaves": [], - "required_paths": { - "main.topbar.wrapper": "verified", - "main.playbar.indicator": "verified", - }, - } - ) - ) - (self.source / "VERIFICATION.md").write_text("# Spotify 1.2.94.583 (1020094)\n") - self.static_report = { - "target": { - "spotify_version": "1.2.96.518", - "css_sha256": "a" * 64, - "classmap_sha256": hashlib.sha256( - (self.source / "classmap.json").read_bytes() - ).hexdigest(), - }, - "summary": {"needs_manual_check": 1, "missing_in_css": 2}, - "rows": [ - { - "path": "main.topbar.wrapper", - "class": "topbarHashAA", - "in_target_css": True, - "verdict": "needs_manual_check", - }, - { - "path": "main.topbar.retired", - "class": "retiredHashBB", - "in_target_css": False, - "verdict": "missing_in_css", - }, - { - "path": "main.playbar.indicator", - "class": "indicatorHashCC", - "in_target_css": False, - "verdict": "missing_in_css", - }, - ], - } - self.cdp_report = { - "cdp": {"browser": {"User-Agent": "Spotify/1.2.96.518 Chrome/146"}}, - "classmap": { - "sha256": self.static_report["target"]["classmap_sha256"], - }, - "mode": "both", - "deep": True, - "navigation": { - "attempted": 8, - "succeeded": 8, - "failed": [], - }, - "summary": {"total": 3, "hits": 1, "hitRate": 0.3333}, - "rows": [ - {"path": "main.topbar.wrapper", "hash": "topbarHashAA", "hit": True}, - {"path": "main.topbar.retired", "hash": "retiredHashBB", "hit": False}, - {"path": "main.playbar.indicator", "hash": "indicatorHashCC", "hit": False}, - ], - } - - def tearDown(self): - self.temp_dir.cleanup() - - def test_promotes_an_unchanged_map_and_records_new_stale_paths(self): - target = publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - self.assertEqual(target, self.root / "1020096") - self.assertEqual( - (target / "classmap.json").read_bytes(), (self.source / "classmap.json").read_bytes() - ) - self.assertEqual( - json.loads((target / "css-map.json").read_text()), - {"customHash": "semantic-name"}, - ) - meta = json.loads((target / "META.json").read_text()) - self.assertNotIn(b"\r", (target / "META.json").read_bytes()) - self.assertEqual(meta["status"], "verified") - self.assertEqual(meta["source"], {"method": "inherited", "key": "1020094"}) - self.assertEqual( - meta["stale_leaves"], - ["main.topbar.retired"], - ) - self.assertEqual(meta["unverified_leaves"], ["main.playbar.indicator"]) - verification = (target / "VERIFICATION.md").read_text() - self.assertIn("| static_present | 1 |", verification) - self.assertIn("| verified_cdp | 1 |", verification) - self.assertEqual(meta["required_paths"]["main.topbar.wrapper"], "verified_cdp") - self.assertEqual(meta["required_paths"]["main.playbar.indicator"], "unverified") - - def test_generated_index_uses_lf_and_hashes_published_bytes(self): - index_path = self.root / "index.json" - with mock.patch.object(build_index, "ROOT", self.root): - with mock.patch.object(build_index, "INDEX", index_path): - with mock.patch.object(sys, "argv", ["build_index.py"]): - self.assertEqual(build_index.main(), 0) - index_bytes = index_path.read_bytes() - self.assertNotIn(b"\r", index_bytes) - entry = json.loads(index_bytes)["keys"]["1020094"] - for field in ("classmap", "cssMapOverlay", "meta"): - self.assertEqual( - entry[field]["sha256"], - hashlib.sha256((self.source / entry[field]["file"]).read_bytes()).hexdigest(), - ) - - def test_refuses_a_report_from_another_spotify_version(self): - self.cdp_report["cdp"]["browser"]["User-Agent"] = "Spotify/1.2.95.453 Chrome/146" - - with self.assertRaisesRegex(ValueError, "does not match Spotify 1.2.96.518"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - def test_refuses_a_spotify_version_that_only_has_the_requested_prefix(self): - self.cdp_report["cdp"]["browser"]["User-Agent"] = "Spotify/1.2.96.5189 Chrome/146" - - with self.assertRaisesRegex(ValueError, "does not match Spotify 1.2.96.518"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - def test_refuses_source_metadata_that_does_not_match_the_source_key(self): - source_meta = json.loads((self.source / "META.json").read_text()) - source_meta["classmap_key"] = "1020092" - (self.source / "META.json").write_text(json.dumps(source_meta) + "\n") - - with self.assertRaisesRegex(ValueError, "source metadata key"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - def test_live_hit_overrides_a_css_only_missing_result(self): - for row in self.cdp_report["rows"]: - if row["path"] == "main.playbar.indicator": - row["hit"] = True - self.cdp_report["summary"].update({"hits": 2, "hitRate": 0.6667}) - - target = publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - meta = json.loads((target / "META.json").read_text()) - self.assertEqual(meta["stale_leaves"], ["main.topbar.retired"]) - self.assertEqual(meta["unverified_leaves"], []) - self.assertEqual(meta["required_paths"]["main.playbar.indicator"], "verified_cdp") - verification = (target / "VERIFICATION.md").read_text() - self.assertIn("| live_only | 1 |", verification) - self.assertIn("| unresolved_missing | 1 |", verification) - - def test_refuses_a_live_report_below_the_required_hit_rate(self): - self.cdp_report["rows"][0]["hit"] = False - self.cdp_report["summary"].update({"hits": 0, "hitRate": 0.0}) - - with self.assertRaisesRegex(ValueError, "below required 0.2500"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - def test_refuses_a_summary_that_disagrees_with_rows(self): - self.cdp_report["rows"][0]["hit"] = False - - with self.assertRaisesRegex(ValueError, "summary does not match rows"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - def test_refuses_non_finite_rates_and_bypass_thresholds(self): - self.cdp_report["summary"]["hitRate"] = float("nan") - with self.assertRaisesRegex(ValueError, "finite"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - self.cdp_report["summary"]["hitRate"] = 0.3333 - with self.assertRaisesRegex(ValueError, "at least 0.25"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0, - ) - - def test_refuses_shallow_or_mismatched_reports(self): - self.cdp_report["deep"] = False - with self.assertRaisesRegex(ValueError, "deep CDP"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - def test_refuses_missing_mode_or_failed_deep_navigation(self): - del self.cdp_report["mode"] - with self.assertRaisesRegex(ValueError, "mode 'both'"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - self.cdp_report["mode"] = "both" - self.cdp_report["navigation"] = { - "attempted": 8, - "succeeded": 0, - "failed": [f"step-{index}" for index in range(8)], - } - with self.assertRaisesRegex(ValueError, "deep navigation coverage"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - def test_refuses_inconsistent_navigation_ledger(self): - self.cdp_report["navigation"] = {"attempted": 8, "succeeded": 9, "failed": []} - with self.assertRaisesRegex(ValueError, "navigation ledger"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - self.cdp_report["navigation"] = {"attempted": 8, "succeeded": 7, "failed": []} - with self.assertRaisesRegex(ValueError, "navigation ledger"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - def test_refuses_static_summary_that_disagrees_with_rows(self): - self.static_report["summary"] = {"missing_in_css": 0, "needs_manual_check": 3} - with self.assertRaisesRegex(ValueError, "static summary does not match rows"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - self.cdp_report["deep"] = True - self.static_report["target"]["spotify_version"] = "1.2.95.453" - with self.assertRaisesRegex(ValueError, "static report does not match"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - def test_refuses_report_leaf_or_digest_mismatch(self): - self.cdp_report["rows"][0]["hash"] = "anotherHashZZ" - with self.assertRaisesRegex(ValueError, "does not match classmap"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - self.cdp_report["rows"][0]["hash"] = "topbarHashAA" - self.cdp_report["classmap"]["sha256"] = "b" * 64 - with self.assertRaisesRegex(ValueError, "classmap digest"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - def test_live_verification_recovers_an_inherited_stale_path(self): - for row in self.cdp_report["rows"]: - if row["path"] == "main.topbar.retired": - row["hit"] = True - self.cdp_report["summary"].update({"hits": 2, "hitRate": 0.6667}) - - target = publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - meta = json.loads((target / "META.json").read_text()) - self.assertNotIn("main.topbar.retired", meta["stale_leaves"]) - - def test_a_source_unverified_path_stays_unverified_without_a_live_hit(self): - source_meta = json.loads((self.source / "META.json").read_text()) - source_meta["required_paths"]["main.topbar.wrapper"] = "unverified" - source_meta["unverified_leaves"] = ["main.topbar.wrapper"] - (self.source / "META.json").write_text(render_json(source_meta)) - self.cdp_report["rows"][0]["hit"] = False - self.cdp_report["rows"][2]["hit"] = True - - target = publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - meta = json.loads((target / "META.json").read_text()) - self.assertEqual(meta["required_paths"]["main.topbar.wrapper"], "unverified") - self.assertEqual(meta["unverified_leaves"], ["main.topbar.wrapper"]) - - def test_a_live_hit_clears_a_source_unverified_path(self): - source_meta = json.loads((self.source / "META.json").read_text()) - source_meta["required_paths"]["main.topbar.wrapper"] = "unverified" - source_meta["unverified_leaves"] = ["main.topbar.wrapper"] - (self.source / "META.json").write_text(render_json(source_meta)) - - target = publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - meta = json.loads((target / "META.json").read_text()) - self.assertEqual(meta["required_paths"]["main.topbar.wrapper"], "verified_cdp") - self.assertEqual(meta["unverified_leaves"], ["main.playbar.indicator"]) - - def test_refuses_a_release_that_keeps_a_required_path_stale(self): - source_meta = json.loads((self.source / "META.json").read_text()) - source_meta["required_paths"]["main.topbar.retired"] = "verified" - (self.source / "META.json").write_text(render_json(source_meta)) - - with self.assertRaisesRegex(ValueError, "required path main.topbar.retired is stale"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - self.assertFalse((self.root / "1020096").exists()) - self.assertEqual(list(self.root.glob(".1020096-*")), []) - - def test_failed_copy_leaves_no_partial_target(self): - with mock.patch.object(publish, "write_text", side_effect=OSError("disk full")): - with self.assertRaisesRegex(OSError, "disk full"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - self.assertFalse((self.root / "1020096").exists()) - self.assertEqual(list(self.root.glob(".1020096-*")), []) - - def test_index_failure_rolls_back_target_and_index(self): - index = self.root / "index.json" - index.write_text('{"old": true}\n') - with mock.patch.object( - publish.subprocess, - "run", - side_effect=subprocess.CalledProcessError(1, ["build_index.py"]), - ): - with self.assertRaises(subprocess.CalledProcessError): - publish.publish_and_index( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - self.assertFalse((self.root / "1020096").exists()) - self.assertEqual(index.read_text(), '{"old": true}\n') - - def test_refuses_to_overwrite_an_existing_target(self): - (self.root / "1020096").mkdir() - - with self.assertRaisesRegex(FileExistsError, "1020096 already exists"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - def bind_reports_to(self, candidate: Path) -> None: - digest = hashlib.sha256(candidate.read_bytes()).hexdigest() - self.static_report["target"]["classmap_sha256"] = digest - self.cdp_report["classmap"]["sha256"] = digest - - def test_publishes_a_derived_key_from_a_candidate_map(self): - candidate = self.root / "candidate.json" - candidate.write_text(json.dumps(self.classmap, indent="\t")) - overlay = self.root / "overlay.json" - overlay.write_text('{"topbarHashAA":"Root__globalNav"}') - self.bind_reports_to(candidate) - - target = publish.publish_release( - root=self.root, - classmap_path=candidate, - overlay_path=overlay, - derived_from="1020094", - stale=("main.topbar.retired",), - notes=("The retired leaf has no rendered instance.",), - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - self.assertEqual((target / "classmap.json").read_text(), render_json(self.classmap)) - self.assertEqual( - (target / "css-map.json").read_text(), render_json({"topbarHashAA": "Root__globalNav"}) - ) - meta = json.loads((target / "META.json").read_text()) - self.assertEqual(meta["source"], {"method": "derived", "key": "1020094"}) - self.assertEqual(meta["stale_leaves"], ["main.topbar.retired"]) - self.assertEqual(meta["unverified_leaves"], ["main.playbar.indicator"]) - self.assertEqual( - meta["required_paths"], - {"main.topbar.wrapper": "verified_cdp", "main.playbar.indicator": "unverified"}, - ) - verification = (target / "VERIFICATION.md").read_text() - self.assertIn("- Classmap derived from 1020094.", verification) - self.assertIn("- The retired leaf has no rendered instance.", verification) - - def test_refuses_a_derived_candidate_that_stores_spicetify_names(self): - candidate = self.root / "candidate.json" - candidate.write_text(render_json(self.classmap)) - overlay = self.root / "overlay.json" - overlay.write_text(render_json({"someHash": "topbarHashAA"})) - self.bind_reports_to(candidate) - - with self.assertRaisesRegex(ValueError, "stores the Spicetify name topbarHashAA"): - publish.publish_release( - root=self.root, - classmap_path=candidate, - overlay_path=overlay, - derived_from="1020094", - stale=("main.topbar.retired",), - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - self.assertFalse((self.root / "1020096").exists()) - - def test_refuses_stale_paths_for_an_inherited_key(self): - with self.assertRaisesRegex(ValueError, "takes its map, overlay and stale paths"): - publish.publish_release( - root=self.root, - inherit_from="1020094", - stale=("main.topbar.wrapper",), - spotify_version="1.2.96.518", - static_report=self.static_report, - cdp_report=self.cdp_report, - generated="2026-08-12", - min_hit_rate=0.25, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_validate_classmaps.py b/scripts/test_validate_classmaps.py deleted file mode 100644 index 5bd1a1d..0000000 --- a/scripts/test_validate_classmaps.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import annotations - -import json -import tempfile -import unittest -from pathlib import Path - -from validate_classmaps import key_errors, render_json - - -class KeyValidationTests(unittest.TestCase): - def setUp(self): - self.temp_dir = tempfile.TemporaryDirectory() - self.root = Path(self.temp_dir.name) - self.classmap = {"main": {"topbar": {"wrapper": "topbarHashAA", "retired": "retiredHashBB"}}} - self.parent = self.write_key( - "1020094", self.classmap, self.meta("1020094", "1.2.94.583", {"method": "derived", "key": None}) - ) - self.child_meta = self.meta("1020096", "1.2.96.518", {"method": "inherited", "key": "1020094"}) - self.child = self.write_key("1020096", self.classmap, self.child_meta) - - def tearDown(self): - self.temp_dir.cleanup() - - def meta(self, key: str, version: str, source: dict) -> dict: - return { - "schema_version": 2, - "classmap_key": key, - "spotify_version": version, - "status": "verified", - "generated": "2026-08-12", - "source": source, - "required_paths": {"main.topbar.wrapper": "verified_cdp"}, - "stale_leaves": ["main.topbar.retired"], - "unverified_leaves": [], - } - - def write_key(self, key: str, classmap: dict, meta: dict) -> Path: - key_dir = self.root / key - key_dir.mkdir(exist_ok=True) - (key_dir / "classmap.json").write_text(render_json(classmap)) - (key_dir / "META.json").write_text(render_json(meta)) - (key_dir / "VERIFICATION.md").write_text(f"# {key}\n") - return key_dir - - def errors(self, key_dir: Path) -> list[str]: - return key_errors(key_dir, root=self.root) - - def test_accepts_a_consistent_inherited_release(self): - self.assertEqual(self.errors(self.parent), []) - self.assertEqual(self.errors(self.child), []) - - def test_rejects_an_inherited_map_whose_bytes_differ_from_its_source(self): - changed = {"main": {"topbar": {"wrapper": "semantic-name", "retired": "retiredHashBB"}}} - self.write_key("1020096", changed, self.child_meta) - self.assertIn( - "classmap.json: inherited from 1020094 but its bytes differ", self.errors(self.child) - ) - - def test_rejects_a_leaf_listed_as_both_stale_and_unverified(self): - self.child_meta["unverified_leaves"] = ["main.topbar.retired"] - self.write_key("1020096", self.classmap, self.child_meta) - self.assertIn("META.json: main.topbar.retired is both stale and unverified", self.errors(self.child)) - - def test_rejects_a_required_status_that_disagrees_with_the_leaf_lists(self): - self.child_meta["required_paths"]["main.topbar.wrapper"] = "unverified" - self.write_key("1020096", self.classmap, self.child_meta) - self.assertIn( - "META.json: main.topbar.wrapper is unverified but unverified_leaves disagrees", - self.errors(self.child), - ) - - def test_rejects_free_text_path_statuses(self): - self.child_meta["required_paths"]["main.topbar.wrapper"] = "verified (present in target CSS)" - self.write_key("1020096", self.classmap, self.child_meta) - self.assertTrue(any("unknown status" in e for e in self.errors(self.child))) - - def test_rejects_empty_groups_and_padded_class_strings(self): - self.write_key( - "1020096", {"main": {"panel": {}, "topbar": {"wrapper": " a b"}}}, self.child_meta - ) - errors = self.errors(self.child) - self.assertIn("classmap.json: main.panel: empty group", errors) - self.assertIn( - "classmap.json: main.topbar.wrapper: classes must be single-space separated with no padding", - errors, - ) - - def test_rejects_non_canonical_json_and_stray_classmap_files(self): - (self.child / "classmap.json").write_text(json.dumps(self.classmap, indent="\t")) - (self.child / "classmap-19f856aefd5.json").write_text("{}\n") - errors = self.errors(self.child) - self.assertIn( - "classmap.json: not canonical (2-space indent, sorted keys, trailing newline)", errors - ) - self.assertIn("classmap-19f856aefd5.json: not part of the published layout", errors) - - def test_rejects_metadata_that_names_another_build(self): - self.child_meta["spotify_version"] = "1.2.97.270" - self.write_key("1020096", self.classmap, self.child_meta) - self.assertIn("META.json: spotify_version does not belong to 1020096", self.errors(self.child)) - - def test_rejects_a_leaf_stored_as_its_spicetify_name(self): - (self.child / "css-map.json").write_text(render_json({"topbarHashAA": "Root__globalNav"})) - named = {"main": {"topbar": {"wrapper": "Root__globalNav", "retired": "main-topBar-retired"}}} - self.write_key("1020094", named, self.parent_meta()) - self.write_key("1020096", named, self.child_meta) - errors = self.errors(self.child) - self.assertIn( - "classmap.json: main.topbar.wrapper stores the Spicetify name Root__globalNav, not the stock class", - errors, - ) - self.assertIn( - "classmap.json: main.topbar.retired stores the Spicetify name main-topBar-retired, not the stock class", - errors, - ) - - def test_rejects_inheritance_across_a_minor_version(self): - meta = self.meta("1030004", "1.3.4.100", {"method": "inherited", "key": "1020094"}) - key_dir = self.write_key("1030004", self.classmap, meta) - self.assertIn( - "META.json: an inherited map must come from the same major.minor family", - self.errors(key_dir), - ) - - def test_rejects_a_compact_or_week_date(self): - for generated in ("20260812", "2026-W33-3"): - with self.subTest(generated=generated): - self.child_meta["generated"] = generated - self.write_key("1020096", self.classmap, self.child_meta) - self.assertIn("META.json: generated must be a YYYY-MM-DD date", self.errors(self.child)) - - def test_rejects_group_keys_that_would_collide_as_dotted_paths(self): - self.write_key("1020096", {"main": {"topbar.wrapper": "a", "topbar": {"wrapper": "b"}}}, self.child_meta) - self.assertIn( - "classmap.json: main: group key 'topbar.wrapper' must be non-empty and contain no dots", - self.errors(self.child), - ) - - def parent_meta(self) -> dict: - return self.meta("1020094", "1.2.94.583", {"method": "derived", "key": None}) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/validate-classmaps.test.mts b/scripts/validate-classmaps.test.mts new file mode 100644 index 0000000..3ce894b --- /dev/null +++ b/scripts/validate-classmaps.test.mts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, test } from "node:test"; + +import { renderJson } from "./lib.ts"; +import { keyErrors } from "./validate-classmaps.ts"; + +const classmap = { main: { topbar: { retired: "retiredHashBB", wrapper: "topbarHashAA" } } }; +let root: string; +let parent: string; +let child: string; +let childMeta: Record; + +function meta(key: string, version: string, source: Record) { + return { + schema_version: 2, + classmap_key: key, + spotify_version: version, + status: "verified", + generated: "2026-08-12", + source, + required_paths: { "main.topbar.wrapper": "verified_cdp" }, + stale_leaves: ["main.topbar.retired"], + unverified_leaves: [], + }; +} + +function writeKey(key: string, map: unknown, keyMeta: unknown): string { + const dir = path.join(root, key); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, "classmap.json"), renderJson(map)); + writeFileSync(path.join(dir, "META.json"), renderJson(keyMeta)); + writeFileSync(path.join(dir, "VERIFICATION.md"), `# ${key}\n`); + return dir; +} + +const parentMeta = () => meta("1020094", "1.2.94.583", { method: "derived", key: null }); +const errors = (dir: string) => keyErrors(dir, path.basename(dir), root); + +beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), "classmaps-")); + parent = writeKey("1020094", classmap, parentMeta()); + childMeta = meta("1020096", "1.2.96.518", { method: "inherited", key: "1020094" }); + child = writeKey("1020096", classmap, childMeta); +}); + +afterEach(() => rmSync(root, { recursive: true, force: true })); + +test("accepts a consistent inherited release", () => { + assert.deepEqual(errors(parent), []); + assert.deepEqual(errors(child), []); +}); + +test("rejects an inherited map whose bytes differ from its source", () => { + writeKey("1020096", { main: { topbar: { retired: "retiredHashBB", wrapper: "otherHashCC" } } }, childMeta); + assert.ok(errors(child).includes("classmap.json: inherited from 1020094 but its bytes differ")); +}); + +test("rejects a leaf listed as both stale and unverified", () => { + childMeta.unverified_leaves = ["main.topbar.retired"]; + writeKey("1020096", classmap, childMeta); + assert.ok(errors(child).includes("META.json: main.topbar.retired is both stale and unverified")); +}); + +test("rejects a required status that disagrees with the leaf lists", () => { + childMeta.required_paths = { "main.topbar.wrapper": "unverified" }; + writeKey("1020096", classmap, childMeta); + assert.ok(errors(child).includes("META.json: main.topbar.wrapper is unverified but unverified_leaves disagrees")); +}); + +test("rejects free-text path statuses", () => { + childMeta.required_paths = { "main.topbar.wrapper": "verified (present in target CSS)" }; + writeKey("1020096", classmap, childMeta); + assert.ok(errors(child).some((e) => e.includes("unknown status"))); +}); + +test("rejects empty groups and padded class strings", () => { + writeKey("1020096", { main: { panel: {}, topbar: { wrapper: " a b" } } }, childMeta); + const found = errors(child); + assert.ok(found.includes("classmap.json: main.panel: empty group")); + assert.ok( + found.includes("classmap.json: main.topbar.wrapper: classes must be single-space separated with no padding"), + ); +}); + +test("rejects non-canonical JSON and stray classmap files", () => { + writeFileSync(path.join(child, "classmap.json"), JSON.stringify(classmap, null, "\t")); + writeFileSync(path.join(child, "classmap-19f856aefd5.json"), "{}\n"); + const found = errors(child); + assert.ok(found.includes("classmap.json: not canonical (2-space indent, sorted keys, trailing newline)")); + assert.ok(found.includes("classmap-19f856aefd5.json: not part of the published layout")); +}); + +test("rejects metadata that names another build", () => { + childMeta.spotify_version = "1.2.97.270"; + writeKey("1020096", classmap, childMeta); + assert.ok(errors(child).includes("META.json: spotify_version does not belong to 1020096")); +}); + +test("rejects a leaf stored as its Spicetify name", () => { + writeFileSync(path.join(child, "css-map.json"), renderJson({ topbarHashAA: "Root__globalNav" })); + const named = { main: { topbar: { retired: "main-topBar-retired", wrapper: "Root__globalNav" } } }; + writeKey("1020094", named, parentMeta()); + writeKey("1020096", named, childMeta); + const found = errors(child); + assert.ok( + found.includes("classmap.json: main.topbar.wrapper stores the Spicetify name Root__globalNav, not the stock class"), + ); + assert.ok( + found.includes( + "classmap.json: main.topbar.retired stores the Spicetify name main-topBar-retired, not the stock class", + ), + ); +}); + +test("rejects inheritance across a minor version", () => { + const dir = writeKey("1030004", classmap, meta("1030004", "1.3.4.100", { method: "inherited", key: "1020094" })); + assert.ok(errors(dir).includes("META.json: an inherited map must come from the same major.minor family")); +}); + +test("rejects a compact, week or impossible date", () => { + for (const generated of ["20260812", "2026-W33-3", "2026-02-30"]) { + childMeta.generated = generated; + writeKey("1020096", classmap, childMeta); + assert.ok(errors(child).includes("META.json: generated must be a YYYY-MM-DD date"), generated); + } +}); + +test("rejects group keys that would collide as dotted paths", () => { + writeKey("1020096", { main: { topbar: { wrapper: "b" }, "topbar.wrapper": "a" } }, childMeta); + assert.ok( + errors(child).includes('classmap.json: main: group key "topbar.wrapper" must be non-empty and contain no dots'), + ); +}); diff --git a/scripts/validate-classmaps.ts b/scripts/validate-classmaps.ts new file mode 100644 index 0000000..d755929 --- /dev/null +++ b/scripts/validate-classmaps.ts @@ -0,0 +1,229 @@ +// Checks every key directory against the published layout: +// +// /classmap.json nested groups of class-name leaves (required) +// /css-map.json flat hash -> semantic overlay (optional) +// /META.json schema_version 2 metadata (optional for legacy keys) +// /VERIFICATION.md verification history (required beside META.json) +// +// JSON files must be in canonical form (renderJson). META claims are checked +// against the files: leaf paths exist, the leaf lists are disjoint and agree +// with required_paths, and an inherited map is byte-identical to its source. +// +// node scripts/validate-classmaps.ts + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { type Classmap, KEY_DIR, keyDirs, leafValues, renderJson, ROOT, versionToKey } from "./lib.ts"; + +const META_FIELDS = [ + "classmap_key", + "generated", + "required_paths", + "schema_version", + "source", + "spotify_version", + "stale_leaves", + "status", + "unverified_leaves", +]; +const STATUSES = ["unverified", "verified"]; +const SOURCE_METHODS = ["derived", "inherited"]; +const PATH_STATUSES = ["unverified", "verified", "verified_cdp", "verified_static", "verified_targeted"]; +const KNOWN_FILES = new Set(["classmap.json", "css-map.json", "META.json", "VERIFICATION.md"]); +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +const SPICETIFY_NAME = /^(main|x)-[A-Za-z0-9]+-/; + +type Meta = Record; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function classmapErrors(node: unknown, parts: string[] = []): string[] { + const where = parts.join(".") || ""; + if (typeof node === "string") { + const normal = node.split(/\s+/).filter(Boolean).join(" "); + return node && node === normal ? [] : [`${where}: classes must be single-space separated with no padding`]; + } + if (!isObject(node)) return [`${where}: must be a group or a class string`]; + if (Object.keys(node).length === 0) return [`${where}: empty group`]; + const errors: string[] = []; + for (const [key, value] of Object.entries(node)) { + if (!key || key.includes(".")) { + errors.push(`${where}: group key ${JSON.stringify(key)} must be non-empty and contain no dots`); + continue; + } + errors.push(...classmapErrors(value, [...parts, key])); + } + return errors; +} + +function loadCanonical(file: string, errors: string[]): unknown { + const raw = readFileSync(file, "utf8"); + let value: unknown; + try { + value = JSON.parse(raw); + } catch (e) { + errors.push(`${path.basename(file)}: ${(e as Error).message}`); + return undefined; + } + if (raw !== renderJson(value)) { + errors.push(`${path.basename(file)}: not canonical (2-space indent, sorted keys, trailing newline)`); + } + return value; +} + +function stringList(meta: Meta, field: string, leaves: Map, errors: string[]): Set { + const items = meta[field]; + if (!Array.isArray(items) || !items.every((i) => typeof i === "string")) { + errors.push(`META.json: ${field} must be a list of paths`); + return new Set(); + } + const sorted = [...new Set(items)].sort(); + if (sorted.length !== items.length || sorted.some((item, i) => item !== items[i])) { + errors.push(`META.json: ${field} must be sorted and unique`); + } + for (const leaf of items) { + if (!leaves.has(leaf)) errors.push(`META.json: ${field} names ${leaf}, which is not a classmap leaf`); + } + return new Set(items); +} + +function isValidDate(value: string): boolean { + if (!ISO_DATE.test(value)) return false; + const parsed = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +} + +function metaErrors(meta: unknown, key: string, leaves: Map, root: string, classmap: Buffer): string[] { + if (!isObject(meta)) return ["META.json: must be an object"]; + const errors: string[] = []; + const fields = Object.keys(meta).sort(); + const missing = META_FIELDS.filter((f) => !fields.includes(f)); + const extra = fields.filter((f) => !META_FIELDS.includes(f)); + if (missing.length || extra.length) { + errors.push( + `META.json: fields must be exactly ${JSON.stringify(META_FIELDS)}; ` + + `missing=${JSON.stringify(missing)}, extra=${JSON.stringify(extra)}`, + ); + } + if (meta.schema_version !== 2) errors.push("META.json: schema_version must be 2"); + if (meta.classmap_key !== key) errors.push(`META.json: classmap_key must be ${key}`); + try { + if (versionToKey(String(meta.spotify_version ?? "")) !== key) { + errors.push(`META.json: spotify_version does not belong to ${key}`); + } + } catch (e) { + errors.push(`META.json: spotify_version: ${(e as Error).message}`); + } + if (!STATUSES.includes(meta.status as string)) { + errors.push(`META.json: status must be one of ${JSON.stringify(STATUSES)}`); + } + if (!isValidDate(String(meta.generated))) errors.push("META.json: generated must be a YYYY-MM-DD date"); + + const source = meta.source; + if (!isObject(source) || Object.keys(source).sort().join() !== "key,method") { + errors.push("META.json: source must be {method, key}"); + } else if (!SOURCE_METHODS.includes(source.method as string)) { + errors.push(`META.json: source.method must be one of ${JSON.stringify(SOURCE_METHODS)}`); + } else if ( + source.key !== null && + !(typeof source.key === "string" && KEY_DIR.test(source.key) && source.key < key) + ) { + errors.push("META.json: source.key must be null or an older seven-digit key"); + } else if (source.method === "inherited") { + const parent = source.key === null ? "" : path.join(root, source.key as string, "classmap.json"); + if (!parent || !existsSync(parent)) { + errors.push("META.json: an inherited map needs an existing source.key"); + } else if ((source.key as string).slice(0, 3) !== key.slice(0, 3)) { + errors.push("META.json: an inherited map must come from the same major.minor family"); + } else if (!readFileSync(parent).equals(classmap)) { + errors.push(`classmap.json: inherited from ${source.key} but its bytes differ`); + } + } + + const stale = stringList(meta, "stale_leaves", leaves, errors); + const unverified = stringList(meta, "unverified_leaves", leaves, errors); + for (const leaf of [...stale].filter((p) => unverified.has(p)).sort()) { + errors.push(`META.json: ${leaf} is both stale and unverified`); + } + + const required = meta.required_paths; + if (!isObject(required)) { + errors.push("META.json: required_paths must be an object"); + return errors; + } + for (const [leaf, status] of Object.entries(required)) { + if (!leaves.has(leaf)) errors.push(`META.json: required path ${leaf} is not a classmap leaf`); + if (!PATH_STATUSES.includes(status as string)) { + errors.push(`META.json: required path ${leaf} has unknown status ${JSON.stringify(status)}`); + } + if (stale.has(leaf)) errors.push(`META.json: required path ${leaf} is stale`); + if ((status === "unverified") !== unverified.has(leaf)) { + errors.push(`META.json: ${leaf} is ${status} but unverified_leaves disagrees`); + } + } + return errors; +} + +export function keyErrors(keyDir: string, key = path.basename(keyDir), root = ROOT): string[] { + const errors: string[] = []; + for (const extra of readdirSync(keyDir).filter((name) => !KNOWN_FILES.has(name)).sort()) { + errors.push(`${extra}: not part of the published layout`); + } + + const classmapPath = path.join(keyDir, "classmap.json"); + if (!existsSync(classmapPath)) return [...errors, "classmap.json: missing"]; + const classmap = loadCanonical(classmapPath, errors); + if (classmap === undefined) return errors; + const treeErrors = classmapErrors(classmap); + errors.push(...treeErrors.map((e) => `classmap.json: ${e}`)); + if (treeErrors.length) return errors; + const leaves = leafValues(classmap as Classmap); + + let semantic = new Set(); + const overlayPath = path.join(keyDir, "css-map.json"); + if (existsSync(overlayPath)) { + const overlay = loadCanonical(overlayPath, errors); + if (overlay !== undefined) { + const valid = + isObject(overlay) && Object.entries(overlay).every(([k, v]) => k && typeof v === "string" && v); + if (!valid) errors.push("css-map.json: must map class names to semantic names"); + else semantic = new Set(Object.values(overlay) as string[]); + } + } + for (const [leaf, value] of leaves) { + for (const token of value.split(" ")) { + if (semantic.has(token) || SPICETIFY_NAME.test(token)) { + errors.push(`classmap.json: ${leaf} stores the Spicetify name ${token}, not the stock class`); + } + } + } + + const metaPath = path.join(keyDir, "META.json"); + if (existsSync(metaPath)) { + const meta = loadCanonical(metaPath, errors); + if (meta !== undefined) errors.push(...metaErrors(meta, key, leaves, root, readFileSync(classmapPath))); + if (!existsSync(path.join(keyDir, "VERIFICATION.md"))) { + errors.push("VERIFICATION.md: required beside META.json"); + } + } + return errors; +} + +export function validateClassmaps(root = ROOT): number { + const keys = keyDirs(root); + let failed = false; + for (const key of keys) { + for (const error of keyErrors(path.join(root, key), key, root)) { + console.error(`${key}/${error}`); + failed = true; + } + } + if (failed) return 1; + console.log(`${keys.length} classmap keys ok`); + return 0; +} + +if (import.meta.main) process.exitCode = validateClassmaps(); diff --git a/scripts/validate-expose.ts b/scripts/validate-expose.ts new file mode 100644 index 0000000..2134e73 --- /dev/null +++ b/scripts/validate-expose.ts @@ -0,0 +1,104 @@ +// Checks expose.json before it is published: the schema the CLI reads, every +// pattern compiles, and every `${N}` in a template names a group the pattern +// has. V8's regex engine is close to the Rust regex crate for the syntax these +// patterns use; the CLI is the final authority and skips a pattern it cannot +// compile, so a construct only one side accepts still surfaces at apply as a +// `did not match` warning rather than a crash. +// +// node scripts/validate-expose.ts + +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { ROOT } from "./lib.ts"; + +const TEMPLATE_REF = /\$\{(\d+)\}|\$(\d+)/g; +const BUILD = /^\d+\.\d+\.\d+$/; + +export function renderExpose(doc: unknown): string { + return `${JSON.stringify(doc, null, 2)}\n`; +} + +function groupCount(pattern: string): number { + return (new RegExp(`${pattern}|`).exec("") as RegExpExecArray).length - 1; +} + +export function exposeErrors(raw: string): string[] { + let doc: Record; + try { + doc = JSON.parse(raw); + } catch (e) { + return [`expose.json: ${(e as Error).message}`]; + } + const errors: string[] = []; + if (raw !== renderExpose(doc)) errors.push("expose.json: not canonical (2-space indent, trailing newline)"); + + const patches = Array.isArray(doc.patches) ? doc.patches : []; + if (!patches.length) errors.push("`patches` must be a non-empty list"); + + const names = new Set(); + patches.forEach((patch: Record, i: number) => { + const where = `patches[${i}]`; + if (typeof patch !== "object" || patch === null || Array.isArray(patch)) { + errors.push(`${where}: not an object`); + return; + } + let name = patch.name; + if (typeof name !== "string" || !name) { + errors.push(`${where}: missing \`name\``); + name = where; + } + if (names.has(name as string)) errors.push(`${name}: duplicate name`); + names.add(name as string); + + const { pattern, replace } = patch; + if (typeof pattern !== "string" || !pattern) { + errors.push(`${name}: missing \`pattern\``); + return; + } + if (typeof replace !== "string") { + errors.push(`${name}: missing \`replace\``); + return; + } + let groups: number; + try { + groups = groupCount(pattern); + } catch (e) { + errors.push(`${name}: pattern does not compile: ${(e as Error).message}`); + return; + } + for (const m of replace.matchAll(TEMPLATE_REF)) { + const n = Number(m[1] ?? m[2]); + if (n > groups) { + errors.push(`${name}: template references \${${n}} but the pattern has ${groups} group(s)`); + } + } + if ("once" in patch && typeof patch.once !== "boolean") errors.push(`${name}: \`once\` must be a boolean`); + if (!["warn", "quiet"].includes((patch.onMiss ?? "warn") as string)) { + errors.push(`${name}: \`onMiss\` must be \`warn\` or \`quiet\``); + } + const hits = patch.hits; + const validHits = + typeof hits === "object" && + hits !== null && + !Array.isArray(hits) && + Object.keys(hits).length > 0 && + Object.entries(hits).every(([build, n]) => BUILD.test(build) && Number.isInteger(n) && (n as number) >= 0); + if (!validHits) errors.push(`${name}: \`hits\` must map major.minor.patch builds to match counts`); + if ("note" in patch && (typeof patch.note !== "string" || !patch.note)) { + errors.push(`${name}: \`note\` must be a non-empty string when present`); + } + }); + return errors; +} + +export function validateExpose(root = ROOT): number { + const raw = readFileSync(path.join(root, "expose.json"), "utf8"); + const errors = exposeErrors(raw); + for (const e of errors) console.error(e); + if (errors.length) return 1; + console.log(`expose.json: ${JSON.parse(raw).patches.length} patches ok`); + return 0; +} + +if (import.meta.main) process.exitCode = validateExpose(); diff --git a/scripts/validate_classmaps.py b/scripts/validate_classmaps.py deleted file mode 100644 index 2c60311..0000000 --- a/scripts/validate_classmaps.py +++ /dev/null @@ -1,244 +0,0 @@ -#!/usr/bin/env python3 -"""Checks every key directory against the published layout: - - /classmap.json nested groups of class-name leaves (required) - /css-map.json flat hash -> semantic overlay (optional) - /META.json schema_version 2 metadata (optional for legacy keys) - /VERIFICATION.md verification history (required beside META.json) - -JSON files must be in canonical form (`render_json`). META claims are checked -against the files: leaf paths exist, the leaf lists are disjoint and agree with -`required_paths`, and an inherited map is byte-identical to its source. - - python3 scripts/validate_classmaps.py -""" - -from __future__ import annotations - -import json -import re -import sys -from datetime import date -from pathlib import Path - -ROOT = Path(__file__).resolve().parent.parent -KEY_DIR = re.compile(r"^\d{7}$") -META_FIELDS = { - "schema_version", - "classmap_key", - "spotify_version", - "status", - "generated", - "source", - "required_paths", - "stale_leaves", - "unverified_leaves", -} -STATUSES = {"verified", "unverified"} -SOURCE_METHODS = {"derived", "inherited"} -PATH_STATUSES = { - "verified_cdp", - "verified_targeted", - "verified_static", - "verified", - "unverified", -} -ISO_DATE = re.compile(r"\d{4}-\d{2}-\d{2}") -SPICETIFY_NAME = re.compile(r"(main|x)-[A-Za-z0-9]+-") -KNOWN_FILES = {"classmap.json", "css-map.json", "META.json", "VERIFICATION.md"} - - -def render_json(value: object) -> str: - return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False, allow_nan=False) + "\n" - - -def version_to_key(version: str) -> str: - match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:\..*)?", version) - if not match: - raise ValueError(f"need major.minor.patch, got {version!r}") - major, minor, patch = (int(part) for part in match.groups()) - return f"{major}{minor:02d}{patch:04d}" - - -def leaf_values(node: object, parts: tuple[str, ...] = ()) -> dict[str, str]: - if isinstance(node, dict): - values: dict[str, str] = {} - for key, value in node.items(): - values.update(leaf_values(value, (*parts, key))) - return values - if isinstance(node, str): - return {".".join(parts): node} - raise ValueError(f"invalid classmap leaf at {'.'.join(parts)}") - - -def classmap_errors(node: object, parts: tuple[str, ...] = ()) -> list[str]: - where = ".".join(parts) or "" - if isinstance(node, str): - if not node or node != " ".join(node.split()): - return [f"{where}: classes must be single-space separated with no padding"] - return [] - if not isinstance(node, dict): - return [f"{where}: must be a group or a class string"] - if not node: - return [f"{where}: empty group"] - errors: list[str] = [] - for key, value in node.items(): - if not key or "." in key: - errors.append(f"{where}: group key {key!r} must be non-empty and contain no dots") - continue - errors += classmap_errors(value, (*parts, key)) - return errors - - -def load_canonical(path: Path, errors: list[str]) -> object | None: - raw = path.read_bytes().decode("utf-8") - try: - value = json.loads(raw) - except json.JSONDecodeError as e: - errors.append(f"{path.name}: {e}") - return None - if raw != render_json(value): - errors.append(f"{path.name}: not canonical (2-space indent, sorted keys, trailing newline)") - return value - - -def string_list(meta: dict, field: str, leaves: dict[str, str], errors: list[str]) -> set[str]: - items = meta.get(field) - if not isinstance(items, list) or not all(isinstance(i, str) for i in items): - errors.append(f"META.json: {field} must be a list of paths") - return set() - if items != sorted(set(items)): - errors.append(f"META.json: {field} must be sorted and unique") - for path in items: - if path not in leaves: - errors.append(f"META.json: {field} names {path}, which is not a classmap leaf") - return set(items) - - -def meta_errors(meta: object, key: str, leaves: dict[str, str], root: Path, classmap: bytes) -> list[str]: - if not isinstance(meta, dict): - return ["META.json: must be an object"] - errors: list[str] = [] - fields = set(meta) - if fields != META_FIELDS: - errors.append( - f"META.json: fields must be exactly {sorted(META_FIELDS)}; " - f"missing={sorted(META_FIELDS - fields)}, extra={sorted(fields - META_FIELDS)}" - ) - if meta.get("schema_version") != 2: - errors.append("META.json: schema_version must be 2") - if meta.get("classmap_key") != key: - errors.append(f"META.json: classmap_key must be {key}") - try: - if version_to_key(str(meta.get("spotify_version", ""))) != key: - errors.append(f"META.json: spotify_version does not belong to {key}") - except ValueError as e: - errors.append(f"META.json: spotify_version: {e}") - if meta.get("status") not in STATUSES: - errors.append(f"META.json: status must be one of {sorted(STATUSES)}") - generated = str(meta.get("generated")) - try: - if not ISO_DATE.fullmatch(generated): - raise ValueError(generated) - date.fromisoformat(generated) - except ValueError: - errors.append("META.json: generated must be a YYYY-MM-DD date") - - source = meta.get("source") - if not isinstance(source, dict) or set(source) != {"method", "key"}: - errors.append("META.json: source must be {method, key}") - elif source["method"] not in SOURCE_METHODS: - errors.append(f"META.json: source.method must be one of {sorted(SOURCE_METHODS)}") - elif source["key"] is not None and not ( - isinstance(source["key"], str) and KEY_DIR.match(source["key"]) and source["key"] < key - ): - errors.append("META.json: source.key must be null or an older seven-digit key") - elif source["method"] == "inherited": - parent = root / str(source["key"]) / "classmap.json" - if source["key"] is None or not parent.is_file(): - errors.append("META.json: an inherited map needs an existing source.key") - elif source["key"][:3] != key[:3]: - errors.append("META.json: an inherited map must come from the same major.minor family") - elif parent.read_bytes() != classmap: - errors.append(f"classmap.json: inherited from {source['key']} but its bytes differ") - - stale = string_list(meta, "stale_leaves", leaves, errors) - unverified = string_list(meta, "unverified_leaves", leaves, errors) - for path in sorted(stale & unverified): - errors.append(f"META.json: {path} is both stale and unverified") - - required = meta.get("required_paths") - if not isinstance(required, dict): - errors.append("META.json: required_paths must be an object") - return errors - for path, status in required.items(): - if path not in leaves: - errors.append(f"META.json: required path {path} is not a classmap leaf") - if status not in PATH_STATUSES: - errors.append(f"META.json: required path {path} has unknown status {status!r}") - if path in stale: - errors.append(f"META.json: required path {path} is stale") - if (status == "unverified") != (path in unverified): - errors.append(f"META.json: {path} is {status} but unverified_leaves disagrees") - return errors - - -def key_errors(key_dir: Path, key: str | None = None, root: Path = ROOT) -> list[str]: - key = key or key_dir.name - errors: list[str] = [] - for extra in sorted(p.name for p in key_dir.iterdir() if p.name not in KNOWN_FILES): - errors.append(f"{extra}: not part of the published layout") - - classmap_path = key_dir / "classmap.json" - if not classmap_path.is_file(): - return errors + ["classmap.json: missing"] - classmap = load_canonical(classmap_path, errors) - if classmap is None: - return errors - tree_errors = classmap_errors(classmap) - errors += [f"classmap.json: {e}" for e in tree_errors] - if tree_errors: - return errors - leaves = leaf_values(classmap) - - semantic: set[str] = set() - overlay_path = key_dir / "css-map.json" - if overlay_path.is_file(): - overlay = load_canonical(overlay_path, errors) - if overlay is not None and not ( - isinstance(overlay, dict) - and all(isinstance(k, str) and isinstance(v, str) and k and v for k, v in overlay.items()) - ): - errors.append("css-map.json: must map class names to semantic names") - elif isinstance(overlay, dict): - semantic = set(overlay.values()) - for path, value in leaves.items(): - for token in value.split(): - if token in semantic or SPICETIFY_NAME.match(token): - errors.append(f"classmap.json: {path} stores the Spicetify name {token}, not the stock class") - - meta_path = key_dir / "META.json" - if meta_path.is_file(): - meta = load_canonical(meta_path, errors) - if meta is not None: - errors += meta_errors(meta, key, leaves, root, classmap_path.read_bytes()) - if not (key_dir / "VERIFICATION.md").is_file(): - errors.append("VERIFICATION.md: required beside META.json") - return errors - - -def main() -> int: - failed = False - keys = sorted(p for p in ROOT.iterdir() if p.is_dir() and KEY_DIR.match(p.name)) - for key_dir in keys: - for error in key_errors(key_dir): - print(f"{key_dir.name}/{error}", file=sys.stderr) - failed = True - if failed: - return 1 - print(f"{len(keys)} classmap keys ok") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/validate_expose.py b/scripts/validate_expose.py deleted file mode 100755 index e6c6cc6..0000000 --- a/scripts/validate_expose.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python3 -"""Checks expose.json before it is published: the schema the CLI reads, every -pattern compiles, and every `${N}` in a template names a group the pattern -has. Python's `re` is close to the Rust regex crate for the syntax these -patterns use; the CLI is the final authority and skips a pattern it cannot -compile, so a construct only one side accepts still surfaces at apply as a -`did not match` warning rather than a crash. - - python3 scripts/validate_expose.py -""" - -from __future__ import annotations - -import json -import re -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parent.parent -EXPOSE = ROOT / "expose.json" -TEMPLATE_REF = re.compile(r"\$\{(\d+)\}|\$(\d+)") -BUILD = re.compile(r"\d+\.\d+\.\d+") - - -def main() -> int: - try: - raw = EXPOSE.read_text(encoding="utf-8") - doc = json.loads(raw) - except (OSError, json.JSONDecodeError) as e: - print(f"expose.json: {e}", file=sys.stderr) - return 1 - - errors: list[str] = [] - if raw != json.dumps(doc, indent=2, ensure_ascii=False) + "\n": - errors.append("expose.json: not canonical (2-space indent, trailing newline)") - patches = doc.get("patches") - if not isinstance(patches, list) or not patches: - errors.append("`patches` must be a non-empty list") - patches = [] - - names: set[str] = set() - for i, patch in enumerate(patches): - where = f"patches[{i}]" - if not isinstance(patch, dict): - errors.append(f"{where}: not an object") - continue - name = patch.get("name") - if not isinstance(name, str) or not name: - errors.append(f"{where}: missing `name`") - name = where - if name in names: - errors.append(f"{name}: duplicate name") - names.add(name) - - pattern = patch.get("pattern") - replace = patch.get("replace") - if not isinstance(pattern, str) or not pattern: - errors.append(f"{name}: missing `pattern`") - continue - if not isinstance(replace, str): - errors.append(f"{name}: missing `replace`") - continue - try: - groups = re.compile(pattern).groups - except re.error as e: - errors.append(f"{name}: pattern does not compile: {e}") - continue - for m in TEMPLATE_REF.finditer(replace): - n = int(m.group(1) or m.group(2)) - if n > groups: - errors.append(f"{name}: template references ${{{n}}} but the pattern has {groups} group(s)") - if "once" in patch and not isinstance(patch["once"], bool): - errors.append(f"{name}: `once` must be a boolean") - if patch.get("onMiss", "warn") not in ("warn", "quiet"): - errors.append(f"{name}: `onMiss` must be `warn` or `quiet`") - hits = patch.get("hits") - if not isinstance(hits, dict) or not hits or not all( - BUILD.fullmatch(build) and isinstance(n, int) and not isinstance(n, bool) and n >= 0 - for build, n in hits.items() - ): - errors.append(f"{name}: `hits` must map major.minor.patch builds to match counts") - if "note" in patch and (not isinstance(patch["note"], str) or not patch["note"]): - errors.append(f"{name}: `note` must be a non-empty string when present") - - for e in errors: - print(e, file=sys.stderr) - if errors: - return 1 - print(f"expose.json: {len(patches)} patches ok") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bc93f56 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["scripts"] +} From dccc72f8589760dd156a74713f597f8c5e37f926 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:39:20 +0200 Subject: [PATCH 17/20] docs: use the pnpm commands for publishing and checks --- AGENTS.md | 12 +++++------- README.md | 23 +++++++++++------------ 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e37ad4..4b16001 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,11 +4,11 @@ The README is the reference for the key layout, the META schema, and the publishing steps. This file holds what the README and the scripts don't make obvious. -- Generate every published file. `scripts/publish_key.py` writes a new key's - `META.json` and `VERIFICATION.md`, `scripts/check.py --fix` formats JSON and - rebuilds `index.json`, and `scripts/check.py` is done when it prints - `all checks passed`. To change an existing key, edit its `classmap.json`, - `css-map.json` or `META.json` fields, then run `check.py --fix`. +- Generate every published file. `pnpm publish-key` writes a new key's + `META.json` and `VERIFICATION.md`, `pnpm fix` formats JSON and rebuilds + `index.json`, and `pnpm check` is done when it prints `all checks passed`. + To change an existing key, edit its `classmap.json`, `css-map.json` or + `META.json` fields, then run `pnpm fix`. - Leaves hold the class exactly as the stock Spotify archive ships it. A class observed on a patched client has already been renamed by the css-map, so translate it back to the one stock hash the effective css-map (the CLI's @@ -20,8 +20,6 @@ obvious. - Record run evidence in `VERIFICATION.md`, and keep reports, archives and scratch files outside the key directories. The validator rejects any file outside the published layout. -- The scripts use only the Python 3.9 standard library, so the system - `python3` on macOS runs them. - Changing `expose.json` changes its published digest. The CLI's release workflow compares its embedded copy against that digest, so open a matching spicetify/cli pull request that copies the file. diff --git a/README.md b/README.md index 9c0f53c..5b8746b 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,7 @@ but only the stock form can be checked against a stock archive. Keys Every JSON file uses two-space indentation and ends with a newline. `classmap.json`, `css-map.json`, `META.json`, and `index.json` also sort their -keys. `python3 scripts/validate_classmaps.py` enforces the layout, and CI runs -it on every pull request. +keys. `pnpm check` enforces the layout, and CI runs it on every pull request. ## META.json @@ -68,7 +67,7 @@ match the published bytes, but the classes the CLI stages are unchanged. ## Publishing a key You never write `META.json`, `VERIFICATION.md`, or `index.json` by hand. -`scripts/publish_key.py` generates them from two CLI verification reports, +`pnpm publish-key` generates them from two CLI verification reports, formats the map and overlay, validates the new key, and rebuilds the index. It refuses mismatched versions, maps, leaf values, shallow CDP runs, inconsistent summaries, low hit rates, and existing targets. If any step @@ -100,7 +99,7 @@ To publish a key, follow these steps: from this repository: ```sh - python3 scripts/publish_key.py --inherit-from 1020094 \ + pnpm publish-key --inherit-from 1020094 \ --spotify-version 1.2.96.518 \ --static-report /tmp/1020096-static.json \ --cdp-report /tmp/1020096-cdp.json @@ -111,7 +110,7 @@ To publish a key, follow these steps: to be wrong, and `--note TEXT` for anything a reviewer needs to know. ```sh - python3 scripts/publish_key.py --classmap /tmp/1030002.json \ + pnpm publish-key --classmap /tmp/1030002.json \ --css-map /tmp/1030002-css-map.json --derived-from 1030001 \ --spotify-version 1.3.2.100 \ --static-report /tmp/1030002-static.json \ @@ -120,7 +119,7 @@ To publish a key, follow these steps: --note "The upgrade button has no rendered instance on a premium account." ``` -3. Run `python3 scripts/check.py` and open a pull request. +3. Run `pnpm check` and open a pull request. The publisher marks a path `unverified` when it's missing from the target CSS and wasn't observed live. An inherited key also keeps its source's @@ -130,11 +129,11 @@ until a live hit clears them. A derived key tracks the `required_paths` of ## Checks -`python3 scripts/check.py` runs everything CI runs: the index check, the key -validator, the exposure patch validator, and the unit tests. The scripts need -only Python 3.9 or later and the standard library. Run -`python3 scripts/check.py --fix` to rewrite every JSON file in canonical form -and rebuild `index.json` before checking. +`pnpm check` runs everything CI runs: the type check, the index check, the key +validator, the exposure patch validator, and the unit tests. The scripts are +TypeScript that Node 24 or later runs directly, and `pnpm install` only adds +the type checker. Run `pnpm fix` to rewrite every JSON file in canonical form +and rebuild `index.json`. ## Exposure patches @@ -172,7 +171,7 @@ Each patch has these fields: Record the hit counts in the patch's `hits`, then: ```sh -python3 scripts/check.py --fix +pnpm fix ``` CI runs the same checks. `onMiss: quiet` is for a patch that is expected to From 0c41bb01cc765ae44e49d013aeaab260b4b63c89 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 01:02:33 +0200 Subject: [PATCH 18/20] fix(scripts): match Python's JSON ordering and reject malformed input renderJson sorts keys by code point, integer-like keys included. Files must be valid UTF-8, a null onMiss is rejected as the CLI would, Rust-style named groups and leading flags compile, each check reports its own failure, and pnpm fix FILE formats a single file. The scripts require Node 24.2 for import.meta.main. --- .gitignore | 1 + package.json | 4 +- pnpm-lock.yaml | 18 ++++----- scripts/check.ts | 36 +++++++++++++----- scripts/lib.ts | 63 ++++++++++++++++++++++++++------ scripts/validate-expose.test.mts | 24 ++++++++++++ scripts/validate-expose.ts | 24 ++++++++---- 7 files changed, 132 insertions(+), 38 deletions(-) create mode 100644 scripts/validate-expose.test.mts diff --git a/.gitignore b/.gitignore index c2658d7..05d3a73 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +/.[0-9][0-9][0-9][0-9][0-9][0-9][0-9]-*/ diff --git a/package.json b/package.json index 9a2ae9f..0adf712 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "engines": { - "node": ">=24" + "node": ">=24.2.0" }, "packageManager": "pnpm@10.0.0", "scripts": { @@ -13,7 +13,7 @@ "publish-key": "node scripts/publish-key.ts" }, "devDependencies": { - "@types/node": "^26.1.1", + "@types/node": "^24.19.0", "typescript": "^7.0.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1c5e43..c1ea4f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,16 +9,16 @@ importers: .: devDependencies: '@types/node': - specifier: ^26.1.1 - version: 26.6.3 + specifier: ^24.19.0 + version: 24.19.0 typescript: specifier: ^7.0.2 version: 7.0.2 packages: - '@types/node@26.6.3': - resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} + '@types/node@24.19.0': + resolution: {integrity: sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==} '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} @@ -145,14 +145,14 @@ packages: engines: {node: '>=16.20.0'} hasBin: true - undici-types@8.9.0: - resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} snapshots: - '@types/node@26.6.3': + '@types/node@24.19.0': dependencies: - undici-types: 8.9.0 + undici-types: 7.24.6 '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -237,4 +237,4 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - undici-types@8.9.0: {} + undici-types@7.24.6: {} diff --git a/scripts/check.ts b/scripts/check.ts index 2255790..185054e 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -1,15 +1,17 @@ // Runs every data check CI runs, then the unit tests. With --fix, first -// rewrites the JSON files in canonical form and rebuilds index.json. +// rewrites the JSON files in canonical form and rebuilds index.json. With +// --fix and file arguments, only formats those files. // // node scripts/check.ts // node scripts/check.ts --fix +// node scripts/check.ts --fix /tmp/candidate.json import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { indexIsCurrent, writeIndex } from "./build-index.ts"; -import { keyDirs, renderJson, ROOT, writeText } from "./lib.ts"; +import { keyDirs, readUtf8, renderJson, ROOT, writeText } from "./lib.ts"; import { validateClassmaps } from "./validate-classmaps.ts"; import { renderExpose, validateExpose } from "./validate-expose.ts"; @@ -26,14 +28,30 @@ function fix(): void { } function main(): number { - if (process.argv.includes("--fix")) fix(); - const failed: string[] = []; - if (!indexIsCurrent()) { - console.error("index.json is out of date; run node scripts/check.ts --fix"); - failed.push("index"); + const args = process.argv.slice(2); + const files = args.filter((arg) => arg !== "--fix"); + if (args.includes("--fix") && files.length) { + for (const file of files) writeText(file, renderJson(JSON.parse(readUtf8(file)))); + console.log(`formatted ${files.join(", ")}`); + return 0; } - if (validateClassmaps() !== 0) failed.push("classmaps"); - if (validateExpose() !== 0) failed.push("expose"); + if (args.includes("--fix")) fix(); + const failed: string[] = []; + const step = (name: string, run: () => boolean) => { + try { + if (!run()) failed.push(name); + } catch (e) { + console.error(`${name}: ${(e as Error).message}`); + failed.push(name); + } + }; + step("index", () => { + if (indexIsCurrent()) return true; + console.error("index.json is out of date; run pnpm fix"); + return false; + }); + step("classmaps", () => validateClassmaps() === 0); + step("expose", () => validateExpose() === 0); const tests = spawnSync(process.execPath, ["--test", "scripts/*.test.mts"], { cwd: ROOT, stdio: "inherit" }); if (tests.status !== 0) failed.push("tests"); if (failed.length) { diff --git a/scripts/lib.ts b/scripts/lib.ts index 4bfc273..75d31b3 100644 --- a/scripts/lib.ts +++ b/scripts/lib.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { readdirSync, statSync, writeFileSync } from "node:fs"; +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; export const ROOT = path.resolve(import.meta.dirname, ".."); @@ -8,21 +8,62 @@ export const KEY_DIR = /^\d{7}$/; export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; export type Classmap = { [key: string]: Classmap | string }; -function sortKeys(value: Json): Json { - if (Array.isArray(value)) return value.map(sortKeys); +/** Orders strings by code point, as Python's sorted() does. */ +export function compareCodePoints(a: string, b: string): number { + const x = Array.from(a); + const y = Array.from(b); + for (let i = 0; i < Math.min(x.length, y.length); i++) { + const diff = (x[i].codePointAt(0) as number) - (y[i].codePointAt(0) as number); + if (diff) return diff; + } + return x.length - y.length; +} + +function render(value: Json, pad: string): string { + const inner = `${pad} `; + if (Array.isArray(value)) { + if (!value.length) return "[]"; + return `[\n${value.map((item) => inner + render(item, inner)).join(",\n")}\n${pad}]`; + } if (value && typeof value === "object") { - return Object.fromEntries( - Object.keys(value) - .sort() - .map((key) => [key, sortKeys(value[key])]), - ); + const keys = Object.keys(value).sort(compareCodePoints); + if (!keys.length) return "{}"; + return `{\n${keys.map((key) => `${inner}${JSON.stringify(key)}: ${render(value[key], inner)}`).join(",\n")}\n${pad}}`; } - return value; + return JSON.stringify(value); } -/** Canonical form: 2-space indent, sorted keys, trailing newline. */ +/** Canonical form: 2-space indent, keys sorted by code point, trailing newline. */ export function renderJson(value: unknown): string { - return `${JSON.stringify(sortKeys(value as Json), null, 2)}\n`; + return `${render(value as Json, "")}\n`; +} + +const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +/** Reads a UTF-8 file, rejecting invalid bytes instead of replacing them. */ +export function readUtf8(file: string): string { + try { + return UTF8.decode(readFileSync(file)); + } catch (e) { + if (e instanceof TypeError) throw new Error("not valid UTF-8"); + throw e; + } +} + +/** Whether a class looks like a Spotify CSS-module hash; mirrors the CLI's classmap-capture.ts. */ +export function isHashLike(token: string): boolean { + const length = Array.from(token).length; + if (length < 4 || length > 25) return false; + if (token.includes("-") && token.toLowerCase() === token && token.toUpperCase() !== token) return false; + if (token.startsWith("spotify") || token.startsWith("encore")) return false; + const hasUpper = /\p{Lu}/u.test(token); + const hasLower = /\p{Ll}/u.test(token); + if (hasUpper && hasLower) return true; + return token.includes("_") && (hasUpper || /\p{Nd}/u.test(token)); +} + +export function isFile(file: string): boolean { + return statSync(file, { throwIfNoEntry: false })?.isFile() === true; } export function writeText(file: string, text: string): void { diff --git a/scripts/validate-expose.test.mts b/scripts/validate-expose.test.mts new file mode 100644 index 0000000..e46e97e --- /dev/null +++ b/scripts/validate-expose.test.mts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { exposeErrors, renderExpose } from "./validate-expose.ts"; + +const patch = { name: "Tippy.js", pattern: "([\\w$]+)\\.setDefaultProps=", replace: "Spicetify.Tippy=${1};${0}", once: false, onMiss: "warn", hits: { "1.2.96": 2 } }; +const errors = (changes: Record) => exposeErrors(renderExpose({ version: 1, patches: [{ ...patch, ...changes }] })); + +test("accepts a well-formed patch", () => assert.deepEqual(errors({}), [])); + +test("rejects a null onMiss, which the CLI cannot deserialize", () => { + assert.ok(errors({ onMiss: null }).includes("Tippy.js: `onMiss` must be `warn` or `quiet`")); +}); + +test("counts groups in Rust-style named groups and leading flags", () => { + assert.deepEqual(errors({ pattern: "(?s)(?Pa.b)", replace: "${1}" }), []); + assert.ok(errors({ pattern: "(?Pa)", replace: "${2}" }).some((e) => e.includes("has 1 group(s)"))); +}); + +test("requires hit counts per major.minor.patch build", () => { + assert.ok(errors({ hits: {} }).some((e) => e.includes("`hits`"))); + assert.ok(errors({ hits: { "1.2": 1 } }).some((e) => e.includes("`hits`"))); + assert.ok(errors({ hits: { "1.2.96": -1 } }).some((e) => e.includes("`hits`"))); +}); diff --git a/scripts/validate-expose.ts b/scripts/validate-expose.ts index 2134e73..ce943b8 100644 --- a/scripts/validate-expose.ts +++ b/scripts/validate-expose.ts @@ -1,16 +1,16 @@ // Checks expose.json before it is published: the schema the CLI reads, every // pattern compiles, and every `${N}` in a template names a group the pattern -// has. V8's regex engine is close to the Rust regex crate for the syntax these -// patterns use; the CLI is the final authority and skips a pattern it cannot +// has. Patterns are compiled with V8 after rewriting the two Rust-only forms +// it lacks, Python-style named groups `(?P` and a leading flag group +// such as `(?s)`. The CLI is the final authority and skips a pattern it cannot // compile, so a construct only one side accepts still surfaces at apply as a // `did not match` warning rather than a crash. // // node scripts/validate-expose.ts -import { readFileSync } from "node:fs"; import path from "node:path"; -import { ROOT } from "./lib.ts"; +import { readUtf8, ROOT } from "./lib.ts"; const TEMPLATE_REF = /\$\{(\d+)\}|\$(\d+)/g; const BUILD = /^\d+\.\d+\.\d+$/; @@ -20,7 +20,11 @@ export function renderExpose(doc: unknown): string { } function groupCount(pattern: string): number { - return (new RegExp(`${pattern}|`).exec("") as RegExpExecArray).length - 1; + const leading = /^\(\?([imsx]+)\)/.exec(pattern); + const body = (leading ? pattern.slice(leading[0].length) : pattern).replaceAll("(?P<", "(?<"); + const flags = (leading?.[1] ?? "").replace("x", ""); + if (leading?.[1].includes("x")) throw new Error("the verbose flag (?x) has no V8 equivalent"); + return (new RegExp(`${body}|`, flags).exec("") as RegExpExecArray).length - 1; } export function exposeErrors(raw: string): string[] { @@ -74,7 +78,7 @@ export function exposeErrors(raw: string): string[] { } } if ("once" in patch && typeof patch.once !== "boolean") errors.push(`${name}: \`once\` must be a boolean`); - if (!["warn", "quiet"].includes((patch.onMiss ?? "warn") as string)) { + if (!["warn", "quiet"].includes(("onMiss" in patch ? patch.onMiss : "warn") as string)) { errors.push(`${name}: \`onMiss\` must be \`warn\` or \`quiet\``); } const hits = patch.hits; @@ -93,7 +97,13 @@ export function exposeErrors(raw: string): string[] { } export function validateExpose(root = ROOT): number { - const raw = readFileSync(path.join(root, "expose.json"), "utf8"); + let raw: string; + try { + raw = readUtf8(path.join(root, "expose.json")); + } catch (e) { + console.error(`expose.json: ${(e as Error).message}`); + return 1; + } const errors = exposeErrors(raw); for (const e of errors) console.error(e); if (errors.length) return 1; From 74fd66abd373dc49ce2afa0eff9b70dc97a3550a Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 01:02:44 +0200 Subject: [PATCH 19/20] feat(scripts): publish derived keys from migrate output and bind them to their verified map A derived key keeps its source's unchanged stale leaves and the migrate leaves whose hashed classes left the stock CSS. A path missing from that CSS counts as observed only through its stock class. --drop-required, --replace and --overlay cover the remaining decisions, and META.json records verified_classmap_sha256 so a map edited after verification fails validation. VERIFICATION.md records the classmap, overlay and target CSS digests. --- 1020084/META.json | 3 +- 1020092/META.json | 3 +- 1020094/META.json | 3 +- 1020095/META.json | 3 +- 1020096/META.json | 3 +- 1020097/META.json | 3 +- 1020098/META.json | 3 +- 1020099/META.json | 3 +- 1030000/META.json | 3 +- 1030001/META.json | 3 +- index.json | 20 +- scripts/build-index.ts | 25 ++- scripts/publish-key.test.mts | 275 +++++++++++++++-------- scripts/publish-key.ts | 327 +++++++++++++++++++--------- scripts/validate-classmaps.test.mts | 37 +++- scripts/validate-classmaps.ts | 46 +++- 16 files changed, 534 insertions(+), 226 deletions(-) diff --git a/1020084/META.json b/1020084/META.json index 589497a..0681f71 100644 --- a/1020084/META.json +++ b/1020084/META.json @@ -32,5 +32,6 @@ "main.playbar.buttons.button.wrapper__active", "main.playbar.buttons.button.wrapper__indicator", "main.topbar.left.button_t.wrapper" - ] + ], + "verified_classmap_sha256": null } diff --git a/1020092/META.json b/1020092/META.json index 641801d..91315e7 100644 --- a/1020092/META.json +++ b/1020092/META.json @@ -28,5 +28,6 @@ "settings.text_input" ], "status": "verified", - "unverified_leaves": [] + "unverified_leaves": [], + "verified_classmap_sha256": null } diff --git a/1020094/META.json b/1020094/META.json index 17b5a46..9969967 100644 --- a/1020094/META.json +++ b/1020094/META.json @@ -29,5 +29,6 @@ "status": "verified", "unverified_leaves": [ "settings.button.wrapper" - ] + ], + "verified_classmap_sha256": null } diff --git a/1020095/META.json b/1020095/META.json index 891fdba..a5f6054 100644 --- a/1020095/META.json +++ b/1020095/META.json @@ -30,5 +30,6 @@ "unverified_leaves": [ "main.playbar.buttons.button.wrapper__indicator", "settings.header.container" - ] + ], + "verified_classmap_sha256": null } diff --git a/1020096/META.json b/1020096/META.json index 0ee7f57..12038e8 100644 --- a/1020096/META.json +++ b/1020096/META.json @@ -29,5 +29,6 @@ "status": "verified", "unverified_leaves": [ "main.playbar.buttons.button.wrapper__indicator" - ] + ], + "verified_classmap_sha256": null } diff --git a/1020097/META.json b/1020097/META.json index c2f6166..053c336 100644 --- a/1020097/META.json +++ b/1020097/META.json @@ -30,5 +30,6 @@ "unverified_leaves": [ "main.playbar.buttons.button.wrapper__indicator", "settings.text_input" - ] + ], + "verified_classmap_sha256": null } diff --git a/1020098/META.json b/1020098/META.json index 2326733..6326085 100644 --- a/1020098/META.json +++ b/1020098/META.json @@ -30,5 +30,6 @@ "unverified_leaves": [ "main.playbar.buttons.button.wrapper__indicator", "settings.text_input" - ] + ], + "verified_classmap_sha256": null } diff --git a/1020099/META.json b/1020099/META.json index 58eaff8..81f5e3d 100644 --- a/1020099/META.json +++ b/1020099/META.json @@ -27,5 +27,6 @@ "status": "verified", "unverified_leaves": [ "settings.header.container" - ] + ], + "verified_classmap_sha256": null } diff --git a/1030000/META.json b/1030000/META.json index a8fd6ec..96c40b1 100644 --- a/1030000/META.json +++ b/1030000/META.json @@ -24,5 +24,6 @@ "spotify_version": "1.3.0.277", "stale_leaves": [], "status": "verified", - "unverified_leaves": [] + "unverified_leaves": [], + "verified_classmap_sha256": null } diff --git a/1030001/META.json b/1030001/META.json index c1dde24..44441aa 100644 --- a/1030001/META.json +++ b/1030001/META.json @@ -26,5 +26,6 @@ "status": "verified", "unverified_leaves": [ "settings.header.container" - ] + ], + "verified_classmap_sha256": null } diff --git a/index.json b/index.json index e32620a..b4dbed7 100644 --- a/index.json +++ b/index.json @@ -29,7 +29,7 @@ }, "meta": { "file": "META.json", - "sha256": "638e8c8d0356a271a7a08eef0abf6dff43d326a33b30a19d8a18c3a55d7a2974" + "sha256": "842511cbd0e59665dbb3c08329283640c3059be197701437bfbe8a681cacf53a" }, "spotifyVersion": "1.2.84.476", "status": "verified" @@ -45,7 +45,7 @@ }, "meta": { "file": "META.json", - "sha256": "d40d57191f05cc1cc8be1935fdf9b707030be31a6357650bab63484cd173dcdb" + "sha256": "2dadd1c404fe29bffb8db83ac84ae59fc9e76dee8bcf12e4710819d5218d5749" }, "spotifyVersion": "1.2.92.148", "status": "verified" @@ -61,7 +61,7 @@ }, "meta": { "file": "META.json", - "sha256": "8395c6de8c80b3cdf1619856d8e68f059b365934eb4eff4f5cc97edc72104ccf" + "sha256": "1e0e0e0a68e08b54110055adc1f1a9e01e798620b5b7b0a9244c5fb3f439d36d" }, "spotifyVersion": "1.2.94.583", "status": "verified" @@ -77,7 +77,7 @@ }, "meta": { "file": "META.json", - "sha256": "34b677bcea4deaa22113e6885ba9b6018bf13dd6addc12b115aef166c21eec21" + "sha256": "a3df356bfd3a72ddc85ed65076e3f4fbaae7357c84fe5efa9e4b8abc2b8acfb2" }, "spotifyVersion": "1.2.95.453", "status": "verified" @@ -93,7 +93,7 @@ }, "meta": { "file": "META.json", - "sha256": "29d2c241233ba11a0c74fda59b770a1911d6d36ba8e9eb576196686fa7f74889" + "sha256": "981db11376e8aa077fa1a6c54c6e2131b882cb0be544676ccc18c5cb0e447a31" }, "spotifyVersion": "1.2.96.518", "status": "verified" @@ -109,7 +109,7 @@ }, "meta": { "file": "META.json", - "sha256": "f45a5257e28d6b3f5c3d97fa10117678935d575f3b2f5741756a65c51e8747bc" + "sha256": "8dfa71738326b1507d389f57b1501d24d3030b99f8f036bb65efef2828450b01" }, "spotifyVersion": "1.2.97.270", "status": "verified" @@ -125,7 +125,7 @@ }, "meta": { "file": "META.json", - "sha256": "1cee8b708c0f7d15aaef43fd6f9ec3cfbb8ccdd51143b7d9cf461c9ce811a522" + "sha256": "d2aadb1c486736811930db20089b901683b06520146e0ad094844d452b92f247" }, "spotifyVersion": "1.2.98.301", "status": "verified" @@ -141,7 +141,7 @@ }, "meta": { "file": "META.json", - "sha256": "c80feef7120cb2fdf162be29221b41e47c75cb3056193a3d289beaed9956123b" + "sha256": "1bd962d745663b48bd70e8c5e297856cc2965e20d27e8bd264f0553cc89ca45c" }, "spotifyVersion": "1.2.99.317", "status": "verified" @@ -157,7 +157,7 @@ }, "meta": { "file": "META.json", - "sha256": "8f36dbed5ee0d902786c2bba205b75a2b4a7311c87d120539e90e0cafab54b87" + "sha256": "a22d944c39a622ae22d884eda92cc9c3949b6d2e338374ae9dc7a09d12d48fe9" }, "spotifyVersion": "1.3.0.277", "status": "verified" @@ -173,7 +173,7 @@ }, "meta": { "file": "META.json", - "sha256": "1c1c8314557c066bbf360dc52b5f1be46e27e960de94bcfc999f4ba992a5d15f" + "sha256": "b83a268923007a87f80eb9384f515d6f4622284751a25270408f9337d353fb62" }, "spotifyVersion": "1.3.1.234", "status": "verified" diff --git a/scripts/build-index.ts b/scripts/build-index.ts index 5186d88..7cd9a8a 100644 --- a/scripts/build-index.ts +++ b/scripts/build-index.ts @@ -4,10 +4,10 @@ // node scripts/build-index.ts # rewrite index.json // node scripts/build-index.ts --check # fail if it is out of date -import { existsSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import path from "node:path"; -import { keyDirs, renderJson, ROOT, sha256, writeText } from "./lib.ts"; +import { isFile, keyDirs, readUtf8, renderJson, ROOT, sha256, writeText } from "./lib.ts"; type FileRef = { file: string; sha256: string }; type Entry = { @@ -23,14 +23,19 @@ function fileRef(dir: string, name: string): FileRef { } function entryFor(keyDir: string): Entry | null { - if (!existsSync(path.join(keyDir, "classmap.json"))) return null; + if (!isFile(path.join(keyDir, "classmap.json"))) return null; const entry: Entry = { classmap: fileRef(keyDir, "classmap.json") }; - if (existsSync(path.join(keyDir, "css-map.json"))) entry.cssMapOverlay = fileRef(keyDir, "css-map.json"); - if (existsSync(path.join(keyDir, "META.json"))) { + if (isFile(path.join(keyDir, "css-map.json"))) entry.cssMapOverlay = fileRef(keyDir, "css-map.json"); + if (isFile(path.join(keyDir, "META.json"))) { entry.meta = fileRef(keyDir, "META.json"); - const meta = JSON.parse(readFileSync(path.join(keyDir, "META.json"), "utf8")); - if ("spotify_version" in meta) entry.spotifyVersion = meta.spotify_version; - if ("status" in meta) entry.status = meta.status; + let meta: Record; + try { + meta = JSON.parse(readUtf8(path.join(keyDir, "META.json"))); + } catch (e) { + throw new Error(`${path.basename(keyDir)}/META.json: ${(e as Error).message}`); + } + if (typeof meta.spotify_version === "string") entry.spotifyVersion = meta.spotify_version; + if (typeof meta.status === "string") entry.status = meta.status; } return entry; } @@ -45,7 +50,7 @@ export function buildIndex(root = ROOT): Record { const index: Record = { version: 1, keys }; // The exposure patch set is one file for every build, so it sits beside the // keys rather than under one; older CLIs ignore the entry. - if (existsSync(path.join(root, "expose.json"))) index.expose = fileRef(root, "expose.json"); + if (isFile(path.join(root, "expose.json"))) index.expose = fileRef(root, "expose.json"); return index; } @@ -55,7 +60,7 @@ export function writeIndex(root = ROOT): void { export function indexIsCurrent(root = ROOT): boolean { const file = path.join(root, "index.json"); - return existsSync(file) && readFileSync(file, "utf8") === renderJson(buildIndex(root)); + return isFile(file) && readFileSync(file, "utf8") === renderJson(buildIndex(root)); } if (import.meta.main) { diff --git a/scripts/publish-key.test.mts b/scripts/publish-key.test.mts index 381aaa3..c449a40 100644 --- a/scripts/publish-key.test.mts +++ b/scripts/publish-key.test.mts @@ -5,7 +5,7 @@ import path from "node:path"; import { afterEach, beforeEach, test } from "node:test"; import { writeIndex } from "./build-index.ts"; -import { renderJson, sha256 } from "./lib.ts"; +import { type Classmap, leafValues, renderJson, sha256 } from "./lib.ts"; import { type PublishOptions, publishAndIndex, publishRelease } from "./publish-key.ts"; const classmap = { @@ -14,45 +14,37 @@ const classmap = { topbar: { retired: "retiredHashBB", wrapper: "topbarHashAA" }, }, }; +const inTargetCss = new Set(["main.topbar.wrapper"]); let root: string; let source: string; let staticReport: Record; let cdpReport: Record; -beforeEach(() => { - root = mkdtempSync(path.join(tmpdir(), "classmaps-")); - source = path.join(root, "1020094"); - mkdirSync(source); - writeFileSync(path.join(source, "classmap.json"), renderJson(classmap)); - writeFileSync(path.join(source, "css-map.json"), renderJson({ customHash: "semantic-name" })); - writeSourceMeta({}); - writeFileSync(path.join(source, "VERIFICATION.md"), "# Spotify 1.2.94.583 (1020094)\n"); - const digest = sha256(readFileSync(path.join(source, "classmap.json"))); - staticReport = { - target: { spotify_version: "1.2.96.518", css_sha256: "a".repeat(64), classmap_sha256: digest }, - summary: { needs_manual_check: 1, missing_in_css: 2 }, - rows: [ - { path: "main.topbar.wrapper", class: "topbarHashAA", in_target_css: true, verdict: "needs_manual_check" }, - { path: "main.topbar.retired", class: "retiredHashBB", in_target_css: false, verdict: "missing_in_css" }, - { path: "main.playbar.indicator", class: "indicatorHashCC", in_target_css: false, verdict: "missing_in_css" }, - ], - }; - cdpReport = { - cdp: { browser: { "User-Agent": "Spotify/1.2.96.518 Chrome/146" } }, - classmap: { sha256: digest }, - mode: "both", - deep: true, - navigation: { attempted: 8, succeeded: 8, failed: [] }, - summary: { total: 3, hits: 1, hitRate: 0.3333 }, - rows: [ - { path: "main.topbar.wrapper", hash: "topbarHashAA", hit: true }, - { path: "main.topbar.retired", hash: "retiredHashBB", hit: false }, - { path: "main.playbar.indicator", hash: "indicatorHashCC", hit: false }, - ], +/** Reports for `map` on `version`: `css` paths are in the target CSS, `hits` were observed live. */ +function reportsFor(map: Classmap, version: string, css: Set, hits: Set) { + const digest = sha256(renderJson(map)); + const leaves = [...leafValues(map)]; + const summary: Record = {}; + const staticRows = leaves.map(([leaf, cls]) => { + const verdict = css.has(leaf) ? "needs_manual_check" : "missing_in_css"; + summary[verdict] = (summary[verdict] ?? 0) + 1; + return { path: leaf, class: cls, missing_classes: css.has(leaf) ? [] : cls.split(" "), in_target_css: css.has(leaf), verdict }; + }); + const hitCount = leaves.filter(([leaf]) => hits.has(leaf)).length; + return { + static: { target: { spotify_version: version, css_sha256: "a".repeat(64), classmap_sha256: digest }, summary, rows: staticRows }, + cdp: { + generatedAt: "2026-08-12T10:00:00.000Z", + cdp: { browser: { "User-Agent": `Spotify/${version} Chrome/146` } }, + classmap: { sha256: digest }, + mode: "both", + deep: true, + navigation: { attempted: 8, succeeded: 8, failed: [] }, + summary: { total: leaves.length, hits: hitCount, hitRate: Number((hitCount / leaves.length).toFixed(4)) }, + rows: leaves.map(([leaf, cls]) => ({ path: leaf, hash: cls, hit: hits.has(leaf), hashHit: hits.has(leaf), semHit: false })), + }, }; -}); - -afterEach(() => rmSync(root, { recursive: true, force: true })); +} function writeSourceMeta(changes: Record): void { const meta = { @@ -65,11 +57,27 @@ function writeSourceMeta(changes: Record): void { stale_leaves: ["main.topbar.retired"], unverified_leaves: [], required_paths: { "main.playbar.indicator": "verified", "main.topbar.wrapper": "verified" }, + verified_classmap_sha256: null, ...changes, }; writeFileSync(path.join(source, "META.json"), renderJson(meta)); } +beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), "classmaps-")); + source = path.join(root, "1020094"); + mkdirSync(source); + writeFileSync(path.join(source, "classmap.json"), renderJson(classmap)); + writeFileSync(path.join(source, "css-map.json"), renderJson({ customHash: "semantic-name" })); + writeSourceMeta({}); + writeFileSync(path.join(source, "VERIFICATION.md"), "# Spotify 1.2.94.583 (1020094)\n"); + const reports = reportsFor(classmap, "1.2.96.518", inTargetCss, new Set(["main.topbar.wrapper"])); + staticReport = reports.static; + cdpReport = reports.cdp; +}); + +afterEach(() => rmSync(root, { recursive: true, force: true })); + function options(changes: Partial = {}): PublishOptions { return { root, @@ -83,21 +91,23 @@ function options(changes: Partial = {}): PublishOptions { }; } +function derived(map: Classmap, changes: Partial = {}): Partial { + const candidate = path.join(root, "candidate.json"); + writeFileSync(candidate, renderJson(map)); + return { inheritFrom: undefined, classmapPath: candidate, derivedFrom: "1020094", ...changes }; +} + const readMeta = (target: string) => JSON.parse(readFileSync(path.join(target, "META.json"), "utf8")); const readVerification = (target: string) => readFileSync(path.join(target, "VERIFICATION.md"), "utf8"); -const stagingDirs = () => readdirSync(root).filter((name) => name.startsWith(".1020096-")); +const stagingDirs = () => readdirSync(root).filter((name) => name.startsWith(".")); function setHit(leaf: string, hit: boolean, summary: Record): void { - cdpReport.rows.find((row: { path: string }) => row.path === leaf).hit = hit; + const row = cdpReport.rows.find((r: { path: string }) => r.path === leaf); + row.hit = hit; + row.hashHit = hit; Object.assign(cdpReport.summary, summary); } -function bindReportsTo(file: string): void { - const digest = sha256(readFileSync(file)); - staticReport.target.classmap_sha256 = digest; - cdpReport.classmap.sha256 = digest; -} - test("promotes an unchanged map and records new stale paths", () => { const target = publishRelease(options()); assert.equal(target, path.join(root, "1020096")); @@ -109,10 +119,14 @@ test("promotes an unchanged map and records new stale paths", () => { assert.deepEqual(meta.source, { method: "inherited", key: "1020094" }); assert.deepEqual(meta.stale_leaves, ["main.topbar.retired"]); assert.deepEqual(meta.unverified_leaves, ["main.playbar.indicator"]); - assert.ok(readVerification(target).includes("| static_present | 1 |")); - assert.ok(readVerification(target).includes("| verified_cdp | 1 |")); + assert.equal(meta.verified_classmap_sha256, sha256(readFileSync(path.join(target, "classmap.json")))); assert.equal(meta.required_paths["main.topbar.wrapper"], "verified_cdp"); assert.equal(meta.required_paths["main.playbar.indicator"], "unverified"); + const verification = readVerification(target); + assert.ok(verification.includes("| static_present | 1 |")); + assert.ok(verification.includes("| verified_cdp | 1 |")); + assert.ok(verification.includes(`- Classmap SHA-256: \`${meta.verified_classmap_sha256}\``)); + assert.ok(verification.includes("- CDP report generated: 2026-08-12T10:00:00.000Z")); }); test("the generated index uses LF and hashes the published bytes", () => { @@ -140,6 +154,11 @@ test("refuses source metadata that does not match the source key", () => { assert.throws(() => publishRelease(options()), /source metadata key/); }); +test("refuses a legacy source without META.json by name", () => { + rmSync(path.join(source, "META.json")); + assert.throws(() => publishRelease(options()), /1020094 has no META\.json/); +}); + test("a live hit overrides a CSS-only missing result", () => { setHit("main.playbar.indicator", true, { hits: 2, hitRate: 0.6667 }); const target = publishRelease(options()); @@ -151,13 +170,34 @@ test("a live hit overrides a CSS-only missing result", () => { assert.ok(readVerification(target).includes("| unresolved_missing | 1 |")); }); +test("a semantic-only hit does not clear a path missing from the stock CSS", () => { + const row = cdpReport.rows.find((r: { path: string }) => r.path === "main.playbar.indicator"); + Object.assign(row, { hit: true, hashHit: false, semHit: true }); + Object.assign(cdpReport.summary, { hits: 2, hitRate: 0.6667 }); + const meta = readMeta(publishRelease(options())); + assert.equal(meta.required_paths["main.playbar.indicator"], "unverified"); + assert.deepEqual(meta.unverified_leaves, ["main.playbar.indicator"]); +}); + +test("accepts the CLI's rounding of a hit rate", () => { + const wide: Classmap = { leaves: Object.fromEntries(Array.from({ length: 160 }, (_, i) => [`leaf${i}`, `hashLeaf${i}XX`])) }; + const hits = new Set(Array.from({ length: 41 }, (_, i) => `leaves.leaf${i}`)); + const reports = reportsFor(wide, "1.2.96.518", new Set(leafValues(wide).keys()), hits); + assert.equal(reports.cdp.summary.hitRate, 0.2562); + writeSourceMeta({ required_paths: {}, stale_leaves: [] }); + const target = publishRelease( + options({ ...derived(wide), staticReport: reports.static, cdpReport: reports.cdp, requiredFrom: "1020094" }), + ); + assert.ok(readVerification(target).includes("| cdp_hit_rate | 0.2562 |")); +}); + test("refuses a live report below the required hit rate", () => { setHit("main.topbar.wrapper", false, { hits: 0, hitRate: 0 }); assert.throws(() => publishRelease(options()), /below required 0\.2500/); }); test("refuses a summary that disagrees with rows", () => { - cdpReport.rows[0].hit = false; + cdpReport.rows[0].hit = !cdpReport.rows[0].hit; assert.throws(() => publishRelease(options()), /summary does not match rows/); }); @@ -197,15 +237,20 @@ test("refuses a static report that disagrees with its rows or build", () => { }); test("refuses a report leaf or digest mismatch", () => { - cdpReport.rows[0].hash = "anotherHashZZ"; + const row = cdpReport.rows[0]; + const hash = row.hash; + row.hash = "anotherHashZZ"; assert.throws(() => publishRelease(options()), /does not match classmap/); - cdpReport.rows[0].hash = "topbarHashAA"; + row.hash = hash; cdpReport.classmap.sha256 = "b".repeat(64); assert.throws(() => publishRelease(options()), /classmap digest/); }); test("live verification recovers an inherited stale path", () => { setHit("main.topbar.retired", true, { hits: 2, hitRate: 0.6667 }); + staticReport.rows.find((r: { path: string }) => r.path === "main.topbar.retired").in_target_css = true; + staticReport.rows.find((r: { path: string }) => r.path === "main.topbar.retired").verdict = "needs_manual_check"; + staticReport.summary = { needs_manual_check: 2, missing_in_css: 1 }; assert.ok(!readMeta(publishRelease(options())).stale_leaves.includes("main.topbar.retired")); }); @@ -216,6 +261,9 @@ test("a source-unverified path stays unverified without a live hit", () => { }); setHit("main.topbar.wrapper", false, {}); setHit("main.playbar.indicator", true, {}); + staticReport.rows.find((r: { path: string }) => r.path === "main.playbar.indicator").in_target_css = true; + staticReport.rows.find((r: { path: string }) => r.path === "main.playbar.indicator").verdict = "needs_manual_check"; + staticReport.summary = { needs_manual_check: 2, missing_in_css: 1 }; const meta = readMeta(publishRelease(options())); assert.equal(meta.required_paths["main.topbar.wrapper"], "unverified"); assert.deepEqual(meta.unverified_leaves, ["main.topbar.wrapper"]); @@ -249,39 +297,62 @@ test("an index failure rolls back the key and the index", () => { mkdirSync(path.join(root, "1020090")); writeFileSync(path.join(root, "1020090", "classmap.json"), "{}\n"); writeFileSync(path.join(root, "1020090", "META.json"), "not json"); - assert.throws(() => publishAndIndex(options()), SyntaxError); + assert.throws(() => publishAndIndex(options()), /1020090\/META\.json/); assert.ok(!existsSync(path.join(root, "1020096"))); assert.equal(readFileSync(path.join(root, "index.json"), "utf8"), '{"old": true}\n'); + assert.deepEqual(stagingDirs(), []); +}); + +test("refuses to overwrite an existing key without --replace", () => { + publishRelease(options()); + assert.throws(() => publishRelease(options()), /1020096 already exists; pass --replace/); +}); + +test("--replace re-verifies a key, keeping its first publication date", () => { + publishRelease(options()); + setHit("main.playbar.indicator", true, { hits: 2, hitRate: 0.6667 }); + const target = publishRelease(options({ replace: true, generated: "2026-09-30" })); + const meta = readMeta(target); + assert.equal(meta.generated, "2026-08-12"); + assert.equal(meta.required_paths["main.playbar.indicator"], "verified_cdp"); + assert.ok(readVerification(target).includes("Replaces an earlier verification")); + assert.deepEqual(stagingDirs(), []); }); -test("refuses to overwrite an existing target", () => { - mkdirSync(path.join(root, "1020096")); - assert.throws(() => publishRelease(options()), /1020096 already exists/); +test("--replace refuses a map that would break the keys inheriting from it", () => { + publishRelease(options()); + const changed = { ...classmap, main: { ...classmap.main, playbar: { indicator: "otherHashDD" } } }; + const reports = reportsFor(changed, "1.2.94.583", inTargetCss, new Set(["main.topbar.wrapper"])); + const before = readFileSync(path.join(source, "classmap.json")); + writeSourceMeta({}); + assert.throws( + () => + publishRelease( + options({ + ...derived(changed, { derivedFrom: undefined, requiredFrom: undefined }), + spotifyVersion: "1.2.94.583", + staticReport: reports.static, + cdpReport: reports.cdp, + replace: true, + }), + ), + /keys inheriting from 1020094 would break/, + ); + assert.deepEqual(readFileSync(path.join(source, "classmap.json")), before); + assert.deepEqual(stagingDirs(), []); }); test("publishes a derived key from a candidate map", () => { - const candidate = path.join(root, "candidate.json"); - writeFileSync(candidate, JSON.stringify(classmap, null, "\t")); const overlay = path.join(root, "overlay.json"); writeFileSync(overlay, '{"topbarHashAA":"Root__globalNav"}'); - bindReportsTo(candidate); - const target = publishRelease( - options({ - inheritFrom: undefined, - classmapPath: candidate, - overlayPath: overlay, - derivedFrom: "1020094", - stale: ["main.topbar.retired"], - notes: ["The retired leaf has no rendered instance."], - }), + options(derived(classmap, { overlayPath: overlay, notes: ["The retired leaf has no rendered instance."] })), ); - assert.equal(readFileSync(path.join(target, "classmap.json"), "utf8"), renderJson(classmap)); assert.equal(readFileSync(path.join(target, "css-map.json"), "utf8"), renderJson({ topbarHashAA: "Root__globalNav" })); const meta = readMeta(target); assert.deepEqual(meta.source, { method: "derived", key: "1020094" }); - assert.deepEqual(meta.stale_leaves, ["main.topbar.retired"]); + assert.deepEqual(meta.stale_leaves, ["main.topbar.retired"], "an unchanged stale leaf of the source stays stale"); assert.deepEqual(meta.unverified_leaves, ["main.playbar.indicator"]); assert.deepEqual(meta.required_paths, { "main.playbar.indicator": "unverified", @@ -291,31 +362,63 @@ test("publishes a derived key from a candidate map", () => { assert.ok(readVerification(target).includes("- The retired leaf has no rendered instance.")); }); -test("refuses a derived candidate that stores Spicetify names", () => { +test("a derived key keeps the leaves the migrate report kept as stale", () => { + writeSourceMeta({ stale_leaves: [], required_paths: { "main.topbar.wrapper": "verified" } }); + const migrateReport = { unmatched: [{ path: "main.playbar.indicator", old: "indicatorHashCC", kept: "indicatorHashCC", stale: true }] }; + const meta = readMeta(publishRelease(options(derived(classmap, { migrateReport, requiredFrom: undefined })))); + assert.deepEqual(meta.stale_leaves, ["main.playbar.indicator"]); + assert.deepEqual(meta.unverified_leaves, ["main.topbar.retired"]); +}); + +test("a kept leaf whose classes are not hashes stays resolvable", () => { + const encore = { main: { topbar: { retired: "retiredHashBB", wrapper: "topbarHashAA" }, playbar: { indicator: "e-10860-button-primary__inner" } } }; + const reports = reportsFor(encore, "1.2.96.518", inTargetCss, new Set(["main.topbar.wrapper"])); + writeSourceMeta({ stale_leaves: [], required_paths: { "main.topbar.wrapper": "verified" } }); + const migrateReport = { unmatched: [{ path: "main.playbar.indicator", kept: "e-10860-button-primary__inner", stale: true }] }; + const meta = readMeta( + publishRelease(options({ ...derived(encore, { migrateReport }), staticReport: reports.static, cdpReport: reports.cdp })), + ); + assert.deepEqual(meta.stale_leaves, []); + assert.deepEqual(meta.unverified_leaves, ["main.playbar.indicator", "main.topbar.retired"]); +}); + +test("a required path migrate kept with a missing hash stays unverified with a note", () => { + writeSourceMeta({ stale_leaves: [] }); + const migrateReport = { unmatched: [{ path: "main.playbar.indicator", kept: "indicatorHashCC", stale: true }] }; + const target = publishRelease(options(derived(classmap, { migrateReport }))); + const meta = readMeta(target); + assert.equal(meta.required_paths["main.playbar.indicator"], "unverified"); + assert.ok(!meta.stale_leaves.includes("main.playbar.indicator")); + assert.ok(readVerification(target).includes("Required path main.playbar.indicator kept indicatorHashCC, which the stock CSS lacks")); +}); + +test("--drop-required stops tracking a path the new map no longer has", () => { + const smaller = { main: { topbar: { retired: "retiredHashBB", wrapper: "topbarHashAA" } } }; + const reports = reportsFor(smaller, "1.2.96.518", inTargetCss, new Set(["main.topbar.wrapper"])); + const target = publishRelease( + options({ ...derived(smaller, { dropRequired: ["main.playbar.indicator"] }), staticReport: reports.static, cdpReport: reports.cdp }), + ); + assert.deepEqual(readMeta(target).required_paths, { "main.topbar.wrapper": "verified_cdp" }); + assert.ok(readVerification(target).includes("No longer tracks the required path main.playbar.indicator.")); +}); + +test("refuses a candidate map that is not in canonical form", () => { const candidate = path.join(root, "candidate.json"); - writeFileSync(candidate, renderJson(classmap)); - const overlay = path.join(root, "overlay.json"); - writeFileSync(overlay, renderJson({ someHash: "topbarHashAA" })); - bindReportsTo(candidate); + writeFileSync(candidate, JSON.stringify(classmap, null, "\t")); assert.throws( - () => - publishRelease( - options({ - inheritFrom: undefined, - classmapPath: candidate, - overlayPath: overlay, - derivedFrom: "1020094", - stale: ["main.topbar.retired"], - }), - ), - /stores the Spicetify name topbarHashAA/, + () => publishRelease(options({ inheritFrom: undefined, classmapPath: candidate, derivedFrom: "1020094" })), + /not in canonical form/, ); +}); + +test("refuses a derived candidate that stores Spicetify names", () => { + const overlay = path.join(root, "overlay.json"); + writeFileSync(overlay, renderJson({ someHash: "topbarHashAA" })); + assert.throws(() => publishRelease(options(derived(classmap, { overlayPath: overlay }))), /stores the Spicetify name topbarHashAA/); assert.ok(!existsSync(path.join(root, "1020096"))); }); -test("refuses stale paths for an inherited key", () => { - assert.throws( - () => publishRelease(options({ stale: ["main.topbar.wrapper"] })), - /takes its map, overlay and stale paths/, - ); +test("refuses stale paths or required-path changes for an inherited key", () => { + assert.throws(() => publishRelease(options({ stale: ["main.topbar.wrapper"] })), /takes its map, overlay and stale paths/); + assert.throws(() => publishRelease(options({ dropRequired: ["main.topbar.wrapper"] })), /tracks its source's required paths/); }); diff --git a/scripts/publish-key.ts b/scripts/publish-key.ts index b1f964b..e6a78d7 100644 --- a/scripts/publish-key.ts +++ b/scripts/publish-key.ts @@ -2,29 +2,55 @@ // // An inherited key copies an older key's map and overlay byte-for-byte: // -// node scripts/publish-key.ts --inherit-from 1020094 \ -// --spotify-version 1.2.96.518 \ +// pnpm publish-key --inherit-from 1020094 --spotify-version 1.2.96.518 \ // --static-report static.json --cdp-report cdp.json // -// A derived key publishes a new candidate map: +// A derived key publishes a candidate map produced by `classmap-capture.ts migrate`: // -// node scripts/publish-key.ts --classmap candidate.json --css-map overlay.json \ -// --derived-from 1030001 --spotify-version 1.3.2.100 \ -// --static-report static.json --cdp-report cdp.json \ -// --stale main.topbar.right.upgrade_button.wrapper \ -// --note "The upgrade button has no rendered instance on a premium account." +// pnpm publish-key --classmap candidate.json --overlay overlay.json \ +// --derived-from 1030001 --migrate-report migrate.json \ +// --spotify-version 1.3.2.100 --static-report static.json --cdp-report cdp.json // -// Both reports must be bound to the published map and Spotify build. The -// command writes classmap.json, css-map.json, META.json and VERIFICATION.md, -// validates the new key, and rebuilds index.json, rolling everything back if a -// step fails. +// Flags: +// --inherit-from KEY copy KEY's map and overlay (an unchanged patch release) +// --classmap FILE candidate map for a derived key, in canonical form +// --overlay FILE css-map overlay for a derived key +// --derived-from KEY key the candidate was migrated from; its stale leaves carry over +// --migrate-report FILE migrate report; the leaves it kept as stale stay stale +// --stale PATH mark a leaf stale (repeatable) +// --required-paths-from KEY track KEY's required_paths (default: --derived-from, else the newest verified key) +// --drop-required PATH stop tracking a required path the new map no longer has (repeatable) +// --note TEXT add a line to VERIFICATION.md (repeatable) +// --replace re-verify an existing key in place +// --spotify-version VERSION exact Spotify build the reports were taken on +// --static-report FILE output of `classmap-capture.ts verify` +// --cdp-report FILE output of `classmap-cdp-verify.mjs --mode both --deep` +// --generated DATE first publication date (default: today, local time) +// --min-hit-rate RATE minimum CDP hit rate (default: 0.25) +// +// The command writes classmap.json, css-map.json, META.json and VERIFICATION.md, +// validates the key and every key inheriting from it, and rebuilds index.json, +// rolling everything back if a step fails. -import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { parseArgs } from "node:util"; import { writeIndex } from "./build-index.ts"; -import { type Classmap, KEY_DIR, keyDirs, leafValues, renderJson, ROOT, sha256, versionToKey, writeText } from "./lib.ts"; +import { + type Classmap, + isFile, + isHashLike, + KEY_DIR, + keyDirs, + leafValues, + readUtf8, + renderJson, + ROOT, + sha256, + versionToKey, + writeText, +} from "./lib.ts"; import { keyErrors } from "./validate-classmaps.ts"; export const MIN_HIT_RATE = 0.25; @@ -33,7 +59,9 @@ const SHA256_RE = /^[0-9a-f]{64}$/; type Report = Record; type Meta = { classmap_key?: string; + generated?: string; required_paths?: Record; + source?: { method?: string; key?: string | null }; spotify_version?: string; stale_leaves?: string[]; status?: string; @@ -51,11 +79,25 @@ export type PublishOptions = { classmapPath?: string; overlayPath?: string; derivedFrom?: string; + migrateReport?: Report; requiredFrom?: string; + dropRequired?: string[]; stale?: string[]; notes?: string[]; + replace?: boolean; }; +/** Matches the CLI's `Number(rate.toFixed(4))`. */ +function round4(n: number): number { + return Number(n.toFixed(4)); +} + +function localDate(): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; +} + function reportRows(report: Report, name: string, expected: Map, field: string): Map { const rows = new Map(); for (const row of report.rows ?? []) if (row.path) rows.set(row.path, row); @@ -71,6 +113,16 @@ function reportRows(report: Report, name: string, expected: Map, return rows; } +function readMeta(root: string, key: string): Meta { + const file = path.join(root, key, "META.json"); + if (!isFile(file)) throw new Error(`${key} has no META.json; only a verified key can be a source`); + try { + return JSON.parse(readUtf8(file)); + } catch (e) { + throw new Error(`${key}/META.json: ${(e as Error).message}`); + } +} + function checkSource(sourceKey: string, targetKey: string, meta: Meta, inherited: boolean): void { if (!KEY_DIR.test(sourceKey)) throw new Error("classmap keys must contain exactly seven digits"); if (meta.status !== "verified") throw new Error(`source ${sourceKey} is not verified`); @@ -84,7 +136,13 @@ function checkSource(sourceKey: string, targetKey: string, meta: Meta, inherited } } -/** Returns the paths missing from the target CSS, the live hits, and the hit rate. */ +type Evidence = { missing: Set; live: Set; hitRate: number; deadHashes: Map }; + +/** + * Checks both reports against the map and build. A path missing from the + * stock target CSS counts as observed only through its stock class: a hit on + * its css-map name can come from another class renamed to the same name. + */ function checkReports( spotifyVersion: string, classmap: Classmap, @@ -92,7 +150,7 @@ function checkReports( staticReport: Report, cdpReport: Report, minHitRate: number, -): { missing: Set; live: Set; hitRate: number } { +): Evidence { if (staticReport.target?.classmap_sha256 !== digest) { throw new Error("static report classmap digest does not match the published map"); } @@ -132,14 +190,14 @@ function checkReports( const values = leafValues(classmap); const staticRows = reportRows(staticReport, "static report", values, "class"); const cdpRows = reportRows(cdpReport, "CDP report", values, "hash"); - const live = new Set([...cdpRows].filter(([, row]) => row.hit === true).map(([p]) => p)); + const hits = [...cdpRows.values()].filter((row) => row.hit === true).length; const total = cdpRows.size; - const hitRate = total ? live.size / total : 0; + const hitRate = total ? hits / total : 0; const summary = cdpReport.summary ?? {}; if (typeof summary.hitRate !== "number" || !Number.isFinite(summary.hitRate)) { throw new Error("CDP summary hit rate must be finite"); } - if (summary.total !== total || summary.hits !== live.size || Math.abs(summary.hitRate - round4(hitRate)) > 0.00005) { + if (summary.total !== total || summary.hits !== hits || Math.abs(summary.hitRate - round4(hitRate)) > 0.00005) { throw new Error("CDP summary does not match rows"); } if (hitRate < minHitRate) { @@ -155,11 +213,15 @@ function checkReports( if (renderJson(staticReport.summary ?? null) !== renderJson(staticSummary)) { throw new Error("static summary does not match rows"); } - return { missing, live, hitRate: round4(hitRate) }; -} - -function round4(n: number): number { - return Math.round(n * 10000) / 10000; + const deadHashes = new Map(); + for (const [leaf, row] of staticRows) { + const dead = (Array.isArray(row.missing_classes) ? row.missing_classes : []).filter(isHashLike); + if (dead.length) deadHashes.set(leaf, dead); + } + const observed = (leaf: string, row: Report) => + missing.has(leaf) && typeof row.hashHit === "boolean" ? row.hashHit : row.hit === true; + const live = new Set([...cdpRows].filter(([leaf, row]) => observed(leaf, row)).map(([leaf]) => leaf)); + return { missing, live, hitRate: round4(hitRate), deadHashes }; } function requiredStatuses(required: Record, unverified: Set, live: Set) { @@ -173,25 +235,29 @@ function requiredStatuses(required: Record, unverified: Set; - live: Set; - navigation: { attempted: number; succeeded: number }; - hitRate: number; + evidence: Evidence; + cdpReport: Report; + staticReport: Report; + classmapSha: string; + overlaySha: string | null; overlayEntries: number; notes: string[]; }): string { - const liveOnly = [...o.missing].filter((p) => o.live.has(p)).length; - const staticPresent = o.leaves - o.missing.size; + const { missing, live, hitRate } = o.evidence; + const liveOnly = [...missing].filter((p) => live.has(p)).length; + const staticPresent = o.leaves - missing.size; + const navigation = o.cdpReport.navigation; const rows: [string, number][] = [ ["leaves", o.leaves], ["static_present", staticPresent], - ["verified_cdp", o.live.size], + ["verified_cdp", live.size], ["live_only", liveOnly], - ["unresolved_missing", o.missing.size - liveOnly], - ["cdp_hit_rate", o.hitRate], + ["unresolved_missing", missing.size - liveOnly], + ["cdp_hit_rate", hitRate], ["overlay_entries", o.overlayEntries], ]; const inherited = o.method === "inherited"; @@ -204,15 +270,15 @@ function verificationMarkdown(o: { return [ `# Spotify ${o.spotifyVersion} (${o.targetKey})`, "", - `Pipeline: ${step} + static target-CSS verification + CDP e2e (deep).`, + `Pipeline: ${step} + static target-CSS verification + CDP e2e (deep). Published ${o.published}.`, "", "## Notes", "", origin, `- Static verification found ${staticPresent}/${o.leaves} paths in the target CSS.`, - `- CSS-only misses observed live, which remain verified: ${liveOnly}.`, - `- Deep CDP verification observed ${o.live.size}/${o.leaves} paths with ` + - `${o.navigation.succeeded}/${o.navigation.attempted} successful navigation steps.`, + `- CSS-only misses observed live by their stock class, which remain verified: ${liveOnly}.`, + `- Deep CDP verification observed ${live.size}/${o.leaves} paths with ` + + `${navigation.succeeded}/${navigation.attempted} successful navigation steps.`, "- Unresolved new misses remain usable but are marked unverified; stale paths stay blocked.", ...o.notes.map((note) => `- ${note}`), "", @@ -222,127 +288,185 @@ function verificationMarkdown(o: { "| --- | --- |", ...rows.map(([field, value]) => `| ${field} | ${value} |`), "", + "## Evidence", + "", + `- Classmap SHA-256: \`${o.classmapSha}\``, + `- Overlay SHA-256: ${o.overlaySha ? `\`${o.overlaySha}\`` : "none"}`, + `- Target CSS SHA-256: \`${o.staticReport.target.css_sha256}\``, + `- CDP report generated: ${o.cdpReport.generatedAt ?? "not recorded"}`, + "", ].join("\n"); } -function readMeta(root: string, key: string): Meta { - return JSON.parse(readFileSync(path.join(root, key, "META.json"), "utf8")); -} - -function newestKeyWithMeta(root: string, below: string): string | undefined { +function newestVerifiedKey(root: string, below: string): string | undefined { return keyDirs(root) - .filter((key) => key < below && existsSync(path.join(root, key, "META.json"))) + .filter((key) => key < below && isFile(path.join(root, key, "META.json")) && readMeta(root, key).status === "verified") .at(-1); } -export function publishRelease(o: PublishOptions): string { +function inheritors(root: string, key: string): string[] { + return keyDirs(root).filter((other) => { + if (!isFile(path.join(root, other, "META.json"))) return false; + const source = readMeta(root, other).source; + return source?.method === "inherited" && source.key === key; + }); +} + +/** Publishes the key; with `index`, also rebuilds index.json inside the same rollback. */ +export function publishRelease(o: PublishOptions, index = false): string { if ((o.inheritFrom === undefined) === (o.classmapPath === undefined)) { throw new Error("pass exactly one of --inherit-from or --classmap"); } - if (o.inheritFrom !== undefined && (o.overlayPath || o.derivedFrom || o.stale?.length)) { + const inherited = o.inheritFrom !== undefined; + if (inherited && (o.overlayPath || o.derivedFrom || o.migrateReport || o.stale?.length)) { throw new Error("an inherited key takes its map, overlay and stale paths from its source"); } + if (inherited && (o.requiredFrom || o.dropRequired?.length)) { + throw new Error("an inherited key tracks its source's required paths"); + } const targetKey = versionToKey(o.spotifyVersion); const targetDir = path.join(o.root, targetKey); - if (existsSync(targetDir)) throw new Error(`${targetKey} already exists`); + const exists = isFile(path.join(targetDir, "classmap.json")); + if (exists && !o.replace) throw new Error(`${targetKey} already exists; pass --replace to re-verify it`); + if (!exists && o.replace) throw new Error(`${targetKey} does not exist, so there is nothing to replace`); + const generated = exists ? (readMeta(o.root, targetKey).generated ?? o.generated) : o.generated; const sourceKey = o.inheritFrom ?? o.derivedFrom ?? null; const sourceMeta: Meta = sourceKey ? readMeta(o.root, sourceKey) : {}; - if (sourceKey) checkSource(sourceKey, targetKey, sourceMeta, o.inheritFrom !== undefined); + if (sourceKey) checkSource(sourceKey, targetKey, sourceMeta, inherited); let classmapBytes: Buffer; let overlay: Record | undefined; - if (o.inheritFrom !== undefined) { - classmapBytes = readFileSync(path.join(o.root, o.inheritFrom, "classmap.json")); - const sourceOverlay = path.join(o.root, o.inheritFrom, "css-map.json"); - if (existsSync(sourceOverlay)) overlay = JSON.parse(readFileSync(sourceOverlay, "utf8")); + if (inherited) { + classmapBytes = readFileSync(path.join(o.root, o.inheritFrom as string, "classmap.json")); + const sourceOverlay = path.join(o.root, o.inheritFrom as string, "css-map.json"); + if (isFile(sourceOverlay)) overlay = JSON.parse(readUtf8(sourceOverlay)); } else { classmapBytes = readFileSync(o.classmapPath as string); - if (o.overlayPath) overlay = JSON.parse(readFileSync(o.overlayPath, "utf8")); + if (o.overlayPath) overlay = JSON.parse(readUtf8(o.overlayPath)); } const classmap: Classmap = JSON.parse(classmapBytes.toString("utf8")); + if (classmapBytes.toString("utf8") !== renderJson(classmap)) { + throw new Error( + "the candidate map is not in canonical form (sorted keys, two-space indent); " + + "`classmap-capture.ts migrate` writes it that way, so verify the file it wrote", + ); + } + const values = leafValues(classmap); - const { missing, live, hitRate } = checkReports( - o.spotifyVersion, - classmap, - sha256(classmapBytes), - o.staticReport, - o.cdpReport, - o.minHitRate, - ); + const evidence = checkReports(o.spotifyVersion, classmap, sha256(classmapBytes), o.staticReport, o.cdpReport, o.minHitRate); + const { missing, live, deadHashes } = evidence; - let stale: string[]; + const notes = [...(o.notes ?? [])]; + let stale: Set; let doubted: Set; let required: Record; - if (o.inheritFrom !== undefined) { - stale = (sourceMeta.stale_leaves ?? []).filter((p) => !live.has(p)).sort(); + if (inherited) { + stale = new Set((sourceMeta.stale_leaves ?? []).filter((p) => !live.has(p))); doubted = new Set([...missing, ...(sourceMeta.unverified_leaves ?? [])]); required = sourceMeta.required_paths ?? {}; } else { - stale = [...new Set(o.stale ?? [])].sort(); + const sourceValues = sourceKey + ? leafValues(JSON.parse(readUtf8(path.join(o.root, sourceKey, "classmap.json")))) + : new Map(); + const unchanged = (leaf: string) => sourceValues.get(leaf) === values.get(leaf); + const carried = (sourceMeta.stale_leaves ?? []).filter((p) => values.has(p) && unchanged(p) && !live.has(p)); + const requiredKey = o.requiredFrom ?? o.derivedFrom ?? newestVerifiedKey(o.root, targetKey); + required = { ...(requiredKey ? (readMeta(o.root, requiredKey).required_paths ?? {}) : {}) }; + for (const leaf of o.dropRequired ?? []) { + if (!Object.hasOwn(required, leaf)) throw new Error(`--drop-required ${leaf} is not a required path of ${requiredKey}`); + delete required[leaf]; + notes.push(`No longer tracks the required path ${leaf}.`); + } + // A leaf migrate kept is dead only when one of its hashed classes is gone + // from the stock CSS and was not seen live; a required one stays resolvable. + const kept = (o.migrateReport?.unmatched ?? []) + .filter((u: Report) => u.stale === true && values.get(u.path) === u.kept && deadHashes.has(u.path) && !live.has(u.path)) + .map((u: Report) => u.path as string); + for (const leaf of kept.filter((p: string) => Object.hasOwn(required, p))) { + notes.push(`Required path ${leaf} kept ${(deadHashes.get(leaf) ?? []).join(", ")}, which the stock CSS lacks; left unverified.`); + } + stale = new Set([...carried, ...kept.filter((p: string) => !Object.hasOwn(required, p)), ...(o.stale ?? [])]); doubted = new Set(missing); - const requiredKey = o.requiredFrom ?? o.derivedFrom ?? newestKeyWithMeta(o.root, targetKey); - required = requiredKey ? (readMeta(o.root, requiredKey).required_paths ?? {}) : {}; } - const unverified = new Set([...doubted].filter((p) => !live.has(p) && !stale.includes(p))); + const observedStale = [...stale].filter((p) => live.has(p)).sort(); + if (observedStale.length) notes.push(`Marked stale although observed live: ${observedStale.join(", ")}.`); + if (exists) notes.push(`Replaces an earlier verification of this key; its history is in git.`); + const unverified = new Set([...doubted].filter((p) => !live.has(p) && !stale.has(p))); - const method = o.inheritFrom !== undefined ? "inherited" : "derived"; + const method = inherited ? "inherited" : "derived"; + const overlayText = overlay ? renderJson(overlay) : null; const meta = { schema_version: 2, classmap_key: targetKey, spotify_version: o.spotifyVersion, status: "verified", - generated: o.generated, + generated, source: { method, key: sourceKey }, required_paths: requiredStatuses(required, unverified, live), - stale_leaves: stale, + stale_leaves: [...stale].sort(), unverified_leaves: [...unverified].sort(), + verified_classmap_sha256: sha256(classmapBytes), }; const verification = verificationMarkdown({ targetKey, spotifyVersion: o.spotifyVersion, + published: o.generated, method, sourceKey, - leaves: leafValues(classmap).size, - missing, - live, - navigation: o.cdpReport.navigation, - hitRate, + leaves: values.size, + evidence, + cdpReport: o.cdpReport, + staticReport: o.staticReport, + classmapSha: sha256(classmapBytes), + overlaySha: overlayText ? sha256(overlayText) : null, overlayEntries: overlay ? Object.keys(overlay).length : 0, - notes: o.notes ?? [], + notes, }); + const indexPath = path.join(o.root, "index.json"); + const previousIndex = index && isFile(indexPath) ? readFileSync(indexPath) : undefined; const staging = mkdtempSync(path.join(o.root, `.${targetKey}-`)); + let previous: string | undefined; try { - if (o.inheritFrom !== undefined) writeFileSync(path.join(staging, "classmap.json"), classmapBytes); - else writeText(path.join(staging, "classmap.json"), renderJson(classmap)); - if (overlay) writeText(path.join(staging, "css-map.json"), renderJson(overlay)); + writeFileSync(path.join(staging, "classmap.json"), classmapBytes); + if (overlayText) writeText(path.join(staging, "css-map.json"), overlayText); writeText(path.join(staging, "META.json"), renderJson(meta)); writeText(path.join(staging, "VERIFICATION.md"), verification); const errors = keyErrors(staging, targetKey, o.root); if (errors.length) throw new Error(`published key fails validation: ${errors.join("; ")}`); + if (exists) { + previous = mkdtempSync(path.join(o.root, `.${targetKey}-previous-`)); + renameSync(targetDir, path.join(previous, targetKey)); + } renameSync(staging, targetDir); + const broken = inheritors(o.root, targetKey).flatMap((key) => + keyErrors(path.join(o.root, key), key, o.root).map((error) => `${key}/${error}`), + ); + if (broken.length) throw new Error(`keys inheriting from ${targetKey} would break: ${broken.join("; ")}`); + if (index) writeIndex(o.root); } catch (e) { + if (index) { + if (previousIndex === undefined) rmSync(indexPath, { force: true }); + else writeFileSync(indexPath, previousIndex); + } + if (previous) { + rmSync(targetDir, { recursive: true, force: true }); + renameSync(path.join(previous, targetKey), targetDir); + } else if (!exists) { + rmSync(targetDir, { recursive: true, force: true }); + } rmSync(staging, { recursive: true, force: true }); + if (previous) rmSync(previous, { recursive: true, force: true }); throw e; } + if (previous) rmSync(previous, { recursive: true, force: true }); return targetDir; } export function publishAndIndex(o: PublishOptions): string { - const indexPath = path.join(o.root, "index.json"); - const previous = existsSync(indexPath) ? readFileSync(indexPath) : undefined; - const target = publishRelease(o); - try { - writeIndex(o.root); - } catch (e) { - rmSync(target, { recursive: true, force: true }); - if (previous === undefined) rmSync(indexPath, { force: true }); - else writeFileSync(indexPath, previous); - throw e; - } - return target; + return publishRelease(o, true); } function main(): number { @@ -350,16 +474,19 @@ function main(): number { options: { "inherit-from": { type: "string" }, classmap: { type: "string" }, - "css-map": { type: "string" }, + overlay: { type: "string" }, "derived-from": { type: "string" }, + "migrate-report": { type: "string" }, "required-paths-from": { type: "string" }, + "drop-required": { type: "string", multiple: true, default: [] }, stale: { type: "string", multiple: true, default: [] }, note: { type: "string", multiple: true, default: [] }, + replace: { type: "boolean", default: false }, "spotify-version": { type: "string" }, "static-report": { type: "string" }, "cdp-report": { type: "string" }, root: { type: "string", default: ROOT }, - generated: { type: "string", default: new Date().toISOString().slice(0, 10) }, + generated: { type: "string", default: localDate() }, "min-hit-rate": { type: "string", default: String(MIN_HIT_RATE) }, help: { type: "boolean", short: "h" }, }, @@ -372,21 +499,25 @@ function main(): number { for (const flag of ["spotify-version", "static-report", "cdp-report"] as const) { if (!values[flag]) throw new Error(`--${flag} is required`); } - const target = publishAndIndex({ + const options: PublishOptions = { root: values.root, spotifyVersion: values["spotify-version"] as string, - staticReport: JSON.parse(readFileSync(values["static-report"] as string, "utf8")), - cdpReport: JSON.parse(readFileSync(values["cdp-report"] as string, "utf8")), + staticReport: JSON.parse(readUtf8(values["static-report"] as string)), + cdpReport: JSON.parse(readUtf8(values["cdp-report"] as string)), generated: values.generated, minHitRate: Number(values["min-hit-rate"]), inheritFrom: values["inherit-from"], classmapPath: values.classmap, - overlayPath: values["css-map"], + overlayPath: values.overlay, derivedFrom: values["derived-from"], + migrateReport: values["migrate-report"] ? JSON.parse(readUtf8(values["migrate-report"])) : undefined, requiredFrom: values["required-paths-from"], + dropRequired: values["drop-required"], stale: values.stale, notes: values.note, - }); + replace: values.replace, + }; + const target = publishAndIndex(options); console.log(`published ${path.basename(target)}: ${target}`); return 0; } catch (e) { diff --git a/scripts/validate-classmaps.test.mts b/scripts/validate-classmaps.test.mts index 3ce894b..8a13fd0 100644 --- a/scripts/validate-classmaps.test.mts +++ b/scripts/validate-classmaps.test.mts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, test } from "node:test"; -import { renderJson } from "./lib.ts"; +import { renderJson, sha256 } from "./lib.ts"; import { keyErrors } from "./validate-classmaps.ts"; const classmap = { main: { topbar: { retired: "retiredHashBB", wrapper: "topbarHashAA" } } }; @@ -24,6 +24,7 @@ function meta(key: string, version: string, source: Record) { required_paths: { "main.topbar.wrapper": "verified_cdp" }, stale_leaves: ["main.topbar.retired"], unverified_leaves: [], + verified_classmap_sha256: null, }; } @@ -134,3 +135,37 @@ test("rejects group keys that would collide as dotted paths", () => { errors(child).includes('classmap.json: main: group key "topbar.wrapper" must be non-empty and contain no dots'), ); }); + +test("rejects a classmap whose bytes differ from the verified digest", () => { + childMeta.verified_classmap_sha256 = sha256(renderJson(classmap)); + writeKey("1020096", classmap, childMeta); + assert.deepEqual(errors(child), []); + childMeta.source = { method: "derived", key: "1020094" }; + childMeta.verified_classmap_sha256 = "0".repeat(64); + writeKey("1020096", classmap, childMeta); + assert.ok( + errors(child).includes("classmap.json: its bytes differ from the map that was verified; republish the key with --replace"), + ); +}); + +test("rejects a null classmap and invalid UTF-8", () => { + writeFileSync(path.join(child, "classmap.json"), "null\n"); + assert.ok(errors(child).includes("classmap.json: : must be a group or a class string")); + writeFileSync(path.join(child, "classmap.json"), Buffer.from([0x7b, 0x22, 0xff, 0x22, 0x3a, 0x22, 0x61, 0x22, 0x7d, 0x0a])); + assert.ok(errors(child).includes("classmap.json: not valid UTF-8")); +}); + +test("treats Python's whitespace as class separators", () => { + writeKey("1020096", { main: { topbar: { retired: "retiredHashBB", wrapper: "b\u001cc" } } }, childMeta); + assert.ok( + errors(child).includes("classmap.json: main.topbar.wrapper: classes must be single-space separated with no padding"), + ); +}); + +test("reports a directory named like a published file instead of crashing", () => { + mkdirSync(path.join(child, "css-map.json")); + assert.doesNotThrow(() => errors(child)); + rmSync(path.join(child, "VERIFICATION.md")); + mkdirSync(path.join(child, "VERIFICATION.md")); + assert.ok(errors(child).includes("VERIFICATION.md: required beside META.json")); +}); diff --git a/scripts/validate-classmaps.ts b/scripts/validate-classmaps.ts index d755929..db651e4 100644 --- a/scripts/validate-classmaps.ts +++ b/scripts/validate-classmaps.ts @@ -11,10 +11,22 @@ // // node scripts/validate-classmaps.ts -import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; -import { type Classmap, KEY_DIR, keyDirs, leafValues, renderJson, ROOT, versionToKey } from "./lib.ts"; +import { + type Classmap, + compareCodePoints, + isFile, + KEY_DIR, + keyDirs, + leafValues, + readUtf8, + renderJson, + ROOT, + sha256, + versionToKey, +} from "./lib.ts"; const META_FIELDS = [ "classmap_key", @@ -26,6 +38,7 @@ const META_FIELDS = [ "stale_leaves", "status", "unverified_leaves", + "verified_classmap_sha256", ]; const STATUSES = ["unverified", "verified"]; const SOURCE_METHODS = ["derived", "inherited"]; @@ -33,6 +46,9 @@ const PATH_STATUSES = ["unverified", "verified", "verified_cdp", "verified_stati const KNOWN_FILES = new Set(["classmap.json", "css-map.json", "META.json", "VERIFICATION.md"]); const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; const SPICETIFY_NAME = /^(main|x)-[A-Za-z0-9]+-/; +const SHA256 = /^[0-9a-f]{64}$/; +// The characters Python's str.split() treats as whitespace. +const WHITESPACE = /[\t\n\v\f\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+/; type Meta = Record; @@ -43,7 +59,7 @@ function isObject(value: unknown): value is Record { function classmapErrors(node: unknown, parts: string[] = []): string[] { const where = parts.join(".") || ""; if (typeof node === "string") { - const normal = node.split(/\s+/).filter(Boolean).join(" "); + const normal = node.split(WHITESPACE).filter(Boolean).join(" "); return node && node === normal ? [] : [`${where}: classes must be single-space separated with no padding`]; } if (!isObject(node)) return [`${where}: must be a group or a class string`]; @@ -60,9 +76,10 @@ function classmapErrors(node: unknown, parts: string[] = []): string[] { } function loadCanonical(file: string, errors: string[]): unknown { - const raw = readFileSync(file, "utf8"); + let raw: string; let value: unknown; try { + raw = readUtf8(file); value = JSON.parse(raw); } catch (e) { errors.push(`${path.basename(file)}: ${(e as Error).message}`); @@ -80,7 +97,7 @@ function stringList(meta: Meta, field: string, leaves: Map, erro errors.push(`META.json: ${field} must be a list of paths`); return new Set(); } - const sorted = [...new Set(items)].sort(); + const sorted = [...new Set(items)].sort(compareCodePoints); if (sorted.length !== items.length || sorted.some((item, i) => item !== items[i])) { errors.push(`META.json: ${field} must be sorted and unique`); } @@ -134,7 +151,7 @@ function metaErrors(meta: unknown, key: string, leaves: Map, roo errors.push("META.json: source.key must be null or an older seven-digit key"); } else if (source.method === "inherited") { const parent = source.key === null ? "" : path.join(root, source.key as string, "classmap.json"); - if (!parent || !existsSync(parent)) { + if (!parent || !isFile(parent)) { errors.push("META.json: an inherited map needs an existing source.key"); } else if ((source.key as string).slice(0, 3) !== key.slice(0, 3)) { errors.push("META.json: an inherited map must come from the same major.minor family"); @@ -143,6 +160,13 @@ function metaErrors(meta: unknown, key: string, leaves: Map, roo } } + const digest = meta.verified_classmap_sha256; + if (digest !== null && !(typeof digest === "string" && SHA256.test(digest))) { + errors.push("META.json: verified_classmap_sha256 must be null or a SHA-256 digest"); + } else if (digest !== null && digest !== sha256(classmap)) { + errors.push("classmap.json: its bytes differ from the map that was verified; republish the key with --replace"); + } + const stale = stringList(meta, "stale_leaves", leaves, errors); const unverified = stringList(meta, "unverified_leaves", leaves, errors); for (const leaf of [...stale].filter((p) => unverified.has(p)).sort()) { @@ -174,7 +198,7 @@ export function keyErrors(keyDir: string, key = path.basename(keyDir), root = RO } const classmapPath = path.join(keyDir, "classmap.json"); - if (!existsSync(classmapPath)) return [...errors, "classmap.json: missing"]; + if (!isFile(classmapPath)) return [...errors, "classmap.json: missing"]; const classmap = loadCanonical(classmapPath, errors); if (classmap === undefined) return errors; const treeErrors = classmapErrors(classmap); @@ -184,7 +208,7 @@ export function keyErrors(keyDir: string, key = path.basename(keyDir), root = RO let semantic = new Set(); const overlayPath = path.join(keyDir, "css-map.json"); - if (existsSync(overlayPath)) { + if (isFile(overlayPath)) { const overlay = loadCanonical(overlayPath, errors); if (overlay !== undefined) { const valid = @@ -194,7 +218,7 @@ export function keyErrors(keyDir: string, key = path.basename(keyDir), root = RO } } for (const [leaf, value] of leaves) { - for (const token of value.split(" ")) { + for (const token of value.split(WHITESPACE)) { if (semantic.has(token) || SPICETIFY_NAME.test(token)) { errors.push(`classmap.json: ${leaf} stores the Spicetify name ${token}, not the stock class`); } @@ -202,10 +226,10 @@ export function keyErrors(keyDir: string, key = path.basename(keyDir), root = RO } const metaPath = path.join(keyDir, "META.json"); - if (existsSync(metaPath)) { + if (isFile(metaPath)) { const meta = loadCanonical(metaPath, errors); if (meta !== undefined) errors.push(...metaErrors(meta, key, leaves, root, readFileSync(classmapPath))); - if (!existsSync(path.join(keyDir, "VERIFICATION.md"))) { + if (!isFile(path.join(keyDir, "VERIFICATION.md"))) { errors.push("VERIFICATION.md: required beside META.json"); } } From 578b55af624c9b9dae7c33dec1e296883c01e99e Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 01:02:44 +0200 Subject: [PATCH 20/20] docs: document the full publishing pipeline and correct the 1.3.0 CSS coverage --- 1030000/VERIFICATION.md | 1 + AGENTS.md | 6 +- README.md | 139 ++++++++++++++++++++++++++++------------ 3 files changed, 102 insertions(+), 44 deletions(-) diff --git a/1030000/VERIFICATION.md b/1030000/VERIFICATION.md index dbf01bf..d5b39aa 100644 --- a/1030000/VERIFICATION.md +++ b/1030000/VERIFICATION.md @@ -9,6 +9,7 @@ Pipeline: exact stock-CSS signature migration from 1020097 + static target-CSS v - Historical deep CDP runs observed 23/36 paths on stock macOS and 24/36 paths on the patched Windows 1.3.0.277 client. The Windows report records 7/8 successful navigation steps; context-menu activation was not confirmed. - Targeted CDP probes verified both modal families and the two settings container hashes that the deep verifier did not render. - The remaining search chip-group and Encore control literals are absent from the static xpui stylesheet because Encore loads them at runtime; exact e-10860 selectors were verified recursively in the live CSSOM and the corresponding chip/control instances were observed in the DOM. +- Correction, 2026-09-30: those Encore classes are in the stock stylesheet, inside `@layer encore`, which the static verifier then skipped. Rechecked with the fixed verifier, 33/36 leaves are present in the stock 1.3.0.277 CSS, not 7. - Confirmed settings.section.container (HdwEUcX8xijMdU7doYPt -> x-settings-section) and settings.header.container (Hwax_pfCzrntLhnYXiqL -> x-settings-outputSectionHeader) bridge correctly across both platforms. - The source artifacts were Spotify's notarized 1.3.0.277 macOS DMG and Windows Desktop 1.3.0.277 stock installation. - The topbar, navigation-link and outer search-container leaves retain their original role-specific classes. The Windows-observed opacity, no-drag and inner-form helper classes coexist with them and are not substitutes for their layout or styling. diff --git a/AGENTS.md b/AGENTS.md index 4b16001..c5df474 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,11 +4,11 @@ The README is the reference for the key layout, the META schema, and the publishing steps. This file holds what the README and the scripts don't make obvious. -- Generate every published file. `pnpm publish-key` writes a new key's +- Generate every published file. `pnpm publish-key` writes a key's `META.json` and `VERIFICATION.md`, `pnpm fix` formats JSON and rebuilds `index.json`, and `pnpm check` is done when it prints `all checks passed`. - To change an existing key, edit its `classmap.json`, `css-map.json` or - `META.json` fields, then run `pnpm fix`. + To change a published key's classes, verify the corrected map and run + `pnpm publish-key --replace`; a hand edit fails the verified digest check. - Leaves hold the class exactly as the stock Spotify archive ships it. A class observed on a patched client has already been renamed by the css-map, so translate it back to the one stock hash the effective css-map (the CLI's diff --git a/README.md b/README.md index 5b8746b..0272272 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ counts, or run details, in `VERIFICATION.md`. | `required_paths` | Status of each path the ecosystem depends on. | | `stale_leaves` | Paths known to be wrong. The CLI refuses to resolve them. | | `unverified_leaves` | Paths not observed on this build. They still resolve. | +| `verified_classmap_sha256` | Digest of the `classmap.json` the reports verified, or `null` for keys published before September 30, 2026. | An `inherited` key's `classmap.json` must be byte-identical to its source key's map. If you change a map, you must also change every key that inherits @@ -56,7 +57,9 @@ Each `required_paths` value is one of the following statuses: - `verified`: verified before the method was recorded (1020092 and 1020094). - `unverified`: not observed. The path must also be in `unverified_leaves`. -A required path can't be stale. +A required path can't be stale. When `verified_classmap_sha256` is set, the +validator rejects a `classmap.json` whose bytes differ from it, so a map +changed after verification has to be verified again. Digests recorded in `VERIFICATION.md` identify the exact file that was tested. On September 30, 2026, every map was renamed to `classmap.json` and @@ -68,72 +71,126 @@ match the published bytes, but the classes the CLI stages are unchanged. You never write `META.json`, `VERIFICATION.md`, or `index.json` by hand. `pnpm publish-key` generates them from two CLI verification reports, -formats the map and overlay, validates the new key, and rebuilds the index. +validates the new key and every key inheriting from it, and rebuilds the index. It refuses mismatched versions, maps, leaf values, shallow CDP runs, -inconsistent summaries, low hit rates, and existing targets. If any step -fails, it rolls back both the key and `index.json`. +inconsistent summaries, low hit rates, and existing keys. If any step fails, it +rolls back both the key and `index.json`. Run `pnpm publish-key --help` for +every flag. -To publish a key, follow these steps: +The CLI commands below run from a spicetify/cli checkout next to this +repository. They need the stock `xpui.spa` of each Spotify build involved, taken +before Spicetify patched it. Spicetify keeps that copy at +`~/Library/Application Support/spicetify/Backup/xpui.spa` on macOS and +`~/.local/state/spicetify/Backup/xpui.spa` on Linux; an unapplied client has it +at `Spotify.app/Contents/Resources/Apps/xpui.spa`. The CDP verifier needs +Spotify running with `--remote-debugging-port`, and `scripts/classmap-e2e.sh` +in the CLI sets that up for you. -1. From the CLI repository, verify the candidate map against the stock client - of the new build. For an unchanged patch release, the candidate is the - previous key's `classmap.json`. For a derived key, it's the output of - `classmap_capture.py migrate` or your edited copy. +### Unchanged patch release + +When a patch release keeps the previous key's classes, verify that key's map +against the new build and publish it as inherited: + +1. From the CLI repository, produce both reports for the previous key's map: ```sh - python3 scripts/classmap_capture.py verify \ - --classmap ../classmaps/1020094/classmap.json \ - --css-map css-map.json \ - --target-spa "/path/to/stock/xpui.spa" \ - --target-version 1.2.96.518 \ + node scripts/classmap-capture.ts verify \ + --classmap ../classmaps/1020094/classmap.json --css-map css-map.json \ + --target-spa "/path/to/stock/xpui.spa" --target-version 1.2.96.518 \ --out /tmp/1020096-static.json - node scripts/classmap_cdp_verify.mjs \ - --port 9229 --mode both --deep \ - --classmap ../classmaps/1020094/classmap.json \ - --css-map css-map.json \ + node scripts/classmap-cdp-verify.mjs --port 9229 --mode both --deep \ + --classmap ../classmaps/1020094/classmap.json --css-map css-map.json \ --out /tmp/1020096-cdp.json ``` -2. Inspect the reports. If no leaf needs a new class, publish an inherited key - from this repository: +2. From this repository, publish the inherited key: + + ```sh + pnpm publish-key --inherit-from 1020094 --spotify-version 1.2.96.518 \ + --static-report /tmp/1020096-static.json --cdp-report /tmp/1020096-cdp.json + ``` + +3. Run `pnpm check` and open a pull request. + +### Changed classes + +When Spotify rehashes classes, migrate the previous key's map to the new build +and publish the result as a derived key: + +1. From the CLI repository, migrate the map, using the stock archives of both + builds: + + ```sh + node scripts/classmap-capture.ts migrate \ + --base-classmap ../classmaps/1030001/classmap.json \ + --base-spa /path/to/1.3.1/xpui.spa --target-spa /path/to/1.3.2/xpui.spa \ + --css-map css-map.json \ + --out /tmp/1030002.json --report /tmp/1030002-migrate.json --allow-partial + ``` + +2. Read `unmatched` in the migrate report. Each entry kept its old class and is + published as stale unless you fix it. Entries with a `tied` list had several + equally good candidates; pick the right one by checking its role in the stock + CSS or live DOM, write it into `/tmp/1030002.json`, and run + `pnpm fix /tmp/1030002.json` to restore canonical form. + +3. Generate the overlay that gives the new classes their Spicetify names: ```sh - pnpm publish-key --inherit-from 1020094 \ - --spotify-version 1.2.96.518 \ - --static-report /tmp/1020096-static.json \ - --cdp-report /tmp/1020096-cdp.json + node scripts/classmap-capture.ts flatten \ + --classmap /tmp/1030002.json --base-classmap ../classmaps/1030001/classmap.json \ + --css-map css-map.json --report /tmp/1030002-migrate.json \ + --out /tmp/1030002-overlay.json --allow-partial ``` - Otherwise, publish the candidate as a derived key. Pass each decision the - reports can't make as a flag: `--stale PATH` for a leaf whose class is known - to be wrong, and `--note TEXT` for anything a reviewer needs to know. +4. Verify the candidate with the same two commands as an unchanged release, + passing `--classmap /tmp/1030002.json`, `--target-version 1.3.2.100`, and + `--report /tmp/1030002-migrate.json` to `verify`. Alternatively, run the whole + CLI side in one command with + `SPOTIFY_VERSION=1.3.2.100 BASE_CLASSMAP=... BASE_CSS_DIR=... OUT_DIR=... scripts/classmap-e2e.sh --deep`. + +5. From this repository, publish the derived key: ```sh - pnpm publish-key --classmap /tmp/1030002.json \ - --css-map /tmp/1030002-css-map.json --derived-from 1030001 \ + pnpm publish-key --classmap /tmp/1030002.json --overlay /tmp/1030002-overlay.json \ + --derived-from 1030001 --migrate-report /tmp/1030002-migrate.json \ --spotify-version 1.3.2.100 \ - --static-report /tmp/1030002-static.json \ - --cdp-report /tmp/1030002-cdp.json \ - --stale main.topbar.right.upgrade_button.wrapper \ - --note "The upgrade button has no rendered instance on a premium account." + --static-report /tmp/1030002-static.json --cdp-report /tmp/1030002-cdp.json ``` -3. Run `pnpm check` and open a pull request. + The source key's stale leaves whose class didn't change, and the leaves the + migrate report kept, stay stale. Add `--stale PATH` for any other leaf whose + class you know is wrong, `--drop-required PATH` for a required path the new + map no longer has, and `--note TEXT` for anything a reviewer needs to know. + +6. Run `pnpm check` and open a pull request. + +### Fixing a published key + +To change the classes of a published key, verify the corrected map and publish +it over the key with `--replace`. The key keeps its first publication date and +records the new verification. A key that inherits from it must still match +byte-for-byte, or the replacement is refused. + +### How statuses are decided -The publisher marks a path `unverified` when it's missing from the target CSS -and wasn't observed live. An inherited key also keeps its source's -`unverified` paths until the deep CDP run observes them, and its stale paths -until a live hit clears them. A derived key tracks the `required_paths` of -`--derived-from`, or of the newest key when you don't pass one. +A path is `unverified` when it's missing from the stock target CSS and the deep +CDP run didn't observe it. For such a path only a hit on its stock class +counts: a hit on its Spicetify name can come from a different class the +css-map renames to the same name. An inherited key also keeps its source's +`unverified` paths until the CDP run observes them, and its stale paths until +a live hit clears them. A derived key tracks the `required_paths` of +`--derived-from`, of `--required-paths-from`, or of the newest verified key. ## Checks `pnpm check` runs everything CI runs: the type check, the index check, the key validator, the exposure patch validator, and the unit tests. The scripts are -TypeScript that Node 24 or later runs directly, and `pnpm install` only adds +TypeScript that Node 24.2 or later runs directly, and `pnpm install` only adds the type checker. Run `pnpm fix` to rewrite every JSON file in canonical form -and rebuild `index.json`. +and rebuild `index.json`, or `pnpm fix FILE` to format one file outside the +repository, such as a hand-edited candidate map. ## Exposure patches