diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..5ab8121e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +# Built SPA output and framework CSS bundles — regenerated by npm run +# build:apps / npm run build, never hand-edited. Marking them +# linguist-generated hides them from GitHub's diff view and language stats +# by default (viewable via "Load diff" when actually needed) so PR reviews +# focus on source changes instead of noisy full-file rewrites of minified +# JS/CSS/source-map output. +SLASHED-for-WP/assets/admin-app/** linguist-generated=true +SLASHED-for-WP/integrations/bricks/assets/editor-app/** linguist-generated=true +SLASHED-for-WP/dist/** linguist-generated=true + +# Minified/source-map output within those trees is binary-diffed content in +# practice — suppress line-oriented diffs entirely rather than showing an +# unreadable one-line "changed" diff. +*.min.css -diff +*.min.js -diff +*.js.map -diff +*.css.map -diff diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7192e09..df507656 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,9 @@ jobs: - name: Lint PHP syntax (php -l) run: npm run lint:php + - name: PHP unit tests + run: composer phpunit + - name: Register PHPCS standards run: >- vendor/bin/phpcs --config-set installed_paths diff --git a/.gitignore b/.gitignore index 584a3b4c..d7fe640e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ test-screenshots/ # PHP Composer dependencies (PHPCS/WPCS + PHPStan tooling). # composer.json + composer.lock are tracked; the installed tree is not. /vendor/ + +# PHPUnit result cache. +/.phpunit.cache/ diff --git a/CLAUDE.md b/CLAUDE.md index 7bd28c5d..5228c4da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,12 +7,16 @@ SLASHED-for-WP/ WordPress plugin admin-app/ Svelte SPA — configurator UI embedded in WP admin + frontend overlay src/ ⚠️ VENDORED — see below framework-css/ Vendored framework CSS (chrome layers + full bundle) + assets/ Built SPA output (admin-app/, editor-app/ — committed build artifacts) + data/ Generated inventories/hints (class-hints.json, variables-hints.json…) integrations/ bricks/editor-app/ Bricks Builder panel — independent, NOT vendored gutenberg/ Gutenberg integration includes/ PHP backend (REST API, CSS generator, token store…) dist/ Framework CSS bundles (updated by npm run update-framework) scripts/ Plugin-level build/sync scripts +tests/ Node --test suite + Playwright admin smoke test +docs/ Project docs ``` ## ⚠️ Vendored files — DO NOT edit in this repo @@ -64,13 +68,19 @@ These are plugin-specific and never overwritten by sync: ## Syncing the framework into the plugin +`npm run sync` is defined in `SLASHED-for-WP/admin-app/package.json`, not the +root `package.json` — run it from that directory (or via `npm run +build:admin-app` at the root, which runs it as a `prebuild` step): + ```bash -npm run sync # pull latest configurator/src from framework (local sibling or GitHub) -npm run build:apps # rebuild admin SPA + bricks editor app +cd SLASHED-for-WP/admin-app +npm run sync # pull latest configurator/src from framework (local sibling or GitHub) +cd ../../.. +npm run build:apps # rebuild admin SPA + bricks editor app ``` `SLASHED_CONFIGURATOR_SRC=/path/to/SLASHED/configurator/src npm run sync` -forces a specific local checkout. +(run from `SLASHED-for-WP/admin-app`) forces a specific local checkout. ## Updating the bundled framework CSS @@ -91,4 +101,21 @@ Downloads release CSS bundles, shallow-clones framework source, regenerates | `npm run build` | Full build: data + sync-dist + SPA apps + zip | | `npm run build:apps` | Build admin SPA + Bricks editor app | | `npm test` | Run test suite | -| `npm run verify` | Verify sync consistency | +| `npm run verify` | Verify version metadata is in sync | +| `npm run check` | Verify generated artifacts (class hints, variables hints, vendored admin-app core) aren't stale — exits non-zero on drift, never writes | +| `composer phpunit` | Run the PHP unit suite (`tests-php/`) | + +`tests/` is `node --test` specs, run automatically by `npm test`, with one +exception: `tests/playwright-admin.js` is a manual, local-only dev/QA tool — +it walks the admin SPA and saves screenshots for a human to review, has no +pass/fail assertions, and isn't wired into `npm test` or CI (no committed +HTML fixture, needs a locally-running dev server). Run it directly with +`node tests/playwright-admin.js`; see the file header for prerequisites. + +`tests-php/` is a plain PHPUnit suite (`composer phpunit`, wired into CI's +`quality` job) covering pure/near-pure PHP logic that needs no WordPress +runtime — CSS parsing, override-value validation, and REST input +sanitization. `tests-php/bootstrap.php` defines `ABSPATH` and stubs the one +WordPress function this code touches (`sanitize_key()`) rather than pulling +in a mocking framework; it does not boot WordPress, so classes with real +`wpdb`/hook dependencies aren't covered here. diff --git a/SLASHED-for-WP/admin-app/.syncignore b/SLASHED-for-WP/admin-app/.syncignore index 3fef6930..bb91a978 100644 --- a/SLASHED-for-WP/admin-app/.syncignore +++ b/SLASHED-for-WP/admin-app/.syncignore @@ -3,11 +3,6 @@ # Plugin-specific build wiring lives OUTSIDE src/ (vite.config.js, package.json, # svelte.config.js, tsconfig.json) and is never touched by the sync. -# codeslash-dev/slashed#443 (the companion upstream PR that added @framework-css -# aliases + the WP persistence seam) merged to main on 2026-06-28. main.ts, -# App.svelte, PreviewPanel.svelte, vite-env.d.ts, lib/persistence.ts, and -# StudioHeader.svelte are no longer plugin-divergent — they sync normally now. - # Plugin-specific: frontend overlay (not in upstream configurator). src/plugin-main.ts src/AppOverlay.svelte diff --git a/SLASHED-for-WP/admin-app/.vendored-manifest.json b/SLASHED-for-WP/admin-app/.vendored-manifest.json index aab56124..a30fc678 100644 --- a/SLASHED-for-WP/admin-app/.vendored-manifest.json +++ b/SLASHED-for-WP/admin-app/.vendored-manifest.json @@ -4,7 +4,7 @@ "frameworkRepo": "https://github.com/codeslash-dev/SLASHED", "configuratorSrc": "configurator/src/", "source": "local", - "syncedAt": "2026-07-01T18:41:55.013Z" + "syncedAt": "2026-07-02T16:31:02.085Z" }, "vendoredFiles": [ { diff --git a/SLASHED-for-WP/admin-app/framework-css/core/layout.css b/SLASHED-for-WP/admin-app/framework-css/core/layout.css index ef3bbefa..c482b527 100644 --- a/SLASHED-for-WP/admin-app/framework-css/core/layout.css +++ b/SLASHED-for-WP/admin-app/framework-css/core/layout.css @@ -1,11 +1,15 @@ /* SLASHED — core/layout.css @layer slashed.layout Layout primitives: section, container, stack, cluster, grid, sidebar, switcher, frame. - Prefix: .sf-*; local rhythm via --sf-*-gap / sizing tokens. */ + Prefix: .sf-*; local rhythm via --sf-*-gap / sizing tokens. + SL-005: every @container query in this file hardcodes its breakpoint + instead of referencing a --sf-* token — var() is not allowed inside an + @container condition per the CSS spec. This applies framework-wide to + every @container site below; not re-explained at each one. */ @layer slashed.layout { - + /* Section */ .sf-section { padding-block: var(--sf-section-pad); } .sf-section--xs { --sf-section-pad: var(--sf-section-pad--xs); } .sf-section--s { --sf-section-pad: var(--sf-section-pad--s); } @@ -52,6 +56,7 @@ } + /* Container */ .sf-container { container: sf-layout / inline-size; width: 100%; @@ -71,6 +76,7 @@ } + /* Stack & gap */ .sf-stack { display: flex; flex-direction: column; @@ -96,12 +102,14 @@ .sf-gap--2xl { gap: var(--sf-space-2xl); } + /* Box */ .sf-box { padding: var(--sf-box-padding); outline: var(--sf-box-border-width) solid var(--sf-box-border-color); } + /* Center */ .sf-center { box-sizing: content-box; width: auto; @@ -117,6 +125,7 @@ } + /* Cluster */ .sf-cluster { display: flex; flex-wrap: wrap; @@ -137,6 +146,7 @@ .sf-cluster--between { justify-content: space-between; } + /* Sidebar */ .sf-sidebar { display: flex; flex-wrap: wrap; @@ -169,6 +179,7 @@ .sf-sidebar--wide { --sf-sidebar-width: 26rem; } + /* Switcher */ .sf-switcher { display: flex; flex-wrap: wrap; @@ -184,6 +195,7 @@ .sf-switcher--vertical { flex-direction: column; } + /* Grid & icon */ .sf-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(var(--sf-grid-min), 100%), 1fr)); @@ -229,6 +241,7 @@ } + /* Cover */ .sf-cover { display: flex; flex-direction: column; @@ -247,6 +260,7 @@ .sf-cover--padding-l { padding-block: var(--sf-space-4xl); } + /* Frame */ .sf-frame { aspect-ratio: var(--sf-frame-ratio); overflow: hidden; @@ -395,7 +409,6 @@ container: sf-grid / inline-size; } - /* Cannot use var() inside @container per CSS spec — breakpoints hardcoded. */ @container (min-width: 30em) { .sf-grid-cols-4 { grid-template-columns: repeat(2, 1fr); } .sf-grid-cols-6 { grid-template-columns: repeat(3, 1fr); } diff --git a/SLASHED-for-WP/admin-app/framework-css/core/themes.css b/SLASHED-for-WP/admin-app/framework-css/core/themes.css index a0bb707f..8f501eba 100644 --- a/SLASHED-for-WP/admin-app/framework-css/core/themes.css +++ b/SLASHED-for-WP/admin-app/framework-css/core/themes.css @@ -58,7 +58,12 @@ /* SECTION-LEVEL THEMING */ /* light-dark() bakes at :root declaration time; re-declarations on [data-theme] - elements are required for section-level theming to work. */ + elements are required for section-level theming to work. + SL-001: the clamp() derivation formula below is the same one used inside + tokens.css's "Resolved color tokens — auto-switch via light-dark()" + block (search core/tokens.css for that comment) — duplicated here, not + shared, because this file needs flat values outside light-dark(). + Keep both copies in sync if the formula ever changes. */ @supports (color: oklch(from red l c h)) { :root { diff --git a/SLASHED-for-WP/admin-app/framework-css/core/tokens.css b/SLASHED-for-WP/admin-app/framework-css/core/tokens.css index 4bc09994..35613a14 100644 --- a/SLASHED-for-WP/admin-app/framework-css/core/tokens.css +++ b/SLASHED-for-WP/admin-app/framework-css/core/tokens.css @@ -101,14 +101,20 @@ --sf-color-danger: var(--sf-color-danger-source-light); } - /* Mode flag — drives formula direction for non-color dark overrides. - Set by themes.css via [data-theme="dark"] and the prefers-color-scheme - media query. Do not set directly. */ + /* Mode flag — INTERNAL, not a public hook (SL-003: same "--sf-is-*" naming + pattern as the public flags below, opposite contract — read here, don't + set). Drives formula direction for non-color dark overrides. Set only by + themes.css via [data-theme="dark"] and the prefers-color-scheme media + query; every other read site (core/tokens.css, the configurator's power + knobs) only reads it inside calc() expressions. Setting it directly + desyncs it from color-scheme/[data-theme] and produces an inconsistent + theme. */ @property --sf-is-dark { syntax: ""; inherits: true; initial-value: 0; } /* @property — INTERACTION STATE FLAGS */ - /* Public hooks for Style Queries. Allow components to react to states - toggled on ancestors via .is-* classes. */ + /* PUBLIC hooks for Style Queries — safe to set from consumer code, unlike + --sf-is-dark above. Allow components to react to states toggled on + ancestors via .is-* classes. */ @property --sf-is-active { syntax: ""; inherits: true; initial-value: 0; } @property --sf-is-current { syntax: ""; inherits: true; initial-value: 0; } @property --sf-is-pressed { syntax: ""; inherits: true; initial-value: 0; } @@ -366,7 +372,11 @@ Dark auto-derivation formula (brand + status): clamp(0.65, 0.95 - l*0.5, 0.88) lightens dark-mode value relative to the light source. Surface inverts: clamp(0.16, 1.18 - l, 0.24) — near-white flips to near-dark. - Override any --sf-color-X-dark to take full per-mode control. */ + Override any --sf-color-X-dark to take full per-mode control. + SL-001: this same clamp() formula is re-declared flat in themes.css + (SECTION-LEVEL THEMING) so [data-theme] overrides on non-:root elements + still resolve correctly — light-dark() only bakes at :root. Keep both + copies in sync if the formula ever changes. */ --sf-color-primary: light-dark(var(--sf-color-primary-source-light), var(--sf-color-primary-source-dark, oklch(from var(--sf-color-primary-source-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); --sf-color-secondary: light-dark(var(--sf-color-secondary-source-light), var(--sf-color-secondary-source-dark, oklch(from var(--sf-color-secondary-source-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); --sf-color-tertiary: light-dark(var(--sf-color-tertiary-source-light), var(--sf-color-tertiary-source-dark, oklch(from var(--sf-color-tertiary-source-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); diff --git a/SLASHED-for-WP/admin-app/package-lock.json b/SLASHED-for-WP/admin-app/package-lock.json index a76c5a4f..34a3e9df 100644 --- a/SLASHED-for-WP/admin-app/package-lock.json +++ b/SLASHED-for-WP/admin-app/package-lock.json @@ -8,8 +8,8 @@ "name": "slashed-admin-app", "version": "0.1.0", "dependencies": { + "@lucide/svelte": "^1.23.0", "fflate": "^0.8.3", - "lucide-svelte": "^1.0.1", "motion": "^12.23.24" }, "devDependencies": { @@ -102,6 +102,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lucide/svelte": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.23.0.tgz", + "integrity": "sha512-3LQbKXx9vId6Nx4E2Nu2qwgJfdmr5+CVeVJbxe5cy+HcnCRd9QVVtZXqvgBYAV1OJrPmQAf9/3gJWLCpASC/Ng==", + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1216,16 +1225,6 @@ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "license": "MIT" }, - "node_modules/lucide-svelte": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz", - "integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==", - "deprecated": "Package deprecated. Please use @lucide/svelte instead.", - "license": "ISC", - "peerDependencies": { - "svelte": "^3 || ^4 || ^5.0.0-next.42" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/SLASHED-for-WP/admin-app/package.json b/SLASHED-for-WP/admin-app/package.json index f5c18787..d7b46a8d 100644 --- a/SLASHED-for-WP/admin-app/package.json +++ b/SLASHED-for-WP/admin-app/package.json @@ -10,11 +10,12 @@ "dev": "vite", "prebuild": "node scripts/sync-core.mjs", "build": "vite build", + "precheck": "node scripts/sync-core.mjs --check", "check": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { + "@lucide/svelte": "^1.23.0", "fflate": "^0.8.3", - "lucide-svelte": "^1.0.1", "motion": "^12.23.24" }, "devDependencies": { diff --git a/SLASHED-for-WP/admin-app/scripts/sync-core.mjs b/SLASHED-for-WP/admin-app/scripts/sync-core.mjs index 73a0ff72..3875dd60 100644 --- a/SLASHED-for-WP/admin-app/scripts/sync-core.mjs +++ b/SLASHED-for-WP/admin-app/scripts/sync-core.mjs @@ -19,6 +19,13 @@ * to diverge — `.syncignore` is empty by default. Plugin build wiring lives * outside src/ (vite.config.js, package.json, svelte.config.js, tsconfig.json) * and is never touched by this script. + * + * --check / --dry-run: reports drift against the framework source (files that + * would change, be added, or are vendored locally but no longer exist upstream) + * without writing anything. Used by `npm run check` (PL-025) to catch admin-app + * vendoring drift in CI. See writeFile() below — every write in this script + * routes through it, so the "never touch disk in check mode" invariant only + * has to hold in one place. */ import { @@ -29,10 +36,13 @@ import { import { resolve, dirname, join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +const CHECK_MODE = process.argv.includes('--check') || process.argv.includes('--dry-run'); + // ── Vendored manifest ───────────────────────────────────────────────────────── -// Written after every sync so editors / AI tools can tell at a glance which -// files in src/ originate from the framework and must NOT be edited here. -// Edit those files in codeslash-dev/SLASHED → configurator/src/ instead. +// Written after every real sync so editors / AI tools can tell at a glance +// which files in src/ originate from the framework and must NOT be edited +// here. Edit those files in codeslash-dev/SLASHED → configurator/src/ instead. +// Skipped entirely in --check mode (no sync happened, nothing to record). const MANIFEST_PATH = resolve(resolve(dirname(fileURLToPath(import.meta.url)), '..'), '.vendored-manifest.json'); const _vendoredFiles = []; @@ -125,18 +135,101 @@ function isIgnored(relFromSrc) { return syncIgnore.has(posix) || syncIgnore.has('src/' + posix); } +// ── --check mode: drift tracking + the single write call site ───────────────── + +const driftFindings = []; +// Relative (posix, from SRC) paths this run actually resolved against the +// framework source — used in --check mode to detect files vendored locally +// that no longer exist upstream ("orphans"). +const visitedSrcRel = new Set(); + +function reportDrift(kind, label, detail) { + driftFindings.push(` ${kind.padEnd(8)} ${label}${detail ? ` (${detail})` : ''}`); +} + +/** + * Writes `content` (string or Buffer) to `destPath` — or, in --check mode, + * compares it against the existing file and records drift instead of + * touching disk. This is the *only* place either the real sync or the check + * mode writes a file, so "never write in --check mode" only needs to be true + * here rather than in every call site separately. + */ +function writeFile(destPath, content, label) { + if (!CHECK_MODE) { + mkdirSync(dirname(destPath), { recursive: true }); + writeFileSync(destPath, content); + return; + } + const next = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8'); + if (!existsSync(destPath)) { + reportDrift('missing', label, 'present upstream, not vendored locally'); + return; + } + const existing = readFileSync(destPath); + if (!existing.equals(next)) { + reportDrift('stale', label, 'vendored copy differs from upstream source'); + } +} + +/** Recursively list every file under `dir`, relative (posix) to `base`. Read-only. */ +function listRelFiles(dir, base, out = []) { + if (!existsSync(dir)) return out; + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) { + listRelFiles(p, base, out); + } else { + out.push(toPosix(relative(base, p))); + } + } + return out; +} + +/** + * In --check mode, flag files vendored locally but no longer resolved from + * upstream: src/ files not visited this run, and framework-css/core/ files + * outside the fixed CHROME_LAYERS set (the complete, hardcoded list of + * chrome layers this script ever vendors — anything else there is stale). + */ +function reportOrphans() { + if (!CHECK_MODE) return; + for (const rel of listRelFiles(SRC, SRC)) { + if (isIgnored(rel) || visitedSrcRel.has(rel)) continue; + reportDrift('orphan', `src/${rel}`, 'vendored locally, no longer present upstream'); + } + for (const rel of listRelFiles(VENDOR_CORE, VENDOR_CORE)) { + if (CHROME_LAYERS.includes(rel)) continue; + reportDrift('orphan', `framework-css/core/${rel}`, 'vendored locally, no longer a tracked chrome layer'); + } +} + +/** Print the check result and set the process exit code. Never used outside --check mode. */ +function finishCheck(sourceLabel) { + console.log(` source: ${sourceLabel}`); + if (driftFindings.length === 0) { + console.log('OK — admin-app/src/ (and vendored framework-css/) match the framework configurator.'); + return; + } + console.error('Drift detected against the framework configurator:'); + for (const line of driftFindings) console.error(line); + console.error(`\n${driftFindings.length} finding(s) — run npm run sync to update.`); + process.exitCode = 1; +} + // ── Framework CSS vendoring (shared) ─────────────────────────────────────────── /** Copy the plugin's full bundle into the vendored badges/ dir for the preview. */ function vendorFullBundle() { - mkdirSync(VENDOR_BADGES, { recursive: true }); + const label = 'framework-css/badges/slashed.full.css'; + const dest = join(VENDOR_BADGES, 'slashed.full.css'); if (existsSync(PLUGIN_FULL_CSS)) { - copyFileSync(PLUGIN_FULL_CSS, join(VENDOR_BADGES, 'slashed.full.css')); - process.stdout.write(' copy framework-css/badges/slashed.full.css (from dist/)\n'); - } else if (existsSync(join(VENDOR_BADGES, 'slashed.full.css'))) { - process.stdout.write(' keep framework-css/badges/slashed.full.css (dist/ bundle missing)\n'); + if (!CHECK_MODE) mkdirSync(VENDOR_BADGES, { recursive: true }); + writeFile(dest, readFileSync(PLUGIN_FULL_CSS), label); + if (!CHECK_MODE) process.stdout.write(` copy ${label} (from dist/)\n`); + } else if (existsSync(dest)) { + if (!CHECK_MODE) process.stdout.write(` keep ${label} (dist/ bundle missing)\n`); } else { - process.stderr.write(' WARN dist/slashed.full.css not found — preview will be unstyled until the framework CSS is installed.\n'); + process.stderr.write(` WARN dist/slashed.full.css not found — preview will be unstyled until the framework CSS is installed.\n`); } } @@ -167,37 +260,162 @@ function copyLocalDir(srcDir, srcBase) { for (const entry of readdirSync(srcDir)) { const srcPath = join(srcDir, entry); const rel = relative(srcBase, srcPath); - if (statSync(srcPath).isDirectory()) { - copyLocalDir(srcPath, srcBase); - } else { - if (isIgnored(rel)) { - process.stdout.write(` skip src/${rel} (syncignore)\n`); - continue; + // Open first so the isDirectory check and (for files) the read below + // operate on the same fd/inode — no TOCTOU window between checking what + // the entry is and using it, matching the O_NOFOLLOW idiom used elsewhere + // in this file for the syncignore-preservation reads. + const fd = openSync(srcPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + let isDir; + try { + isDir = fstatSync(fd).isDirectory(); + if (!isDir) { + if (isIgnored(rel)) { + if (!CHECK_MODE) process.stdout.write(` skip src/${rel} (syncignore)\n`); + } else { + const destPath = join(SRC, rel); + assertWithinSrc(destPath); + const relPosix = toPosix(rel); + visitedSrcRel.add(relPosix); + writeFile(destPath, readFileSync(fd), `src/${relPosix}`); + if (!CHECK_MODE) { + trackVendored(rel, `local:configurator/src/${rel}`); + process.stdout.write(` copy src/${rel}\n`); + } + } } - const destPath = join(SRC, rel); - assertWithinSrc(destPath); - mkdirSync(dirname(destPath), { recursive: true }); - copyFileSync(srcPath, destPath); - trackVendored(rel, `local:configurator/src/${rel}`); - process.stdout.write(` copy src/${rel}\n`); + } finally { + closeSync(fd); } + if (isDir) copyLocalDir(srcPath, srcBase); } } function vendorChromeLocal(repoRoot) { const coreDir = resolve(repoRoot, 'core'); - mkdirSync(VENDOR_CORE, { recursive: true }); + if (!CHECK_MODE) mkdirSync(VENDOR_CORE, { recursive: true }); for (const name of CHROME_LAYERS) { const from = join(coreDir, name); if (!existsSync(from)) { throw new Error(`Framework chrome layer missing: ${from}`); } - copyFileSync(from, join(VENDOR_CORE, name)); - process.stdout.write(` copy framework-css/core/${name}\n`); + const dest = join(VENDOR_CORE, name); + const label = `framework-css/core/${name}`; + writeFile(dest, readFileSync(from), label); + if (!CHECK_MODE) process.stdout.write(` copy ${label}\n`); } } // ── GitHub API sync ─────────────────────────────────────────────────────────── +// +// Content fetched here is written to disk verbatim by writeFile() (source +// vendoring — there's no meaningful way to "sanitize" a .svelte/.ts file +// without corrupting it). The accepted trust boundary: SLASHED_REPO/REF are +// hardcoded constants (not derived from any input), fetched over HTTPS via +// the official GitHub REST API, and every write destination is confined to +// SRC by assertWithinSrc()/assertSafeName() regardless of fetched content. + +const RETRY_MAX_ATTEMPTS = 3; // total attempts, i.e. up to 2 retries. +const RETRY_BASE_DELAY_MS = 500; + +/** Exponential backoff delay (ms) before retry attempt `attempt` (0-based). */ +export function backoffDelayMs(attempt, baseDelayMs = RETRY_BASE_DELAY_MS) { + return baseDelayMs * 2 ** attempt; +} + +function defaultSleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +/** + * fetch() wrapper that retries transient failures with exponential backoff: + * network errors (fetch itself throwing), 5xx responses, and 403 responses + * GitHub marks as an exhausted rate limit (`x-ratelimit-remaining: 0`). + * Any other response (2xx, or a non-retryable error like plain 403/404) is + * returned/thrown immediately on the first attempt so existing callers' + * "keep the vendored copy" fallback logic still runs on real not-found / + * permission errors instead of being delayed by pointless retries. + * + * `fetchImpl`/`sleep` are injectable so this is unit-testable without a + * real network or real timers. + */ +export async function fetchWithRetry( + url, + options, + { maxAttempts = RETRY_MAX_ATTEMPTS, baseDelayMs = RETRY_BASE_DELAY_MS, fetchImpl = fetch, sleep = defaultSleep } = {}, +) { + let lastErr; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + let res; + try { + res = await fetchImpl(url, options); + } catch (err) { + lastErr = err; + if (attempt === maxAttempts - 1) throw err; + await sleep(backoffDelayMs(attempt, baseDelayMs)); + continue; + } + if (res.ok) return res; + const rateLimited = res.status === 403 && res.headers.get('x-ratelimit-remaining') === '0'; + const retryable = res.status >= 500 || rateLimited; + if (!retryable || attempt === maxAttempts - 1) return res; + await sleep(backoffDelayMs(attempt, baseDelayMs)); + } + // Unreachable in practice (the loop always returns or throws above), but + // keeps control flow explicit for the "all attempts were network errors" case. + throw lastErr; +} + +// Caps total concurrent GitHub API requests across the *whole* recursive tree +// walk (not just per-directory) so a wide/deep configurator/src tree can't +// fan out to hundreds of simultaneous requests and trip GitHub's secondary +// rate limits. Deliberately wraps only the network call itself (inside +// ghFetch(), below) rather than the recursive syncGhDir()/syncGhFile() work: +// wrapping a whole recursive subtree would have a directory job hold a slot +// for its entire subtree's duration while its own children queue behind the +// same limiter for a slot — a reentrant lock-holding pattern that can starve +// or deadlock once enough directory jobs are simultaneously in flight. +// Wrapping just the atomic HTTP request has no such self-referential wait. +const SYNC_CONCURRENCY = 6; + +/** + * Bounded-concurrency job queue. `run(fn)` queues `fn` and resolves/rejects + * with its result once it's had a turn; at most `limit` jobs run at once. + * + * `fn` is invoked through `Promise.resolve().then(fn)` rather than called + * directly so a job that throws synchronously (instead of returning a + * rejected promise) still goes through the same resolve/reject/cleanup path + * — a bare `fn()` call would let a sync throw skip `.finally()` entirely, + * permanently leaking an "active" slot and stalling every future job. + * + * @param {number} limit + * @returns {(fn: () => Promise) => Promise} + */ +export function createLimiter(limit) { + if (!Number.isInteger(limit) || limit < 1) { + throw new TypeError(`createLimiter: limit must be a positive integer, got ${limit}`); + } + let active = 0; + const queue = []; + + function next() { + if (active >= limit || queue.length === 0) return; + active++; + const { fn, resolve, reject } = queue.shift(); + Promise.resolve().then(fn).then(resolve, reject).finally(() => { + active--; + next(); + }); + } + + return function run(fn) { + return new Promise((resolve, reject) => { + queue.push({ fn, resolve, reject }); + next(); + }); + }; +} + +const ghLimiter = createLimiter(SYNC_CONCURRENCY); async function ghFetch(path) { const url = `https://api.github.com/repos/${SLASHED_REPO}/contents/${path}?ref=${REF}`; @@ -208,7 +426,7 @@ async function ghFetch(path) { if (process.env.GITHUB_TOKEN) { headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; } - const res = await fetch(url, { headers }); + const res = await ghLimiter(() => fetchWithRetry(url, { headers })); if (!res.ok) { const err = new Error(`GitHub API ${res.status} for ${path}`); err.status = res.status; @@ -228,24 +446,27 @@ async function ghFetchContent(ghPath) { async function syncGhFile(ghPath, destPath) { assertWithinSrc(destPath); const rel = relative(SRC, destPath); + const relPosix = toPosix(rel); if (isIgnored(rel)) { - process.stdout.write(` skip src/${rel} (syncignore)\n`); + if (!CHECK_MODE) process.stdout.write(` skip src/${rel} (syncignore)\n`); return; } + visitedSrcRel.add(relPosix); let content; try { content = await ghFetchContent(ghPath); } catch (err) { if ((err.status === 403 || err.status === 404) && existsSync(destPath)) { - process.stdout.write(` keep src/${rel} (GitHub API ${err.status} — keeping vendored copy)\n`); + if (!CHECK_MODE) process.stdout.write(` keep src/${rel} (GitHub API ${err.status} — keeping vendored copy)\n`); return; } throw err; } - mkdirSync(dirname(destPath), { recursive: true }); - writeFileSync(destPath, content, { encoding: 'utf8' }); - trackVendored(rel, `github:${SLASHED_REPO}/${ghPath}@${REF}`); - process.stdout.write(` fetch src/${rel}\n`); + writeFile(destPath, content, `src/${relPosix}`); + if (!CHECK_MODE) { + trackVendored(rel, `github:${SLASHED_REPO}/${ghPath}@${REF}`); + process.stdout.write(` fetch src/${rel}\n`); + } } async function syncGhDir(ghDir, destDir) { @@ -254,6 +475,10 @@ async function syncGhDir(ghDir, destDir) { if (!Array.isArray(entries)) { throw new Error(`Expected directory listing from API for ${ghDir}`); } + // The recursive fan-out itself is unbounded — actual concurrency is capped + // where it matters, inside ghFetch()'s shared ghLimiter around the network + // request. See the comment on SYNC_CONCURRENCY above for why the limiter + // wraps the request rather than this whole recursive call. await Promise.all( entries.map((entry) => { assertSafeName(entry.name); @@ -266,18 +491,17 @@ async function syncGhDir(ghDir, destDir) { } async function vendorChromeRemote() { - mkdirSync(VENDOR_CORE, { recursive: true }); + if (!CHECK_MODE) mkdirSync(VENDOR_CORE, { recursive: true }); for (const name of CHROME_LAYERS) { const dest = join(VENDOR_CORE, name); + const label = `framework-css/core/${name}`; try { const content = await ghFetchContent(`core/${name}`); - // lgtm[js/path-injection] -- dest is join(VENDOR_CORE, name) where name - // is from the hardcoded CHROME_LAYERS array, not from network data. - writeFileSync(dest, content, 'utf8'); - process.stdout.write(` fetch framework-css/core/${name}\n`); + writeFile(dest, content, label); + if (!CHECK_MODE) process.stdout.write(` fetch ${label}\n`); } catch (err) { if ((err.status === 403 || err.status === 404) && existsSync(dest)) { - process.stdout.write(` keep framework-css/core/${name} (GitHub API ${err.status})\n`); + if (!CHECK_MODE) process.stdout.write(` keep ${label} (GitHub API ${err.status})\n`); continue; } throw err; @@ -288,21 +512,68 @@ async function vendorChromeRemote() { // ── Main ───────────────────────────────────────────────────────────────────── async function main() { - console.log('Syncing configurator core...'); + console.log(CHECK_MODE ? 'Checking configurator core sync...' : 'Syncing configurator core...'); const local = findLocalCfgSrc(); if (local) { - console.log(` source: local ${local}`); - // Fresh tree: drop any stale files from the previous fork before copying, - // but preserve syncignored plugin-specific files across the wipe (mirrors - // the same logic used in GitHub API mode). + if (!CHECK_MODE) { + console.log(` source: local ${local}`); + // Fresh tree: drop any stale files from the previous fork before copying, + // but preserve syncignored plugin-specific files across the wipe (mirrors + // the same logic used in GitHub API mode). + const preserved = []; + if (existsSync(SRC)) { + for (const entry of syncIgnore) { + const rel = entry.startsWith('src/') ? entry.slice(4) : entry; + const srcPath = resolve(join(SRC, rel)); + if (!srcPath.startsWith(SRC_ROOT)) continue; + // O_NOFOLLOW rejects symlinks at open time without a TOCTOU-prone pre-check. + let fd; + try { fd = openSync(srcPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); } catch { continue; } + try { + if (!fstatSync(fd).isFile()) continue; + preserved.push({ rel, content: readFileSync(fd) }); + process.stdout.write(` keep src/${rel} (syncignore — saved across wipe)\n`); + } finally { closeSync(fd); } + } + rmSync(SRC, { recursive: true, force: true }); + } + mkdirSync(SRC, { recursive: true }); + for (const { rel, content } of preserved) { + const destPath = resolve(join(SRC, rel)); + if (!destPath.startsWith(SRC_ROOT)) continue; + mkdirSync(dirname(destPath), { recursive: true }); + writeFileSync(destPath, content); + } + } + // --check mode never wipes/preserves — SRC is left untouched; copyLocalDir + // below only reads from it (via writeFile()'s compare branch). + copyLocalDir(local, local); + vendorChromeLocal(resolve(local, '../..')); // configurator/src -> repo root + vendorFullBundle(); + if (CHECK_MODE) { + reportOrphans(); + finishCheck(`local ${local}`); + return; + } + writeManifest(process.env.SLASHED_CONFIGURATOR_SRC ? 'local:SLASHED_CONFIGURATOR_SRC' : 'local'); + console.log('Done (local).'); + return; + } + + if (!CHECK_MODE) { + console.log(` source: GitHub ${SLASHED_REPO}@${REF}`); + // Fresh tree: drop stale files before fetching, but preserve any syncignored + // files so plugin-specific overrides survive the wipe. const preserved = []; if (existsSync(SRC)) { for (const entry of syncIgnore) { const rel = entry.startsWith('src/') ? entry.slice(4) : entry; const srcPath = resolve(join(SRC, rel)); if (!srcPath.startsWith(SRC_ROOT)) continue; - // O_NOFOLLOW rejects symlinks at open time without a TOCTOU-prone pre-check. + // Open the fd first so stat + read operate on the same inode (no + // TOCTOU). O_NOFOLLOW rejects symlinks at open time, matching the + // local-source preserve block above. let fd; try { fd = openSync(srcPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); } catch { continue; } try { @@ -320,51 +591,29 @@ async function main() { mkdirSync(dirname(destPath), { recursive: true }); writeFileSync(destPath, content); } - copyLocalDir(local, local); - vendorChromeLocal(resolve(local, '../..')); // configurator/src -> repo root - vendorFullBundle(); - writeManifest(process.env.SLASHED_CONFIGURATOR_SRC ? 'local:SLASHED_CONFIGURATOR_SRC' : 'local'); - console.log('Done (local).'); - return; - } - - console.log(` source: GitHub ${SLASHED_REPO}@${REF}`); - // Fresh tree: drop stale files before fetching, but preserve any syncignored - // files so plugin-specific overrides survive the wipe. - const preserved = []; - if (existsSync(SRC)) { - for (const entry of syncIgnore) { - const rel = entry.startsWith('src/') ? entry.slice(4) : entry; - const srcPath = resolve(join(SRC, rel)); - if (!srcPath.startsWith(SRC_ROOT)) continue; - // Open the fd first so stat + read operate on the same inode (no TOCTOU). - let fd; - try { fd = openSync(srcPath, 'r'); } catch { continue; } - try { - if (!fstatSync(fd).isFile()) continue; - preserved.push({ rel, content: readFileSync(fd) }); - process.stdout.write(` keep src/${rel} (syncignore — saved across wipe)\n`); - } finally { closeSync(fd); } - } - rmSync(SRC, { recursive: true, force: true }); - } - mkdirSync(SRC, { recursive: true }); - for (const { rel, content } of preserved) { - const destPath = resolve(join(SRC, rel)); - if (!destPath.startsWith(SRC_ROOT)) continue; - mkdirSync(dirname(destPath), { recursive: true }); - writeFileSync(destPath, content); } // Recursively vendor the entire configurator/src tree (components, lib, data, // and root files: App.svelte, main.ts, types.ts, app.css, vite-env.d.ts). await syncGhDir(CFG_SRC, SRC); await vendorChromeRemote(); vendorFullBundle(); + if (CHECK_MODE) { + reportOrphans(); + finishCheck(`GitHub ${SLASHED_REPO}@${REF}`); + return; + } writeManifest(`github:${SLASHED_REPO}@${REF}`); console.log('Done (remote).'); } -main().catch((err) => { - console.error('sync-core failed:', err.message); - process.exit(1); -}); +// Only run when executed directly (`node scripts/sync-core.mjs`, which is how +// every npm script invokes this file) — not when imported, so unit tests can +// import the exported pure helpers (backoffDelayMs, fetchWithRetry, +// createLimiter) above without triggering a real sync as an import side effect. +const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]); +if (isMainModule) { + main().catch((err) => { + console.error('sync-core failed:', err.message); + process.exit(1); + }); +} diff --git a/SLASHED-for-WP/admin-app/src/App.svelte b/SLASHED-for-WP/admin-app/src/App.svelte index 6b5d3b7d..9a256f18 100644 --- a/SLASHED-for-WP/admin-app/src/App.svelte +++ b/SLASHED-for-WP/admin-app/src/App.svelte @@ -1,19 +1,19 @@