diff --git a/.changeset/vs-extension-xml-validation.md b/.changeset/vs-extension-xml-validation.md new file mode 100644 index 000000000..714319aca --- /dev/null +++ b/.changeset/vs-extension-xml-validation.md @@ -0,0 +1,10 @@ +--- +'b2c-vs-extension': minor +'@salesforce/b2c-dx-docs': patch +--- + +Add XSD-based validation for B2C metadata XML files. The extension now contributes 48 schemas (catalogs, promotions, jobs, services, customer feeds, A/B tests, page-meta-tags, sorting rules, source codes, content libraries, and more) so opening a B2C metadata XML produces inline diagnostics, autocomplete, and hover documentation. + +**File-glob coverage** spans both common workspace conventions: the canonical SFCC site-archive layout (`sites//promotions.xml`, `catalogs//catalog.xml`, …) and exploded-archive workspaces under a `metadata/` folder (`metadata/promotions/*.xml`, `metadata/catalogs/*.xml`, …). + +**New install requirement:** the extension declares `redhat.vscode-xml` as an extension dependency. VS Code installs it automatically the first time this extension activates after the upgrade. Users / teams whose policies block third-party extensions should disable `b2c-dx.features.xmlValidation` (or remove the dependency declaration) before deploying the upgrade. diff --git a/docs/vscode-extension/configuration.md b/docs/vscode-extension/configuration.md index 140f24293..59cc3801f 100644 --- a/docs/vscode-extension/configuration.md +++ b/docs/vscode-extension/configuration.md @@ -94,6 +94,27 @@ The B2C Script Debugger registers regardless of these toggles — it activates o | `b2c-dx.sandbox.pollingInterval` | `10` | Seconds between polls while a sandbox is in a transitional state (`creating`, `starting`, `stopping`, `deleting`, `cloning`). Range: 2–300. Polling stops automatically once the realm settles. | | `b2c-dx.telemetry.enabled` | `true` | Send anonymous usage telemetry. Honors VS Code's `telemetry.telemetryLevel` — disabling that disables this regardless of this setting. | +### XML schema validation + +The extension contributes XSD-based validation for B2C metadata XML files via the [Red Hat XML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-xml), which is declared as an extension dependency and installed automatically. When a file path matches one of the contributed globs, diagnostics, autocomplete, and hover docs are driven by the corresponding B2C schema. + +Both common workspace conventions are recognized: + +- **Canonical site-archive layout** — `sites//`, `catalogs//`, `libraries//`, `customer_lists//`, `pricebooks/`, `inventory_lists/`, `meta/`. +- **Exploded `metadata/` workspace layout** — `metadata/sites//*.xml`, `metadata/catalogs/*.xml`, `metadata/promotions/*.xml`, etc. + +Schemas covered include catalog, promotion, slot, customer-group, customer-list, custom-object, inventory, library, payment-method, payment-processor, preference, pricebook, redirect-url, search/search2, shipping, site, sourcecode, store, url-rule, jobs, services, schedules, ab-test (and participants), assignment, cache-settings, commerce-feature-state, coupon (and redemption), csrf-allowlist, customer, customer-cdn-settings, dcext, form, geolocation, gift-certificate, locales, meta (system/custom-objecttype-extensions), oauth-providers, page-meta-tags, price-adjustment-limits, product-list, sitemap-configuration, sorting-rules, storefronts, and tax. The full mapping is at `packages/b2c-vs-extension/resources/xsd-mappings.json`. + +To disable XML validation globally in your workspace, set: + +```jsonc +{ + "xml.validation.enabled": false +} +``` + +To opt out of the Red Hat XML dependency entirely, uninstall this extension or pin to a release prior to the one that introduced XML validation. + ### Complete defaults (copy-paste) ```jsonc diff --git a/packages/b2c-vs-extension/.gitignore b/packages/b2c-vs-extension/.gitignore index 337d10fcb..19ab11ca4 100644 --- a/packages/b2c-vs-extension/.gitignore +++ b/packages/b2c-vs-extension/.gitignore @@ -5,3 +5,4 @@ node_modules .vsce-stage/ *.vsix /coverage +resources/xsd/ diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index dac8f9bc9..c76b1fdca 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -2,7 +2,7 @@ "name": "b2c-vs-extension", "displayName": "B2C DX VSCE", "description": "VS Code extension for B2C Commerce developer experience (Page Designer assistant, B2C CLI integration)", - "version": "0.8.0", + "version": "0.8.2", "publisher": "Salesforce", "license": "Apache-2.0", "repository": "SalesforceCommerceCloud/b2c-developer-tooling", @@ -38,6 +38,9 @@ "onDebugResolve:b2c-script", "workspaceContains:**/commerce-app.json" ], + "extensionDependencies": [ + "redhat.vscode-xml" + ], "main": "./dist/extension.cjs", "contributes": { "typescriptServerPlugins": [ @@ -100,6 +103,556 @@ {"language": "isml"}, {"language": "javascript"} ], + "xmlValidation": [ + { + "fileMatch": "**/sites/*/ab-tests.xml", + "url": "./resources/xsd/abtest.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/ab-tests.xml", + "url": "./resources/xsd/abtest.xsd" + }, + { + "fileMatch": "**/metadata/ab-tests/*.xml", + "url": "./resources/xsd/abtest.xsd" + }, + { + "fileMatch": "**/sites/*/abtest-participants.xml", + "url": "./resources/xsd/abtestparticipants.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/abtest-participants.xml", + "url": "./resources/xsd/abtestparticipants.xsd" + }, + { + "fileMatch": "**/catalogs/*/assignments.xml", + "url": "./resources/xsd/assignment.xsd" + }, + { + "fileMatch": "**/sites/*/assignments.xml", + "url": "./resources/xsd/assignment.xsd" + }, + { + "fileMatch": "**/metadata/catalogs/*/assignments.xml", + "url": "./resources/xsd/assignment.xsd" + }, + { + "fileMatch": "**/sites/*/cache-settings.xml", + "url": "./resources/xsd/cachesettings.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/cache-settings.xml", + "url": "./resources/xsd/cachesettings.xsd" + }, + { + "fileMatch": "**/metadata/cache-settings/*.xml", + "url": "./resources/xsd/cachesettings.xsd" + }, + { + "fileMatch": "**/catalogs/*/catalog.xml", + "url": "./resources/xsd/catalog.xsd" + }, + { + "fileMatch": "**/metadata/catalogs/*/catalog.xml", + "url": "./resources/xsd/catalog.xsd" + }, + { + "fileMatch": "**/metadata/catalogs/*.xml", + "url": "./resources/xsd/catalog.xsd" + }, + { + "fileMatch": "**/commerce-feature-states.xml", + "url": "./resources/xsd/commercefeaturestate.xsd" + }, + { + "fileMatch": "**/sites/*/commerce-feature-states.xml", + "url": "./resources/xsd/commercefeaturestate.xsd" + }, + { + "fileMatch": "**/metadata/**/commerce-feature-states.xml", + "url": "./resources/xsd/commercefeaturestate.xsd" + }, + { + "fileMatch": "**/sites/*/coupons.xml", + "url": "./resources/xsd/coupon.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/coupons.xml", + "url": "./resources/xsd/coupon.xsd" + }, + { + "fileMatch": "**/metadata/coupons/*.xml", + "url": "./resources/xsd/coupon.xsd" + }, + { + "fileMatch": "**/sites/*/coupon-redemptions.xml", + "url": "./resources/xsd/couponredemption.xsd" + }, + { + "fileMatch": "**/metadata/**/coupon-redemptions.xml", + "url": "./resources/xsd/couponredemption.xsd" + }, + { + "fileMatch": "**/sites/*/csrf-whitelists.xml", + "url": "./resources/xsd/csrfwhitelists.xsd" + }, + { + "fileMatch": "**/sites/*/csrf-allowlists.xml", + "url": "./resources/xsd/csrfwhitelists.xsd" + }, + { + "fileMatch": "**/metadata/**/csrf-whitelists.xml", + "url": "./resources/xsd/csrfwhitelists.xsd" + }, + { + "fileMatch": "**/metadata/**/csrf-allowlists.xml", + "url": "./resources/xsd/csrfwhitelists.xsd" + }, + { + "fileMatch": "**/customer_lists/*/customers.xml", + "url": "./resources/xsd/customer.xsd" + }, + { + "fileMatch": "**/customer_lists/*/customers/*.xml", + "url": "./resources/xsd/customer.xsd" + }, + { + "fileMatch": "**/sites/*/customer-cdn-settings.xml", + "url": "./resources/xsd/customercdnsettings.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/customer-cdn-settings.xml", + "url": "./resources/xsd/customercdnsettings.xsd" + }, + { + "fileMatch": "**/metadata/customer-cdn-settings/*.xml", + "url": "./resources/xsd/customercdnsettings.xsd" + }, + { + "fileMatch": "**/sites/*/customer-groups.xml", + "url": "./resources/xsd/customergroup.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/customer-groups.xml", + "url": "./resources/xsd/customergroup.xsd" + }, + { + "fileMatch": "**/metadata/customer-groups/*.xml", + "url": "./resources/xsd/customergroup.xsd" + }, + { + "fileMatch": "**/customer_lists.xml", + "url": "./resources/xsd/customerlist.xsd" + }, + { + "fileMatch": "**/metadata/customer-lists/*.xml", + "url": "./resources/xsd/customerlist.xsd" + }, + { + "fileMatch": "**/customer_lists/*/customer-list.xml", + "url": "./resources/xsd/customerlist2.xsd" + }, + { + "fileMatch": "**/customer_lists/*.xml", + "url": "./resources/xsd/customerlist2.xsd" + }, + { + "fileMatch": "**/sites/*/custom-objects.xml", + "url": "./resources/xsd/customobject.xsd" + }, + { + "fileMatch": "**/sites/*/custom-objects/*.xml", + "url": "./resources/xsd/customobject.xsd" + }, + { + "fileMatch": "**/metadata/custom-objects/*.xml", + "url": "./resources/xsd/customobject.xsd" + }, + { + "fileMatch": "**/sites/*/dcext.xml", + "url": "./resources/xsd/dcext.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/dcext.xml", + "url": "./resources/xsd/dcext.xsd" + }, + { + "fileMatch": "**/metadata/dcext/*.xml", + "url": "./resources/xsd/dcext.xsd" + }, + { + "fileMatch": "**/cartridges/*/cartridge/forms/**/*.xml", + "url": "./resources/xsd/form.xsd" + }, + { + "fileMatch": "**/geolocations.xml", + "url": "./resources/xsd/geolocation.xsd" + }, + { + "fileMatch": "**/sites/*/geolocations.xml", + "url": "./resources/xsd/geolocation.xsd" + }, + { + "fileMatch": "**/metadata/**/geolocations.xml", + "url": "./resources/xsd/geolocation.xsd" + }, + { + "fileMatch": "**/gift_certificates/gift-certificates.xml", + "url": "./resources/xsd/giftcertificate.xsd" + }, + { + "fileMatch": "**/sites/*/gift-certificates.xml", + "url": "./resources/xsd/giftcertificate.xsd" + }, + { + "fileMatch": "**/metadata/**/gift-certificates.xml", + "url": "./resources/xsd/giftcertificate.xsd" + }, + { + "fileMatch": "**/inventory_lists/*.xml", + "url": "./resources/xsd/inventory.xsd" + }, + { + "fileMatch": "**/inventory_lists/*/inventory.xml", + "url": "./resources/xsd/inventory.xsd" + }, + { + "fileMatch": "**/metadata/inventory-lists/*.xml", + "url": "./resources/xsd/inventory.xsd" + }, + { + "fileMatch": "**/jobs.xml", + "url": "./resources/xsd/jobs.xsd" + }, + { + "fileMatch": "**/sites/*/jobs.xml", + "url": "./resources/xsd/jobs.xsd" + }, + { + "fileMatch": "**/metadata/**/jobs.xml", + "url": "./resources/xsd/jobs.xsd" + }, + { + "fileMatch": "**/libraries/*/library.xml", + "url": "./resources/xsd/library.xsd" + }, + { + "fileMatch": "**/sites/*/library.xml", + "url": "./resources/xsd/library.xsd" + }, + { + "fileMatch": "**/metadata/libraries/*.xml", + "url": "./resources/xsd/library.xsd" + }, + { + "fileMatch": "**/sites/*/locales.xml", + "url": "./resources/xsd/locales.xsd" + }, + { + "fileMatch": "**/locales.xml", + "url": "./resources/xsd/locales.xsd" + }, + { + "fileMatch": "**/metadata/**/locales.xml", + "url": "./resources/xsd/locales.xsd" + }, + { + "fileMatch": "**/meta/system-objecttype-extensions.xml", + "url": "./resources/xsd/metadata.xsd" + }, + { + "fileMatch": "**/meta/custom-objecttype-definitions.xml", + "url": "./resources/xsd/metadata.xsd" + }, + { + "fileMatch": "**/sites/*/meta/*.xml", + "url": "./resources/xsd/metadata.xsd" + }, + { + "fileMatch": "**/metadata/meta/*.xml", + "url": "./resources/xsd/metadata.xsd" + }, + { + "fileMatch": "**/sites/*/oauth-providers.xml", + "url": "./resources/xsd/oauth.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/oauth-providers.xml", + "url": "./resources/xsd/oauth.xsd" + }, + { + "fileMatch": "**/metadata/oauth-providers/*.xml", + "url": "./resources/xsd/oauth.xsd" + }, + { + "fileMatch": "**/sites/*/page-meta-tags.xml", + "url": "./resources/xsd/pagemetatag.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/page-meta-tags.xml", + "url": "./resources/xsd/pagemetatag.xsd" + }, + { + "fileMatch": "**/metadata/page-meta-tags/*.xml", + "url": "./resources/xsd/pagemetatag.xsd" + }, + { + "fileMatch": "**/sites/*/payment-methods.xml", + "url": "./resources/xsd/paymentmethod.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/payment-methods.xml", + "url": "./resources/xsd/paymentmethod.xsd" + }, + { + "fileMatch": "**/metadata/payment-methods/*.xml", + "url": "./resources/xsd/paymentmethod.xsd" + }, + { + "fileMatch": "**/sites/*/payment-processors.xml", + "url": "./resources/xsd/paymentprocessor.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/payment-processors.xml", + "url": "./resources/xsd/paymentprocessor.xsd" + }, + { + "fileMatch": "**/metadata/payment-processors/*.xml", + "url": "./resources/xsd/paymentprocessor.xsd" + }, + { + "fileMatch": "**/sites/*/preferences.xml", + "url": "./resources/xsd/preferences.xsd" + }, + { + "fileMatch": "**/preferences.xml", + "url": "./resources/xsd/preferences.xsd" + }, + { + "fileMatch": "**/metadata/preferences/*.xml", + "url": "./resources/xsd/preferences.xsd" + }, + { + "fileMatch": "**/price_adjustment_limits/price-adjustment-limits.xml", + "url": "./resources/xsd/priceadjustmentlimits.xsd" + }, + { + "fileMatch": "**/sites/*/price-adjustment-limits.xml", + "url": "./resources/xsd/priceadjustmentlimits.xsd" + }, + { + "fileMatch": "**/metadata/**/price-adjustment-limits.xml", + "url": "./resources/xsd/priceadjustmentlimits.xsd" + }, + { + "fileMatch": "**/pricebooks/*.xml", + "url": "./resources/xsd/pricebook.xsd" + }, + { + "fileMatch": "**/pricebooks/*/pricebook.xml", + "url": "./resources/xsd/pricebook.xsd" + }, + { + "fileMatch": "**/metadata/pricebooks/*.xml", + "url": "./resources/xsd/pricebook.xsd" + }, + { + "fileMatch": "**/customer_lists/*/product-lists/*.xml", + "url": "./resources/xsd/productlist.xsd" + }, + { + "fileMatch": "**/sites/*/product-lists.xml", + "url": "./resources/xsd/productlist.xsd" + }, + { + "fileMatch": "**/metadata/**/product-lists.xml", + "url": "./resources/xsd/productlist.xsd" + }, + { + "fileMatch": "**/sites/*/promotions.xml", + "url": "./resources/xsd/promotion.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/promotions.xml", + "url": "./resources/xsd/promotion.xsd" + }, + { + "fileMatch": "**/metadata/promotions/*.xml", + "url": "./resources/xsd/promotion.xsd" + }, + { + "fileMatch": "**/sites/*/redirect-urls.xml", + "url": "./resources/xsd/redirecturl.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/redirect-urls.xml", + "url": "./resources/xsd/redirecturl.xsd" + }, + { + "fileMatch": "**/metadata/redirect-urls/*.xml", + "url": "./resources/xsd/redirecturl.xsd" + }, + { + "fileMatch": "**/sites/*/schedules.xml", + "url": "./resources/xsd/schedules.xsd" + }, + { + "fileMatch": "**/metadata/**/schedules.xml", + "url": "./resources/xsd/schedules.xsd" + }, + { + "fileMatch": "**/sites/*/search.xml", + "url": "./resources/xsd/search.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/search.xml", + "url": "./resources/xsd/search.xsd" + }, + { + "fileMatch": "**/metadata/search/*.xml", + "url": "./resources/xsd/search.xsd" + }, + { + "fileMatch": "**/sites/*/search2.xml", + "url": "./resources/xsd/search2.xsd" + }, + { + "fileMatch": "**/metadata/**/search2.xml", + "url": "./resources/xsd/search2.xsd" + }, + { + "fileMatch": "**/sites/*/services.xml", + "url": "./resources/xsd/services.xsd" + }, + { + "fileMatch": "**/services.xml", + "url": "./resources/xsd/services.xsd" + }, + { + "fileMatch": "**/metadata/**/services.xml", + "url": "./resources/xsd/services.xsd" + }, + { + "fileMatch": "**/sites/*/shipping.xml", + "url": "./resources/xsd/shipping.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/shipping.xml", + "url": "./resources/xsd/shipping.xsd" + }, + { + "fileMatch": "**/metadata/shipping/*.xml", + "url": "./resources/xsd/shipping.xsd" + }, + { + "fileMatch": "**/sites/*/site.xml", + "url": "./resources/xsd/site.xsd" + }, + { + "fileMatch": "**/metadata/sites/*.xml", + "url": "./resources/xsd/site.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/site.xml", + "url": "./resources/xsd/site.xsd" + }, + { + "fileMatch": "**/sites/*/sitemap-configuration.xml", + "url": "./resources/xsd/sitemapconfiguration.xsd" + }, + { + "fileMatch": "**/metadata/**/sitemap-configuration.xml", + "url": "./resources/xsd/sitemapconfiguration.xsd" + }, + { + "fileMatch": "**/sites/*/slots.xml", + "url": "./resources/xsd/slot.xsd" + }, + { + "fileMatch": "**/sites/*/slot-configurations.xml", + "url": "./resources/xsd/slot.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/slots.xml", + "url": "./resources/xsd/slot.xsd" + }, + { + "fileMatch": "**/metadata/slots/*.xml", + "url": "./resources/xsd/slot.xsd" + }, + { + "fileMatch": "**/sites/*/sorting-rules.xml", + "url": "./resources/xsd/sort.xsd" + }, + { + "fileMatch": "**/sites/*/sort.xml", + "url": "./resources/xsd/sort.xsd" + }, + { + "fileMatch": "**/metadata/**/sorting-rules.xml", + "url": "./resources/xsd/sort.xsd" + }, + { + "fileMatch": "**/sites/*/source-codes.xml", + "url": "./resources/xsd/sourcecode.xsd" + }, + { + "fileMatch": "**/sites/*/sourcecodes.xml", + "url": "./resources/xsd/sourcecode.xsd" + }, + { + "fileMatch": "**/metadata/sourcecodes/*.xml", + "url": "./resources/xsd/sourcecode.xsd" + }, + { + "fileMatch": "**/sites/*/stores.xml", + "url": "./resources/xsd/store.xsd" + }, + { + "fileMatch": "**/stores/stores.xml", + "url": "./resources/xsd/store.xsd" + }, + { + "fileMatch": "**/metadata/stores/*.xml", + "url": "./resources/xsd/store.xsd" + }, + { + "fileMatch": "**/sites/*/storefronts.xml", + "url": "./resources/xsd/storefronts.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/storefronts.xml", + "url": "./resources/xsd/storefronts.xsd" + }, + { + "fileMatch": "**/metadata/storefronts/*.xml", + "url": "./resources/xsd/storefronts.xsd" + }, + { + "fileMatch": "**/sites/*/tax.xml", + "url": "./resources/xsd/tax.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/tax.xml", + "url": "./resources/xsd/tax.xsd" + }, + { + "fileMatch": "**/metadata/tax/*.xml", + "url": "./resources/xsd/tax.xsd" + }, + { + "fileMatch": "**/sites/*/url-rules.xml", + "url": "./resources/xsd/urlrules.xsd" + }, + { + "fileMatch": "**/metadata/sites/*/url-rules.xml", + "url": "./resources/xsd/urlrules.xsd" + }, + { + "fileMatch": "**/metadata/url-rules/*.xml", + "url": "./resources/xsd/urlrules.xsd" + } + ], "configuration": { "title": "B2C DX", "properties": { @@ -1289,16 +1842,17 @@ } }, "scripts": { + "sync:xsd": "node scripts/sync-xsd.mjs", "build": "node scripts/esbuild-bundle.mjs", "watch": "node scripts/esbuild-bundle.mjs --watch", "vscode:prepublish": "pnpm run typecheck:agent && pnpm --filter @salesforce/b2c-tooling-sdk run build && node scripts/esbuild-bundle.mjs", - "package": "pnpm exec vsce package --no-dependencies && node scripts/inject-script-types.mjs", + "package": "pnpm run build && pnpm exec vsce package --no-dependencies && node scripts/inject-script-types.mjs", "lint": "eslint", "lint:agent": "eslint --quiet", "typecheck:agent": "tsc -p . --noEmit --pretty false", "format": "prettier --write src", "format:check": "prettier --check src", - "pretest": "tsc -p tsconfig.test.json", + "pretest": "pnpm run sync:xsd && tsc -p tsconfig.test.json", "test": "vscode-test", "posttest": "pnpm run lint", "analyze": "ANALYZE_BUNDLE=1 node scripts/esbuild-bundle.mjs" diff --git a/packages/b2c-vs-extension/resources/xsd-mappings.json b/packages/b2c-vs-extension/resources/xsd-mappings.json new file mode 100644 index 000000000..9b42a902a --- /dev/null +++ b/packages/b2c-vs-extension/resources/xsd-mappings.json @@ -0,0 +1,395 @@ +{ + "$schema": "./xsd-mappings.schema.json", + "description": "Source-of-truth for XML→XSD validation contributed by this extension. The build step (scripts/sync-xsd.mjs) reads this file to (1) copy the listed schemas from the SDK into resources/xsd/ and (2) regenerate package.json#contributes.xmlValidation. Each `mappings` entry covers two workspace conventions: the canonical SFCC site-archive layout (sites//, catalogs//, …) and the exploded `metadata/` workspace convention used by sfcc-ci / prophet-style projects. Every SDK schema must appear in `mappings` (bundled + contributed as fileMatch), `bundleOnly` (bundled because another schema imports it, but never the validation target itself), or `skipped` (excluded entirely). The build fails if any SDK schema is in none of those lists — this prevents new schemas from silently missing validation.", + "bundleOnly": [ + "xml.xsd" + ], + "skipped": [ + "bmext.xsd", + "customeractivedata.xsd", + "customerpaymentinstrument.xsd", + "feed.xsd", + "order.xsd", + "returnimportfeed.xsd", + "shippingorderupdatefeed.xsd" + ], + "mappings": [ + { + "schema": "abtest.xsd", + "fileMatch": [ + "**/sites/*/ab-tests.xml", + "**/metadata/sites/*/ab-tests.xml", + "**/metadata/ab-tests/*.xml" + ] + }, + { + "schema": "abtestparticipants.xsd", + "fileMatch": [ + "**/sites/*/abtest-participants.xml", + "**/metadata/sites/*/abtest-participants.xml" + ] + }, + { + "schema": "assignment.xsd", + "fileMatch": [ + "**/catalogs/*/assignments.xml", + "**/sites/*/assignments.xml", + "**/metadata/catalogs/*/assignments.xml" + ] + }, + { + "schema": "cachesettings.xsd", + "fileMatch": [ + "**/sites/*/cache-settings.xml", + "**/metadata/sites/*/cache-settings.xml", + "**/metadata/cache-settings/*.xml" + ] + }, + { + "schema": "catalog.xsd", + "fileMatch": [ + "**/catalogs/*/catalog.xml", + "**/metadata/catalogs/*/catalog.xml", + "**/metadata/catalogs/*.xml" + ] + }, + { + "schema": "commercefeaturestate.xsd", + "fileMatch": [ + "**/commerce-feature-states.xml", + "**/sites/*/commerce-feature-states.xml", + "**/metadata/**/commerce-feature-states.xml" + ] + }, + { + "schema": "coupon.xsd", + "fileMatch": [ + "**/sites/*/coupons.xml", + "**/metadata/sites/*/coupons.xml", + "**/metadata/coupons/*.xml" + ] + }, + { + "schema": "couponredemption.xsd", + "fileMatch": [ + "**/sites/*/coupon-redemptions.xml", + "**/metadata/**/coupon-redemptions.xml" + ] + }, + { + "schema": "csrfwhitelists.xsd", + "fileMatch": [ + "**/sites/*/csrf-whitelists.xml", + "**/sites/*/csrf-allowlists.xml", + "**/metadata/**/csrf-whitelists.xml", + "**/metadata/**/csrf-allowlists.xml" + ] + }, + { + "schema": "customer.xsd", + "fileMatch": [ + "**/customer_lists/*/customers.xml", + "**/customer_lists/*/customers/*.xml" + ] + }, + { + "schema": "customercdnsettings.xsd", + "fileMatch": [ + "**/sites/*/customer-cdn-settings.xml", + "**/metadata/sites/*/customer-cdn-settings.xml", + "**/metadata/customer-cdn-settings/*.xml" + ] + }, + { + "schema": "customergroup.xsd", + "fileMatch": [ + "**/sites/*/customer-groups.xml", + "**/metadata/sites/*/customer-groups.xml", + "**/metadata/customer-groups/*.xml" + ] + }, + { + "schema": "customerlist.xsd", + "fileMatch": [ + "**/customer_lists.xml", + "**/metadata/customer-lists/*.xml" + ] + }, + { + "schema": "customerlist2.xsd", + "fileMatch": [ + "**/customer_lists/*/customer-list.xml", + "**/customer_lists/*.xml" + ] + }, + { + "schema": "customobject.xsd", + "fileMatch": [ + "**/sites/*/custom-objects.xml", + "**/sites/*/custom-objects/*.xml", + "**/metadata/custom-objects/*.xml" + ] + }, + { + "schema": "dcext.xsd", + "fileMatch": [ + "**/sites/*/dcext.xml", + "**/metadata/sites/*/dcext.xml", + "**/metadata/dcext/*.xml" + ] + }, + { + "schema": "form.xsd", + "fileMatch": [ + "**/cartridges/*/cartridge/forms/**/*.xml" + ] + }, + { + "schema": "geolocation.xsd", + "fileMatch": [ + "**/geolocations.xml", + "**/sites/*/geolocations.xml", + "**/metadata/**/geolocations.xml" + ] + }, + { + "schema": "giftcertificate.xsd", + "fileMatch": [ + "**/gift_certificates/gift-certificates.xml", + "**/sites/*/gift-certificates.xml", + "**/metadata/**/gift-certificates.xml" + ] + }, + { + "schema": "inventory.xsd", + "fileMatch": [ + "**/inventory_lists/*.xml", + "**/inventory_lists/*/inventory.xml", + "**/metadata/inventory-lists/*.xml" + ] + }, + { + "schema": "jobs.xsd", + "fileMatch": [ + "**/jobs.xml", + "**/sites/*/jobs.xml", + "**/metadata/**/jobs.xml" + ] + }, + { + "schema": "library.xsd", + "fileMatch": [ + "**/libraries/*/library.xml", + "**/sites/*/library.xml", + "**/metadata/libraries/*.xml" + ] + }, + { + "schema": "locales.xsd", + "fileMatch": [ + "**/sites/*/locales.xml", + "**/locales.xml", + "**/metadata/**/locales.xml" + ] + }, + { + "schema": "metadata.xsd", + "fileMatch": [ + "**/meta/system-objecttype-extensions.xml", + "**/meta/custom-objecttype-definitions.xml", + "**/sites/*/meta/*.xml", + "**/metadata/meta/*.xml" + ] + }, + { + "schema": "oauth.xsd", + "fileMatch": [ + "**/sites/*/oauth-providers.xml", + "**/metadata/sites/*/oauth-providers.xml", + "**/metadata/oauth-providers/*.xml" + ] + }, + { + "schema": "pagemetatag.xsd", + "fileMatch": [ + "**/sites/*/page-meta-tags.xml", + "**/metadata/sites/*/page-meta-tags.xml", + "**/metadata/page-meta-tags/*.xml" + ] + }, + { + "schema": "paymentmethod.xsd", + "fileMatch": [ + "**/sites/*/payment-methods.xml", + "**/metadata/sites/*/payment-methods.xml", + "**/metadata/payment-methods/*.xml" + ] + }, + { + "schema": "paymentprocessor.xsd", + "fileMatch": [ + "**/sites/*/payment-processors.xml", + "**/metadata/sites/*/payment-processors.xml", + "**/metadata/payment-processors/*.xml" + ] + }, + { + "schema": "preferences.xsd", + "fileMatch": [ + "**/sites/*/preferences.xml", + "**/preferences.xml", + "**/metadata/preferences/*.xml" + ] + }, + { + "schema": "priceadjustmentlimits.xsd", + "fileMatch": [ + "**/price_adjustment_limits/price-adjustment-limits.xml", + "**/sites/*/price-adjustment-limits.xml", + "**/metadata/**/price-adjustment-limits.xml" + ] + }, + { + "schema": "pricebook.xsd", + "fileMatch": [ + "**/pricebooks/*.xml", + "**/pricebooks/*/pricebook.xml", + "**/metadata/pricebooks/*.xml" + ] + }, + { + "schema": "productlist.xsd", + "fileMatch": [ + "**/customer_lists/*/product-lists/*.xml", + "**/sites/*/product-lists.xml", + "**/metadata/**/product-lists.xml" + ] + }, + { + "schema": "promotion.xsd", + "fileMatch": [ + "**/sites/*/promotions.xml", + "**/metadata/sites/*/promotions.xml", + "**/metadata/promotions/*.xml" + ] + }, + { + "schema": "redirecturl.xsd", + "fileMatch": [ + "**/sites/*/redirect-urls.xml", + "**/metadata/sites/*/redirect-urls.xml", + "**/metadata/redirect-urls/*.xml" + ] + }, + { + "schema": "schedules.xsd", + "fileMatch": [ + "**/sites/*/schedules.xml", + "**/metadata/**/schedules.xml" + ] + }, + { + "schema": "search.xsd", + "fileMatch": [ + "**/sites/*/search.xml", + "**/metadata/sites/*/search.xml", + "**/metadata/search/*.xml" + ] + }, + { + "schema": "search2.xsd", + "fileMatch": [ + "**/sites/*/search2.xml", + "**/metadata/**/search2.xml" + ] + }, + { + "schema": "services.xsd", + "fileMatch": [ + "**/sites/*/services.xml", + "**/services.xml", + "**/metadata/**/services.xml" + ] + }, + { + "schema": "shipping.xsd", + "fileMatch": [ + "**/sites/*/shipping.xml", + "**/metadata/sites/*/shipping.xml", + "**/metadata/shipping/*.xml" + ] + }, + { + "schema": "site.xsd", + "fileMatch": [ + "**/sites/*/site.xml", + "**/metadata/sites/*.xml", + "**/metadata/sites/*/site.xml" + ] + }, + { + "schema": "sitemapconfiguration.xsd", + "fileMatch": [ + "**/sites/*/sitemap-configuration.xml", + "**/metadata/**/sitemap-configuration.xml" + ] + }, + { + "schema": "slot.xsd", + "fileMatch": [ + "**/sites/*/slots.xml", + "**/sites/*/slot-configurations.xml", + "**/metadata/sites/*/slots.xml", + "**/metadata/slots/*.xml" + ] + }, + { + "schema": "sort.xsd", + "fileMatch": [ + "**/sites/*/sorting-rules.xml", + "**/sites/*/sort.xml", + "**/metadata/**/sorting-rules.xml" + ] + }, + { + "schema": "sourcecode.xsd", + "fileMatch": [ + "**/sites/*/source-codes.xml", + "**/sites/*/sourcecodes.xml", + "**/metadata/sourcecodes/*.xml" + ] + }, + { + "schema": "store.xsd", + "fileMatch": [ + "**/sites/*/stores.xml", + "**/stores/stores.xml", + "**/metadata/stores/*.xml" + ] + }, + { + "schema": "storefronts.xsd", + "fileMatch": [ + "**/sites/*/storefronts.xml", + "**/metadata/sites/*/storefronts.xml", + "**/metadata/storefronts/*.xml" + ] + }, + { + "schema": "tax.xsd", + "fileMatch": [ + "**/sites/*/tax.xml", + "**/metadata/sites/*/tax.xml", + "**/metadata/tax/*.xml" + ] + }, + { + "schema": "urlrules.xsd", + "fileMatch": [ + "**/sites/*/url-rules.xml", + "**/metadata/sites/*/url-rules.xml", + "**/metadata/url-rules/*.xml" + ] + } + ] +} diff --git a/packages/b2c-vs-extension/resources/xsd-mappings.schema.json b/packages/b2c-vs-extension/resources/xsd-mappings.schema.json new file mode 100644 index 000000000..e0eb2ebb4 --- /dev/null +++ b/packages/b2c-vs-extension/resources/xsd-mappings.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://salesforce.com/b2c-vs-extension/xsd-mappings.schema.json", + "title": "B2C VS Extension XSD Mappings", + "description": "Source-of-truth for XML→XSD validation contributed by the B2C VS extension. Read by scripts/sync-xsd.mjs at build time to (1) bundle the listed schemas from the SDK into resources/xsd/ and (2) regenerate package.json#contributes.xmlValidation.", + "type": "object", + "additionalProperties": false, + "required": ["mappings"], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to this JSON Schema for editor validation." + }, + "description": { + "type": "string", + "description": "Human-readable explanation of how this file is used." + }, + "mappings": { + "type": "array", + "description": "Schemas that are bundled AND contributed as fileMatch entries to package.json#contributes.xmlValidation. These are the user-facing validation targets.", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "fileMatch"], + "properties": { + "schema": { + "type": "string", + "description": "Filename of the XSD inside packages/b2c-tooling-sdk/data/xsd/ (e.g., \"catalog.xsd\").", + "pattern": "^[a-zA-Z0-9_-]+\\.xsd$" + }, + "fileMatch": { + "type": "array", + "description": "VS Code glob patterns (relative to the workspace root) that should validate against this schema.", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "bundleOnly": { + "type": "array", + "description": "Schemas that are bundled because they are imported by other XSDs (e.g., xml.xsd providing the W3C xml: namespace), but never the validation target themselves. These are NOT contributed to package.json.", + "items": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+\\.xsd$" + } + }, + "skipped": { + "type": "array", + "description": "Schemas intentionally excluded from the extension entirely — typically internal data feeds, OMS integration files, or schemas with no canonical workspace location.", + "items": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+\\.xsd$" + } + } + } +} diff --git a/packages/b2c-vs-extension/scripts/esbuild-bundle.mjs b/packages/b2c-vs-extension/scripts/esbuild-bundle.mjs index 52713b54e..3b0b4b468 100644 --- a/packages/b2c-vs-extension/scripts/esbuild-bundle.mjs +++ b/packages/b2c-vs-extension/scripts/esbuild-bundle.mjs @@ -11,6 +11,7 @@ import esbuild from 'esbuild'; import fs from 'node:fs'; import path from 'path'; import {fileURLToPath} from 'url'; +import {syncXsd} from './sync-xsd.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -18,6 +19,10 @@ const __dirname = path.dirname(__filename); // scripts/ -> package root const pkgRoot = path.resolve(__dirname, '..'); +// Keep XSDs and package.json#contributes.xmlValidation in lockstep with +// resources/xsd-mappings.json before any bundling happens. +syncXsd({pkgRoot}); + // In CJS there is no import.meta; SDK's version.js uses createRequire(import.meta.url). Shim it. // Use globalThis so the value is visible inside all module wrappers in the bundle. const IMPORT_META_URL_SHIM = diff --git a/packages/b2c-vs-extension/scripts/sync-xsd.mjs b/packages/b2c-vs-extension/scripts/sync-xsd.mjs new file mode 100644 index 000000000..a4d568855 --- /dev/null +++ b/packages/b2c-vs-extension/scripts/sync-xsd.mjs @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/* + * Single source of truth: resources/xsd-mappings.json + * + * Reads the mappings file and: + * 1. Copies the listed XSDs from the SDK (`b2c-tooling-sdk/data/xsd/`) into + * `resources/xsd/`. Any unrelated *.xsd previously written under that dir + * is removed so the bundle never carries unused schemas. + * 2. Regenerates the `contributes.xmlValidation` block in package.json from + * the same mappings — keeping the VS Code contribution and the bundled + * schemas in lockstep. + * + * Invoked automatically at the start of esbuild-bundle.mjs (build/watch), + * so a developer never needs to run it as a separate step. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const defaultPkgRoot = path.resolve(__dirname, '..'); + +function listXsdFiles(directory) { + if (!fs.existsSync(directory)) return []; + return fs + .readdirSync(directory) + .filter((name) => name.endsWith('.xsd')) + .sort((left, right) => left.localeCompare(right)); +} + +function filesEqual(leftPath, rightPath) { + if (!fs.existsSync(leftPath) || !fs.existsSync(rightPath)) return false; + return fs.readFileSync(leftPath).equals(fs.readFileSync(rightPath)); +} + +function readMappings(mappingsPath) { + if (!fs.existsSync(mappingsPath)) { + throw new Error(`[xsd-sync] Mappings file not found: ${mappingsPath}`); + } + const parsed = JSON.parse(fs.readFileSync(mappingsPath, 'utf8')); + const entries = parsed?.mappings; + const bundleOnly = parsed?.bundleOnly ?? []; + const skipped = parsed?.skipped ?? []; + if (!Array.isArray(entries) || entries.length === 0) { + throw new Error('[xsd-sync] xsd-mappings.json must contain a non-empty "mappings" array'); + } + if (!Array.isArray(bundleOnly)) { + throw new Error('[xsd-sync] xsd-mappings.json "bundleOnly" must be an array of filenames'); + } + if (!Array.isArray(skipped)) { + throw new Error('[xsd-sync] xsd-mappings.json "skipped" must be an array of filenames'); + } + for (const entry of entries) { + if (typeof entry?.schema !== 'string' || !entry.schema.endsWith('.xsd')) { + throw new Error(`[xsd-sync] Invalid mapping entry — "schema" must end in .xsd: ${JSON.stringify(entry)}`); + } + if (!Array.isArray(entry.fileMatch) || entry.fileMatch.length === 0) { + throw new Error(`[xsd-sync] Mapping for ${entry.schema} must have a non-empty fileMatch[]`); + } + } + for (const name of [...bundleOnly, ...skipped]) { + if (typeof name !== 'string' || !name.endsWith('.xsd')) { + throw new Error( + `[xsd-sync] Invalid bundleOnly/skipped entry — must be a filename ending in .xsd: ${JSON.stringify(name)}`, + ); + } + } + return {mappings: entries, bundleOnly, skipped}; +} + +function assertFullSdkCoverage(mappings, bundleOnly, skipped, sdkXsdDir) { + const sdkFiles = new Set(listXsdFiles(sdkXsdDir)); + const accounted = new Set([...mappings.map((m) => m.schema), ...bundleOnly, ...skipped]); + + const unaccounted = [...sdkFiles].filter((file) => !accounted.has(file)).sort(); + if (unaccounted.length > 0) { + const list = unaccounted.map((f) => ` - ${f}`).join('\n'); + throw new Error( + `[xsd-sync] ${unaccounted.length} SDK schema(s) are not accounted for:\n${list}\n` + + `[xsd-sync] Edit resources/xsd-mappings.json — add each to one of:\n` + + `[xsd-sync] mappings[] (bundled + contributed as fileMatch) for user-facing schemas\n` + + `[xsd-sync] bundleOnly[] (bundled, no fileMatch) for schemas imported by other XSDs\n` + + `[xsd-sync] skipped[] (excluded entirely) for internal/feed/non-workspace schemas.`, + ); + } + + const phantom = [...accounted].filter((file) => !sdkFiles.has(file)).sort(); + if (phantom.length > 0) { + const list = phantom.map((f) => ` - ${f}`).join('\n'); + throw new Error( + `[xsd-sync] ${phantom.length} schema(s) listed in xsd-mappings.json no longer exist in the SDK:\n${list}\n` + + `[xsd-sync] Remove them from mappings[]/bundleOnly[]/skipped[] in resources/xsd-mappings.json.`, + ); + } +} + +function syncSchemas(mappings, bundleOnly, sdkXsdDir, extensionXsdDir) { + if (!fs.existsSync(sdkXsdDir)) { + throw new Error(`[xsd-sync] SDK XSD source directory not found: ${sdkXsdDir}`); + } + fs.mkdirSync(extensionXsdDir, {recursive: true}); + + const sourceFiles = new Set(listXsdFiles(sdkXsdDir)); + // Bundle both: schemas users validate against AND their transitive imports. + const requested = new Set([...mappings.map((m) => m.schema), ...bundleOnly]); + + for (const schema of requested) { + if (!sourceFiles.has(schema)) { + throw new Error(`[xsd-sync] Schema "${schema}" is missing from SDK source directory ${sdkXsdDir}`); + } + } + + let removedCount = 0; + for (const file of listXsdFiles(extensionXsdDir)) { + if (!requested.has(file)) { + fs.rmSync(path.join(extensionXsdDir, file), {force: true}); + removedCount += 1; + } + } + + let copiedCount = 0; + let unchangedCount = 0; + for (const schema of requested) { + const src = path.join(sdkXsdDir, schema); + const dest = path.join(extensionXsdDir, schema); + if (filesEqual(src, dest)) { + unchangedCount += 1; + continue; + } + fs.copyFileSync(src, dest); + copiedCount += 1; + } + + return {total: requested.size, copiedCount, unchangedCount, removedCount}; +} + +function buildContribution(mappings) { + const entries = []; + for (const mapping of mappings) { + for (const fileMatch of mapping.fileMatch) { + entries.push({fileMatch, url: `./resources/xsd/${mapping.schema}`}); + } + } + return entries; +} + +function writeContribution(packageJsonPath, generated) { + const raw = fs.readFileSync(packageJsonPath, 'utf8'); + const trailingNewline = raw.endsWith('\n'); + const pkg = JSON.parse(raw); + pkg.contributes ??= {}; + const existing = pkg.contributes.xmlValidation ?? []; + + const sameContent = + existing.length === generated.length && + existing.every((entry, idx) => entry?.fileMatch === generated[idx].fileMatch && entry?.url === generated[idx].url); + + if (sameContent) return false; + + pkg.contributes.xmlValidation = generated; + fs.writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + (trailingNewline ? '\n' : '')); + return true; +} + +/** + * Run the full sync. Safe to call multiple times — copies only when source/dest + * differ and rewrites package.json only when the contribution array would change. + */ +export function syncXsd({pkgRoot = defaultPkgRoot} = {}) { + const sdkXsdDir = path.resolve(pkgRoot, '..', 'b2c-tooling-sdk', 'data', 'xsd'); + const extensionXsdDir = path.join(pkgRoot, 'resources', 'xsd'); + const mappingsPath = path.join(pkgRoot, 'resources', 'xsd-mappings.json'); + const packageJsonPath = path.join(pkgRoot, 'package.json'); + + const {mappings, bundleOnly, skipped} = readMappings(mappingsPath); + assertFullSdkCoverage(mappings, bundleOnly, skipped, sdkXsdDir); + const stats = syncSchemas(mappings, bundleOnly, sdkXsdDir, extensionXsdDir); + const contribution = buildContribution(mappings); + const wrotePkg = writeContribution(packageJsonPath, contribution); + + console.log( + `[xsd-sync] ${mappings.length} mapped, ${bundleOnly.length} bundleOnly, ${skipped.length} skipped; ${contribution.length} fileMatch entries (${stats.copiedCount} copied, ${stats.unchangedCount} unchanged, ${stats.removedCount} removed)${wrotePkg ? ' — wrote package.json' : ''}`, + ); + return { + ...stats, + mappedCount: mappings.length, + bundleOnlyCount: bundleOnly.length, + skippedCount: skipped.length, + contributionEntries: contribution.length, + wrotePackageJson: wrotePkg, + }; +} + +// Allow direct invocation (e.g. `node scripts/sync-xsd.mjs`) for emergencies, +// even though the build pipeline calls syncXsd() directly. +const isDirectInvoke = process.argv[1] && path.resolve(process.argv[1]) === __filename; +if (isDirectInvoke) { + try { + syncXsd(); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } +} diff --git a/packages/b2c-vs-extension/src/test/xml-validation.test.ts b/packages/b2c-vs-extension/src/test/xml-validation.test.ts new file mode 100644 index 000000000..60f01066b --- /dev/null +++ b/packages/b2c-vs-extension/src/test/xml-validation.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import {fileURLToPath} from 'url'; + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const packageJsonPath = path.join(pkgRoot, 'package.json'); +const mappingsPath = path.join(pkgRoot, 'resources', 'xsd-mappings.json'); +const xsdDir = path.join(pkgRoot, 'resources', 'xsd'); + +interface XmlValidationEntry { + fileMatch: string; + url: string; +} + +interface MappingEntry { + schema: string; + fileMatch: string[]; +} + +interface MappingsFile { + mappings: MappingEntry[]; + bundleOnly: string[]; + skipped: string[]; +} + +function readPackageJson(): {contributes?: {xmlValidation?: XmlValidationEntry[]}} { + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); +} + +function readMappings(): MappingEntry[] { + return JSON.parse(fs.readFileSync(mappingsPath, 'utf8')).mappings; +} + +function readMappingsFile(): MappingsFile { + const parsed = JSON.parse(fs.readFileSync(mappingsPath, 'utf8')); + return {mappings: parsed.mappings, bundleOnly: parsed.bundleOnly ?? [], skipped: parsed.skipped ?? []}; +} + +function listSdkXsds(): string[] { + const sdkXsdDir = path.resolve(pkgRoot, '..', 'b2c-tooling-sdk', 'data', 'xsd'); + return fs + .readdirSync(sdkXsdDir) + .filter((name) => name.endsWith('.xsd')) + .sort(); +} + +suite('XML schema validation contribution', () => { + test('package.json has xmlValidation entries', () => { + const entries = readPackageJson().contributes?.xmlValidation ?? []; + assert.ok(entries.length > 0, 'expected at least one xmlValidation entry'); + }); + + test('every xmlValidation url resolves to an XSD file on disk', () => { + const entries = readPackageJson().contributes?.xmlValidation ?? []; + for (const entry of entries) { + assert.ok(entry.url.startsWith('./resources/xsd/'), `unexpected url shape: ${entry.url}`); + const abs = path.resolve(pkgRoot, entry.url); + assert.ok(fs.existsSync(abs), `XSD file is missing for ${entry.fileMatch}: ${abs}`); + } + }); + + test('xsd-mappings.json drives package.json contributes.xmlValidation (no drift)', () => { + const mappings = readMappings(); + const expected: XmlValidationEntry[] = []; + for (const mapping of mappings) { + for (const fileMatch of mapping.fileMatch) { + expected.push({fileMatch, url: `./resources/xsd/${mapping.schema}`}); + } + } + const actual = readPackageJson().contributes?.xmlValidation ?? []; + assert.deepStrictEqual( + actual, + expected, + 'package.json#contributes.xmlValidation is out of sync with resources/xsd-mappings.json — run `pnpm run build`.', + ); + }); + + test('every schema referenced in mappings exists under resources/xsd/', () => { + for (const {schema} of readMappings()) { + const abs = path.join(xsdDir, schema); + assert.ok(fs.existsSync(abs), `Mapped schema not found on disk: ${schema}`); + } + }); + + test('resources/xsd/ contains no XSDs that are not declared in mappings or bundleOnly', () => { + const {mappings, bundleOnly} = readMappingsFile(); + const declared = new Set([...mappings.map((m) => m.schema), ...bundleOnly]); + const onDisk = fs.readdirSync(xsdDir).filter((name) => name.endsWith('.xsd')); + for (const file of onDisk) { + assert.ok(declared.has(file), `Stray XSD on disk (not in mappings or bundleOnly): ${file}`); + } + }); + + test('every SDK XSD is mapped, bundleOnly, or skipped (no silent gaps)', () => { + const {mappings, bundleOnly, skipped} = readMappingsFile(); + const accounted = new Set([...mappings.map((m) => m.schema), ...bundleOnly, ...skipped]); + const sdkXsds = listSdkXsds(); + + const unaccounted = sdkXsds.filter((file) => !accounted.has(file)); + assert.deepStrictEqual( + unaccounted, + [], + `SDK schemas not accounted for — add to mappings[], bundleOnly[], or skipped[] in resources/xsd-mappings.json: ${unaccounted.join(', ')}`, + ); + + const sdkSet = new Set(sdkXsds); + const phantom = [...accounted].filter((file) => !sdkSet.has(file)); + assert.deepStrictEqual( + phantom, + [], + `Schemas listed in xsd-mappings.json that no longer exist in the SDK: ${phantom.join(', ')}`, + ); + }); + + test('schemas imported by mapped XSDs are present (transitive bundling)', () => { + const {mappings} = readMappingsFile(); + const declared = new Set(fs.readdirSync(xsdDir).filter((name) => name.endsWith('.xsd'))); + const importPattern = /schemaLocation\s*=\s*"([^"]+\.xsd)"/g; + + for (const {schema} of mappings) { + const content = fs.readFileSync(path.join(xsdDir, schema), 'utf8'); + let match: RegExpExecArray | null; + while ((match = importPattern.exec(content)) !== null) { + const imported = match[1].split('/').pop()!; + assert.ok( + declared.has(imported), + `${schema} imports "${imported}" but it is not bundled — add it to bundleOnly[] in resources/xsd-mappings.json`, + ); + } + } + }); +});