diff --git a/.github/workflows/index.yml b/.github/workflows/index.yml index 1919704..3ac079f 100644 --- a/.github/workflows/index.yml +++ b/.github/workflows/index.yml @@ -13,11 +13,17 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Verify index - run: python3 scripts/build_index.py --check + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - name: Test classmap contracts and promotion - run: python3 -m unittest discover -s scripts + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: pnpm - - name: Validate exposure patches - run: python3 scripts/validate_expose.py + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run classmap checks + run: pnpm check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05d3a73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +/.[0-9][0-9][0-9][0-9][0-9][0-9][0-9]-*/ diff --git a/1020038/classmap-1718192021222.json b/1020038/classmap-1718192021222.json deleted file mode 100644 index acc7879..0000000 --- a/1020038/classmap-1718192021222.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "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" - } - } - } - } -} diff --git a/1020038/classmap.json b/1020038/classmap.json new file mode 100644 index 0000000..14b7560 --- /dev/null +++ b/1020038/classmap.json @@ -0,0 +1,34 @@ +{ + "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-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-190747c4b8f.json deleted file mode 100644 index de002b9..0000000 --- a/1020040/classmap-190747c4b8f.json +++ /dev/null @@ -1,109 +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" - }, - "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" - } -} diff --git a/1020040/classmap.json b/1020040/classmap.json new file mode 100644 index 0000000..cdde094 --- /dev/null +++ b/1020040/classmap.json @@ -0,0 +1,104 @@ +{ + "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-191b119b48e.json b/1020045/classmap-191b119b48e.json deleted file mode 100644 index efc3acf..0000000 --- a/1020045/classmap-191b119b48e.json +++ /dev/null @@ -1,312 +0,0 @@ -{ - "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" - } -} diff --git a/1020045/classmap.json b/1020045/classmap.json new file mode 100644 index 0000000..7cf387d --- /dev/null +++ b/1020045/classmap.json @@ -0,0 +1,311 @@ +{ + "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/META.json b/1020084/META.json index 01780cd..0681f71 100644 --- a/1020084/META.json +++ b/1020084/META.json @@ -1,56 +1,37 @@ { - "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 - } + "verified_classmap_sha256": null } 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/1020084/classmap-1a05f280501.json b/1020084/classmap.json similarity index 99% rename from 1020084/classmap-1a05f280501.json rename to 1020084/classmap.json index 9d1a214..d293661 100644 --- a/1020084/classmap-1a05f280501.json +++ b/1020084/classmap.json @@ -101,4 +101,4 @@ "tracklist": { "column_header": "qev2KFBSKCHkeXT4fDTl" } -} \ No newline at end of file +} diff --git a/1020092/META.json b/1020092/META.json index 04e006d..91315e7 100644 --- a/1020092/META.json +++ b/1020092/META.json @@ -1,53 +1,33 @@ { - "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" ], + "status": "verified", "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)." - ] + "verified_classmap_sha256": null } 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/1020092/classmap-19f8522e902.json b/1020092/classmap.json similarity index 78% rename from 1020092/classmap-19f8522e902.json rename to 1020092/classmap.json index 9897a06..1667337 100644 --- a/1020092/classmap-19f8522e902.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/META.json b/1020094/META.json index ce67dd6..9969967 100644 --- a/1020094/META.json +++ b/1020094/META.json @@ -1,64 +1,34 @@ { - "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." - } + "verified_classmap_sha256": null } 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/1020096/classmap-19f856aefd5.json b/1020094/classmap.json similarity index 78% rename from 1020096/classmap-19f856aefd5.json rename to 1020094/classmap.json index dd8e490..43b1b36 100644 --- a/1020096/classmap-19f856aefd5.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/META.json b/1020095/META.json index 882d9c6..a5f6054 100644 --- a/1020095/META.json +++ b/1020095/META.json @@ -1,74 +1,35 @@ { - "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" - } + "verified_classmap_sha256": null } 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/1020095/classmap-1020095.json b/1020095/classmap.json similarity index 79% rename from 1020095/classmap-1020095.json rename to 1020095/classmap.json index fe399ff..64274d8 100644 --- a/1020095/classmap-1020095.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/META.json b/1020096/META.json index b7dc4d2..12038e8 100644 --- a/1020096/META.json +++ b/1020096/META.json @@ -1,52 +1,34 @@ { - "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 - } + "verified_classmap_sha256": null } 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/1020094/classmap-19f856aefd5.json b/1020096/classmap.json similarity index 78% rename from 1020094/classmap-19f856aefd5.json rename to 1020096/classmap.json index dd8e490..43b1b36 100644 --- a/1020094/classmap-19f856aefd5.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/META.json b/1020097/META.json index 9f8c7ad..053c336 100644 --- a/1020097/META.json +++ b/1020097/META.json @@ -1,53 +1,35 @@ { - "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 - } + "verified_classmap_sha256": null } 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/1020097/classmap-19f856aefd5.json b/1020097/classmap.json similarity index 78% rename from 1020097/classmap-19f856aefd5.json rename to 1020097/classmap.json index dd8e490..43b1b36 100644 --- a/1020097/classmap-19f856aefd5.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/META.json b/1020098/META.json index caeea87..6326085 100644 --- a/1020098/META.json +++ b/1020098/META.json @@ -1,53 +1,35 @@ { - "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 - } + "verified_classmap_sha256": null } 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/1020098/classmap-19f856aefd5.json b/1020098/classmap.json similarity index 78% rename from 1020098/classmap-19f856aefd5.json rename to 1020098/classmap.json index dd8e490..43b1b36 100644 --- a/1020098/classmap-19f856aefd5.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/META.json b/1020099/META.json index 2f700f1..81f5e3d 100644 --- a/1020099/META.json +++ b/1020099/META.json @@ -1,91 +1,32 @@ { - "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." - } - } + "verified_classmap_sha256": null } 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/1020099/classmap-19f856aefd5.json b/1020099/classmap.json similarity index 64% rename from 1020099/classmap-19f856aefd5.json rename to 1020099/classmap.json index ec63a41..ebec63a 100644 --- a/1020099/classmap-19f856aefd5.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": "main-globalNav-navLink", - "container": "main-globalNav-navLink", - "container__active": "main-globalNav-navLinkActive" - } - }, - "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": { - "widget_generator": { - "container": "main-embedWidgetGenerator-container", - "header": { - "container": "main-embedWidgetGenerator-header", - "close": "main-embedWidgetGenerator-closeBtn" - }, + "track_credits": { + "container": "p5LJBmlswjcRT458", "content": { - "container": "main-embedWidgetGenerator-content" + "container": "yKG9lWaUWIBUyVCu" + }, + "header": { + "close": "_18qFNPzv7Eyyck8", + "container": "idsrX6VAUY49Eczw" } }, - "track_credits": { - "container": "main-trackCreditsModalV2-container", - "header": { - "container": "main-trackCreditsModalV2-header", - "close": "main-trackCreditsModalV2-closeBtn" - }, + "widget_generator": { + "container": "gx9qsprOAO4ubEFc", "content": { - "container": "main-trackCreditsModalV2-mainSection" + "container": "ywtSYUL2xRwVlvSo" + }, + "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": "x-settings-outputSectionHeader" + "container": "KsnD09q3oqAjJX3e" }, "section": { - "container": "x-settings-section" - }, - "button": { - "wrapper": "e-10810-legacy-button e-10810-legacy-button--small e-10810-legacy-button-secondary" + "container": "fNaaQ0Cp8Yzy19j8" }, "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": "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" - }, - "context_menu": { - "menu_item": "main-contextMenu-menuItemButton" + "tracklist": { + "column_header": "e-10810-legacy-list-row__header" } } diff --git a/1030000/META.json b/1030000/META.json index 9d19945..96c40b1 100644 --- a/1030000/META.json +++ b/1030000/META.json @@ -1,159 +1,29 @@ { - "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" + "settings.header.container": "verified_targeted", + "settings.section.container": "verified_targeted", + "sort_box.list.button": "verified_cdp", + "tracklist.column_header": "verified_cdp" }, + "schema_version": 2, + "source": { + "key": "1020097", + "method": "derived" + }, + "spotify_version": "1.3.0.277", "stale_leaves": [], + "status": "verified", "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 - }, - "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." - } - }, - "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." - } - } + "verified_classmap_sha256": null } diff --git a/1030000/VERIFICATION.md b/1030000/VERIFICATION.md new file mode 100644 index 0000000..d5b39aa --- /dev/null +++ b/1030000/VERIFICATION.md @@ -0,0 +1,137 @@ +# 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. +- 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. +- 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/classmap-19f856aefd5.json b/1030000/classmap.json similarity index 82% rename from 1030001/classmap-19f856aefd5.json rename to 1030000/classmap.json index f6d2cea..27c8ec6 100644 --- a/1030001/classmap-19f856aefd5.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/META.json b/1030001/META.json index 909384f..44441aa 100644 --- a/1030001/META.json +++ b/1030001/META.json @@ -1,83 +1,31 @@ { - "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 - } - } + "verified_classmap_sha256": null } 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/1030000/classmap-19f856aefd5.json b/1030001/classmap.json similarity index 70% rename from 1030000/classmap-19f856aefd5.json rename to 1030001/classmap.json index 0d5982b..27c8ec6 100644 --- a/1030000/classmap-19f856aefd5.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": { - "widget_generator": { - "container": "main-embedWidgetGenerator-container", - "header": { - "container": "main-embedWidgetGenerator-header", - "close": "main-embedWidgetGenerator-closeBtn" - }, + "track_credits": { + "container": "gtXQasDkhtg93wGk6yNG", "content": { - "container": "main-embedWidgetGenerator-content" + "container": "wYQNBdH2rNMGe_B4oumf" + }, + "header": { + "close": "CaHvWv36mAu4x5QTKlb1", + "container": "nQ_c7yoZ2XOD6fWtmv18" } }, - "track_credits": { - "container": "main-trackCreditsModalV2-container", - "header": { - "container": "main-trackCreditsModalV2-header", - "close": "main-trackCreditsModalV2-closeBtn" - }, + "widget_generator": { + "container": "_XeAt3IgtZQepOYqU2g4", "content": { - "container": "main-trackCreditsModalV2-mainSection" + "container": "Xu_Z4l6TIOF1f5kDB9dt" + }, + "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": "x-sortBox-sortDropdown" + "button": "MYWr4UY4pKAAscUXvqrB" } }, - "search_box": { - "container": "mlx0CgXAnGz0dADVsCdS", - "expand_button": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium aslJk0YypwKvyKXaBJcG" - }, - "context_menu": { - "menu_item": "main-contextMenu-menuItemButton" + "tracklist": { + "column_header": "e-10860-legacy-list-row__header" } } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c5df474 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# 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. `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 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 + `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. +- 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 ed067ff..0272272 100644 --- a/README.md +++ b/README.md @@ -5,57 +5,202 @@ 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. -## Unchanged patch 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. `pnpm check` 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. | +| `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 +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. 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 +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. + +## Publishing a key + +You never write `META.json`, `VERIFICATION.md`, or `index.json` by hand. +`pnpm publish-key` generates them from two CLI verification reports, +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 keys. If any step fails, it +rolls back both the key and `index.json`. Run `pnpm publish-key --help` for +every flag. + +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. + +### 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 + 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 \ + --out /tmp/1020096-cdp.json + ``` + +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 + 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 + ``` + +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 --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 + ``` + + 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 + +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. -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-19f856aefd5.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-19f856aefd5.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 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. - -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. +## 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.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`, or `pnpm fix FILE` to format one file outside the +repository, such as a hand-edited candidate map. ## 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 -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. +`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 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 @@ -68,15 +213,26 @@ 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 -python3 scripts/build_index.py +pnpm 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. 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 3e5912d..b4dbed7 100644 --- a/index.json +++ b/index.json @@ -1,183 +1,183 @@ { - "expose": { - "file": "expose.json", - "sha256": "3c609aca27255ad637cae51c1355482ef02fe6c1e61156d3e82039e1fe6a372a" - }, - "keys": { - "1020038": { - "classmap": { - "file": "classmap-1718192021222.json", - "sha256": "29522d09a1de092d6ba4c68f249d8a0bcfa1539d0c0f74f9347328443ec09d73" - } - }, - "1020040": { - "classmap": { - "file": "classmap-190747c4b8f.json", - "sha256": "d281c6c73b4ae40d9bddc5ade971b818a69325b0626e5347a3a47615df2fcde3" - } - }, - "1020045": { - "classmap": { - "file": "classmap-191b119b48e.json", - "sha256": "97dd9b8882fa3e1eb39b63d6d52303a6550147e8553276ddf7639dcfde9b29ef" - } - }, - "1020084": { - "classmap": { - "file": "classmap-1a05f280501.json", - "sha256": "e5800bf048dfe57043d5e3611da043d44f3480e1fa6dba4781085f9ec37c784e" - }, - "meta": { - "file": "META.json", - "sha256": "0670e8c0d8e34b31612f986ba1bb3ab917c38fdbd4006255b4f27efdb48093ba" - }, - "spotifyVersion": "1.2.84.476", - "status": "verified" - }, - "1020092": { - "classmap": { - "file": "classmap-19f8522e902.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-19f856aefd5.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-1020095.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-19f856aefd5.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-19f856aefd5.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-19f856aefd5.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-19f856aefd5.json", - "sha256": "d015b23c52199fe287ad60f1da8c34c168349d6dd3b9f6a66e874bda1740c2e6" - }, - "cssMapOverlay": { - "file": "css-map.json", - "sha256": "b4d8016a12301d6be24369dba618093813ad33376617ee6a7fc651d06c959600" - }, - "meta": { - "file": "META.json", - "sha256": "11ef871fe8d029f94e138f442aede11190d068fa0b07febb776f28742d8f8a6d" - }, - "spotifyVersion": "1.2.99.317", - "status": "verified" - }, - "1030000": { - "classmap": { - "file": "classmap-19f856aefd5.json", - "sha256": "f0a79a96c949b016686a4b4f2feaefee6540652135799133bfa3d1b1411f18d5" - }, - "cssMapOverlay": { - "file": "css-map.json", - "sha256": "c0c993277547bfc8ec53f4fa0a055c6e6a470dc5dbb38e37e53ed13514f20e01" - }, - "meta": { - "file": "META.json", - "sha256": "f3c6870c099a73b1ddeea9361ed829675c0dcceacdb262fd96c4f8fd3c2fba21" - }, - "spotifyVersion": "1.3.0.277", - "status": "verified" - }, - "1030001": { - "classmap": { - "file": "classmap-19f856aefd5.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": "9d6ceb358e7e7d7e4099e0e8bce13bd9d9825e69741411b0d235eada8a48c655" + }, + "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": "842511cbd0e59665dbb3c08329283640c3059be197701437bfbe8a681cacf53a" + }, + "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": "2dadd1c404fe29bffb8db83ac84ae59fc9e76dee8bcf12e4710819d5218d5749" + }, + "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": "1e0e0e0a68e08b54110055adc1f1a9e01e798620b5b7b0a9244c5fb3f439d36d" + }, + "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": "a3df356bfd3a72ddc85ed65076e3f4fbaae7357c84fe5efa9e4b8abc2b8acfb2" + }, + "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": "981db11376e8aa077fa1a6c54c6e2131b882cb0be544676ccc18c5cb0e447a31" + }, + "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": "8dfa71738326b1507d389f57b1501d24d3030b99f8f036bb65efef2828450b01" + }, + "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": "d2aadb1c486736811930db20089b901683b06520146e0ad094844d452b92f247" + }, + "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": "1bd962d745663b48bd70e8c5e297856cc2965e20d27e8bd264f0553cc89ca45c" + }, + "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": "a22d944c39a622ae22d884eda92cc9c3949b6d2e338374ae9dc7a09d12d48fe9" + }, + "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": "b83a268923007a87f80eb9384f515d6f4622284751a25270408f9337d353fb62" + }, + "spotifyVersion": "1.3.1.234", + "status": "verified" + } + }, + "version": 1 } diff --git a/package.json b/package.json new file mode 100644 index 0000000..0adf712 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "spicetify-classmaps", + "private": true, + "type": "module", + "engines": { + "node": ">=24.2.0" + }, + "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": "^24.19.0", + "typescript": "^7.0.2" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..c1ea4f9 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,240 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^24.19.0 + version: 24.19.0 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + +packages: + + '@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==} + 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@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + +snapshots: + + '@types/node@24.19.0': + dependencies: + undici-types: 7.24.6 + + '@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@7.24.6: {} diff --git a/scripts/build-index.ts b/scripts/build-index.ts new file mode 100644 index 0000000..7cd9a8a --- /dev/null +++ b/scripts/build-index.ts @@ -0,0 +1,77 @@ +// 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 { readFileSync } from "node:fs"; +import path from "node:path"; + +import { isFile, keyDirs, readUtf8, 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 (!isFile(path.join(keyDir, "classmap.json"))) return null; + const entry: Entry = { classmap: fileRef(keyDir, "classmap.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"); + 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; +} + +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 (isFile(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 isFile(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 12f013a..0000000 --- a/scripts/build_index.py +++ /dev/null @@ -1,103 +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 re -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parent.parent -INDEX = ROOT / "index.json" -KEY_DIR = re.compile(r"^\d+$") - - -def sha256(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - - -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 - - -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 = json.dumps(build(), indent="\t", sort_keys=True) + "\n" - - 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_text(rendered, encoding="utf-8", newline="\n") - print(f"wrote {INDEX.relative_to(ROOT)} ({len(build()['keys'])} keys)") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/check.ts b/scripts/check.ts new file mode 100644 index 0000000..185054e --- /dev/null +++ b/scripts/check.ts @@ -0,0 +1,65 @@ +// Runs every data check CI runs, then the unit tests. With --fix, first +// 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, readUtf8, 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 { + 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 (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) { + 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..75d31b3 --- /dev/null +++ b/scripts/lib.ts @@ -0,0 +1,103 @@ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync, 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 }; + +/** 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") { + 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 JSON.stringify(value); +} + +/** Canonical form: 2-space indent, keys sorted by code point, trailing newline. */ +export function renderJson(value: unknown): string { + 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 { + 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/promote_inherited.py b/scripts/promote_inherited.py deleted file mode 100644 index 5b21b20..0000000 --- a/scripts/promote_inherited.py +++ /dev/null @@ -1,374 +0,0 @@ -#!/usr/bin/env python3 -"""Promote a verified classmap to an unchanged Spotify patch release. - -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. -""" - -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 - -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, - 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 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() - - -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): - 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 source_key[:3] != target_key[:3] or int(target_key) <= int(source_key): - raise ValueError(f"{target_key} is not a newer patch in the {source_key[:3]} family") - - 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") - if cdp_report.get("mode") != "both": - raise ValueError("promotion 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("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}") - - 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 updated_required_paths( - source_meta: dict, unverified: set[str], live_hits: set[str] -) -> dict[str, str]: - required = dict(source_meta.get("required_paths") or {}) - for path in required: - if path in live_hits: - 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)" - return required - - -def verification_notes( - source_key: str, - paths: set[str], - missing_in_target: set[str], - live_hits: set[str], -) -> list[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, - ( - f"Deep CDP verification observed {len(live_hits)}/{len(paths)} 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.", - ] - - -def build_meta( - source_key: str, - target_key: str, - spotify_version: str, - generated: str, - classmap: dict, - source_meta: dict, - static_report: dict, - cdp_report: dict, - 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, - "classmap_key": target_key, - "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, - }, - "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", {}), - } - - -def promote_inherited_release( - *, - root: Path, - source_key: str, - spotify_version: str, - static_report: dict, - cdp_report: dict, - generated: str, - min_hit_rate: float, -) -> Path: - 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 = classmap_file(source_dir) - 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, - ) - - overlay = source_dir / "css-map.json" - overlay_entries = len(json.loads(overlay.read_text())) if overlay.is_file() else 0 - meta = build_meta( - source_key, - target_key, - spotify_version, - generated, - classmap, - source_meta, - static_report, - cdp_report, - missing_in_target, - live_hits, - overlay_entries, - cdp_hit_rate, - ) - - 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( - json.dumps(meta, indent=2, allow_nan=False) + "\n", encoding="utf-8", newline="\n" - ) - staging_dir.rename(target_dir) - except Exception: - shutil.rmtree(staging_dir, ignore_errors=True) - raise - return target_dir - - -def publish_inherited_release(**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) - 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__) - parser.add_argument("--from-key", required=True) - 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) - args = parser.parse_args() - - target = publish_inherited_release( - 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()), - generated=args.generated, - min_hit_rate=args.min_hit_rate, - ) - print(f"promoted {args.from_key} to {target.name}: {target}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/publish-key.test.mts b/scripts/publish-key.test.mts new file mode 100644 index 0000000..c449a40 --- /dev/null +++ b/scripts/publish-key.test.mts @@ -0,0 +1,424 @@ +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 { type Classmap, leafValues, 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" }, + }, +}; +const inTargetCss = new Set(["main.topbar.wrapper"]); +let root: string; +let source: string; +let staticReport: Record; +let cdpReport: Record; + +/** 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 })), + }, + }; +} + +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" }, + 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, + inheritFrom: "1020094", + spotifyVersion: "1.2.96.518", + staticReport, + cdpReport, + generated: "2026-08-12", + minHitRate: 0.25, + ...changes, + }; +} + +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(".")); + +function setHit(leaf: string, hit: boolean, summary: Record): void { + const row = cdpReport.rows.find((r: { path: string }) => r.path === leaf); + row.hit = hit; + row.hashHit = hit; + Object.assign(cdpReport.summary, summary); +} + +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.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", () => { + 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("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()); + 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("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 = !cdpReport.rows[0].hit; + 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", () => { + const row = cdpReport.rows[0]; + const hash = row.hash; + row.hash = "anotherHashZZ"; + assert.throws(() => publishRelease(options()), /does not match classmap/); + 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")); +}); + +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, {}); + 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"]); +}); + +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()), /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("--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 overlay = path.join(root, "overlay.json"); + writeFileSync(overlay, '{"topbarHashAA":"Root__globalNav"}'); + const target = publishRelease( + 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"], "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", + "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("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, JSON.stringify(classmap, null, "\t")); + assert.throws( + () => 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 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 new file mode 100644 index 0000000..e6a78d7 --- /dev/null +++ b/scripts/publish-key.ts @@ -0,0 +1,529 @@ +// 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: +// +// 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 candidate map produced by `classmap-capture.ts migrate`: +// +// 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 +// +// 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 { 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, + 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; +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; + 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; + 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); + 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 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`); + 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`); + } +} + +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, + digest: string, + staticReport: Report, + cdpReport: Report, + minHitRate: number, +): Evidence { + 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 hits = [...cdpRows.values()].filter((row) => row.hit === true).length; + const total = cdpRows.size; + 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 !== hits || 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"); + } + 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) { + 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; + published: string; + method: string; + sourceKey: string | null; + leaves: number; + evidence: Evidence; + cdpReport: Report; + staticReport: Report; + classmapSha: string; + overlaySha: string | null; + overlayEntries: number; + notes: string[]; +}): string { + 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", live.size], + ["live_only", liveOnly], + ["unresolved_missing", missing.size - liveOnly], + ["cdp_hit_rate", 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). Published ${o.published}.`, + "", + "## Notes", + "", + origin, + `- Static verification found ${staticPresent}/${o.leaves} paths in the target CSS.`, + `- 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}`), + "", + "## Statistics at publication", + "", + "| Field | Value |", + "| --- | --- |", + ...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 newestVerifiedKey(root: string, below: string): string | undefined { + return keyDirs(root) + .filter((key) => key < below && isFile(path.join(root, key, "META.json")) && readMeta(root, key).status === "verified") + .at(-1); +} + +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"); + } + 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); + 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, inherited); + + let classmapBytes: Buffer; + let overlay: Record | undefined; + 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(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 evidence = checkReports(o.spotifyVersion, classmap, sha256(classmapBytes), o.staticReport, o.cdpReport, o.minHitRate); + const { missing, live, deadHashes } = evidence; + + const notes = [...(o.notes ?? [])]; + let stale: Set; + let doubted: Set; + let required: Record; + 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 { + 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 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 = inherited ? "inherited" : "derived"; + const overlayText = overlay ? renderJson(overlay) : null; + const meta = { + schema_version: 2, + classmap_key: targetKey, + spotify_version: o.spotifyVersion, + status: "verified", + generated, + source: { method, key: sourceKey }, + required_paths: requiredStatuses(required, unverified, live), + 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: 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, + }); + + 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 { + 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 { + return publishRelease(o, true); +} + +function main(): number { + const { values } = parseArgs({ + options: { + "inherit-from": { type: "string" }, + classmap: { 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: localDate() }, + "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 options: PublishOptions = { + root: values.root, + spotifyVersion: values["spotify-version"] as string, + 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.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) { + console.error(`error: ${(e as Error).message}`); + return 1; + } +} + +if (import.meta.main) process.exitCode = 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_promote_inherited.py b/scripts/test_promote_inherited.py deleted file mode 100644 index 74dd9a6..0000000 --- a/scripts/test_promote_inherited.py +++ /dev/null @@ -1,455 +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 promote_inherited as promote # noqa: E402 -import build_index # noqa: E402 - - -class PromoteInheritedTests(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-source.json").write_text(json.dumps(self.classmap) + "\n") - (self.source / "css-map.json").write_text('{"customHash": "semantic-name"}\n') - (self.source / "META.json").write_text( - json.dumps( - { - "spotify_version": "1.2.94.583", - "classmap_key": "1020094", - "status": "verified", - "stale_leaves": ["main.topbar.retired"], - "required_paths": { - "main.topbar.wrapper": "verified", - "main.playbar.indicator": "verified", - }, - } - ) - + "\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() - ).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 = 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.assertEqual(target, self.root / "1020096") - self.assertEqual(json.loads((target / "classmap-source.json").read_text()), self.classmap) - 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["inherited_from"], "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) - 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), - mock.patch.object(build_index, "INDEX", index_path), - 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"): - 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, - ) - - 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( - 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, - ) - - 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"): - 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, - ) - - 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 = 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["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"])) - - 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"): - 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, - ) - - 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( - 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, - ) - - 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( - 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.cdp_report["summary"]["hitRate"] = 0.3333 - with self.assertRaisesRegex(ValueError, "at least 0.25"): - 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, - ) - - def test_refuses_shallow_or_mismatched_reports(self): - self.cdp_report["deep"] = False - with self.assertRaisesRegex(ValueError, "deep CDP"): - 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, - ) - - 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( - 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.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"): - 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, - ) - - 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( - 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.cdp_report["navigation"] = {"attempted": 8, "succeeded": 7, "failed": []} - with self.assertRaisesRegex(ValueError, "navigation ledger"): - 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, - ) - 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( - 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.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( - 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, - ) - - 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( - 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.cdp_report["rows"][0]["hash"] = "topbarHashAA" - self.cdp_report["classmap"]["sha256"] = "b" * 64 - with self.assertRaisesRegex(ValueError, "classmap digest"): - 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, - ) - - 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 = 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.assertNotIn("main.topbar.retired", meta["stale_leaves"]) - - 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"): - 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_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, - "run", - side_effect=subprocess.CalledProcessError(1, ["build_index.py"]), - ): - with self.assertRaises(subprocess.CalledProcessError): - promote.publish_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(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"): - 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, - ) - - -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..8a13fd0 --- /dev/null +++ b/scripts/validate-classmaps.test.mts @@ -0,0 +1,171 @@ +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, sha256 } 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: [], + verified_classmap_sha256: null, + }; +} + +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'), + ); +}); + +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 new file mode 100644 index 0000000..db651e4 --- /dev/null +++ b/scripts/validate-classmaps.ts @@ -0,0 +1,253 @@ +// 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 { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { + type Classmap, + compareCodePoints, + isFile, + KEY_DIR, + keyDirs, + leafValues, + readUtf8, + renderJson, + ROOT, + sha256, + versionToKey, +} from "./lib.ts"; + +const META_FIELDS = [ + "classmap_key", + "generated", + "required_paths", + "schema_version", + "source", + "spotify_version", + "stale_leaves", + "status", + "unverified_leaves", + "verified_classmap_sha256", +]; +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]+-/; +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; + +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(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`]; + 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 { + 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}`); + 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(compareCodePoints); + 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 || !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"); + } else if (!readFileSync(parent).equals(classmap)) { + errors.push(`classmap.json: inherited from ${source.key} but its bytes differ`); + } + } + + 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()) { + 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 (!isFile(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 (isFile(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(WHITESPACE)) { + 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 (isFile(metaPath)) { + const meta = loadCanonical(metaPath, errors); + if (meta !== undefined) errors.push(...metaErrors(meta, key, leaves, root, readFileSync(classmapPath))); + if (!isFile(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.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 new file mode 100644 index 0000000..ce943b8 --- /dev/null +++ b/scripts/validate-expose.ts @@ -0,0 +1,114 @@ +// 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. 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 path from "node:path"; + +import { readUtf8, 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 { + 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[] { + 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(("onMiss" in patch ? 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 { + 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; + 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_expose.py b/scripts/validate_expose.py deleted file mode 100755 index 441cc86..0000000 --- a/scripts/validate_expose.py +++ /dev/null @@ -1,82 +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+)") - - -def main() -> int: - try: - doc = json.loads(EXPOSE.read_text()) - except (OSError, json.JSONDecodeError) as e: - print(f"expose.json: {e}", file=sys.stderr) - return 1 - - errors: list[str] = [] - 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`") - - 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"] +}