From e422d89c319759b6a7d0a87d216362173bc4b47d Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 21 Sep 2026 19:45:38 +0200 Subject: [PATCH 1/2] feat: add local-first GitHub browser extension Add the portable browser API and complete Chrome Manifest V3 application with replacement-aware Changed projections, GitHub-native aggregate and per-file rendering, anchored reports, trusted-base policy acquisition, recoverable settings, bounded local caches, native label-picker handoff, and the full branded searchable settings experience. Carry literal stable #setting identifiers, Basic and Advanced views, the accepted shared diffdevil/brand glyph with full-colour and no-icon alternatives, privacy and security boundaries, browser/application/acquisition qualification, Store review material, and the regenerated install-free Action closure. Preserve the current managed-App runtime smoke gate and human/agent presentation documentation while adding the browser surface. The extension remains local-first and unpublished. This contribution does not merge, deploy, publish npm, create a Chrome Web Store listing, mutate provider labels automatically, or trigger hosted analysis. Room: https://github.com/Wolfsblvt/emergency-meeting/issues/496 Co-authored-via: Nyxara | ChatGPT Web | GPT-5.6 Sol Pro | Pro Co-authored-by: Nyxara --- .github/workflows/browser-extension.yml | 60 +++++ LICENSES/README.md | 17 +- actions/runtime/LICENSES/README.md | 17 +- actions/runtime/MANIFEST.json | 33 ++- actions/runtime/lib/browser/acquisition.js | 120 ++++++++++ .../runtime/lib/browser/acquisition.js.map | 1 + actions/runtime/lib/browser/app.js | 26 +++ actions/runtime/lib/browser/app.js.map | 1 + actions/runtime/lib/browser/github-change.js | 35 +++ .../runtime/lib/browser/github-change.js.map | 1 + actions/runtime/lib/browser/index.js | 10 + actions/runtime/lib/browser/index.js.map | 1 + actions/runtime/lib/browser/policy.js | 91 ++++++++ actions/runtime/lib/browser/policy.js.map | 1 + actions/runtime/lib/browser/runtime/crypto.js | 87 +++++++ .../runtime/lib/browser/runtime/crypto.js.map | 1 + actions/runtime/lib/browser/runtime/util.js | 5 + .../runtime/lib/browser/runtime/util.js.map | 1 + actions/runtime/lib/browser/text.js | 12 + actions/runtime/lib/browser/text.js.map | 1 + actions/runtime/lib/browser/view.js | 53 +++++ actions/runtime/lib/browser/view.js.map | 1 + apps/browser-extension/ARCHITECTURE.md | 59 +++++ apps/browser-extension/CHANGELOG.md | 13 ++ apps/browser-extension/LICENSES.md | 21 ++ apps/browser-extension/PRIVACY.md | 37 +++ apps/browser-extension/QA.md | 60 +++++ apps/browser-extension/QUALIFICATION.md | 44 ++++ apps/browser-extension/README.md | 79 +++++++ apps/browser-extension/SECURITY.md | 31 +++ .../assets/diffdevil-brand.svg | 15 ++ apps/browser-extension/manifest.json | 61 +++++ apps/browser-extension/options.html | 27 +++ apps/browser-extension/qa/acquisition.mjs | 71 ++++++ apps/browser-extension/qa/dom-entry.ts | 9 + apps/browser-extension/qa/fixtures.mjs | 20 ++ apps/browser-extension/qa/installed.mjs | 81 +++++++ apps/browser-extension/qa/run.mjs | 194 +++++++++++++++ .../src/background/authorization.ts | 18 ++ .../browser-extension/src/background/cache.ts | 60 +++++ .../src/background/preferences.ts | 41 ++++ .../src/background/public-source.ts | 51 ++++ .../src/background/worker.ts | 141 +++++++++++ apps/browser-extension/src/chrome.d.ts | 20 ++ apps/browser-extension/src/content/acquire.ts | 81 +++++++ .../browser-extension/src/content/content.css | 8 + .../src/content/controller.ts | 124 ++++++++++ apps/browser-extension/src/content/github.ts | 78 +++++++ apps/browser-extension/src/content/labels.ts | 32 +++ apps/browser-extension/src/content/main.ts | 3 + apps/browser-extension/src/content/popover.ts | 44 ++++ apps/browser-extension/src/content/render.ts | 75 ++++++ apps/browser-extension/src/options/main.ts | 221 ++++++++++++++++++ .../browser-extension/src/options/options.css | 18 ++ apps/browser-extension/src/options/theme.ts | 3 + .../browser-extension/src/shared/catalogue.ts | 59 +++++ apps/browser-extension/src/shared/dom.ts | 9 + apps/browser-extension/src/shared/errors.ts | 13 ++ apps/browser-extension/src/shared/icons.ts | 20 ++ apps/browser-extension/src/shared/protocol.ts | 25 ++ .../src/shared/settings-key.ts | 3 + apps/browser-extension/src/shared/settings.ts | 91 ++++++++ apps/browser-extension/store/LISTING.md | 39 ++++ .../store/PRIVACY-ANSWERS.md | 37 +++ apps/browser-extension/store/README.md | 11 + apps/browser-extension/store/SUBMISSION.md | 33 +++ .../tests/application.test.mjs | 87 +++++++ apps/browser-extension/tests/support.mjs | 38 +++ apps/browser-extension/tsconfig.json | 30 +++ docs/README.md | 1 + docs/integration/browser-extension.md | 44 ++++ package.json | 21 +- src/diffdevil/browser/acquisition.ts | 94 ++++++++ src/diffdevil/browser/app.ts | 20 ++ src/diffdevil/browser/github-change.ts | 29 +++ src/diffdevil/browser/index.ts | 14 ++ src/diffdevil/browser/policy.ts | 64 +++++ src/diffdevil/browser/runtime/crypto.ts | 69 ++++++ src/diffdevil/browser/runtime/util.ts | 4 + src/diffdevil/browser/text.ts | 8 + src/diffdevil/browser/view.ts | 55 +++++ src/diffdevil/tests/browser.test.mjs | 98 ++++++++ tools/browser-extension-build.mjs | 61 +++++ tools/browser-extension-store.mjs | 69 ++++++ tools/browser-library-build.mjs | 14 ++ tools/build.mjs | 2 + 86 files changed, 3555 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/browser-extension.yml create mode 100644 actions/runtime/lib/browser/acquisition.js create mode 100644 actions/runtime/lib/browser/acquisition.js.map create mode 100644 actions/runtime/lib/browser/app.js create mode 100644 actions/runtime/lib/browser/app.js.map create mode 100644 actions/runtime/lib/browser/github-change.js create mode 100644 actions/runtime/lib/browser/github-change.js.map create mode 100644 actions/runtime/lib/browser/index.js create mode 100644 actions/runtime/lib/browser/index.js.map create mode 100644 actions/runtime/lib/browser/policy.js create mode 100644 actions/runtime/lib/browser/policy.js.map create mode 100644 actions/runtime/lib/browser/runtime/crypto.js create mode 100644 actions/runtime/lib/browser/runtime/crypto.js.map create mode 100644 actions/runtime/lib/browser/runtime/util.js create mode 100644 actions/runtime/lib/browser/runtime/util.js.map create mode 100644 actions/runtime/lib/browser/text.js create mode 100644 actions/runtime/lib/browser/text.js.map create mode 100644 actions/runtime/lib/browser/view.js create mode 100644 actions/runtime/lib/browser/view.js.map create mode 100644 apps/browser-extension/ARCHITECTURE.md create mode 100644 apps/browser-extension/CHANGELOG.md create mode 100644 apps/browser-extension/LICENSES.md create mode 100644 apps/browser-extension/PRIVACY.md create mode 100644 apps/browser-extension/QA.md create mode 100644 apps/browser-extension/QUALIFICATION.md create mode 100644 apps/browser-extension/README.md create mode 100644 apps/browser-extension/SECURITY.md create mode 100644 apps/browser-extension/assets/diffdevil-brand.svg create mode 100644 apps/browser-extension/manifest.json create mode 100644 apps/browser-extension/options.html create mode 100644 apps/browser-extension/qa/acquisition.mjs create mode 100644 apps/browser-extension/qa/dom-entry.ts create mode 100644 apps/browser-extension/qa/fixtures.mjs create mode 100644 apps/browser-extension/qa/installed.mjs create mode 100644 apps/browser-extension/qa/run.mjs create mode 100644 apps/browser-extension/src/background/authorization.ts create mode 100644 apps/browser-extension/src/background/cache.ts create mode 100644 apps/browser-extension/src/background/preferences.ts create mode 100644 apps/browser-extension/src/background/public-source.ts create mode 100644 apps/browser-extension/src/background/worker.ts create mode 100644 apps/browser-extension/src/chrome.d.ts create mode 100644 apps/browser-extension/src/content/acquire.ts create mode 100644 apps/browser-extension/src/content/content.css create mode 100644 apps/browser-extension/src/content/controller.ts create mode 100644 apps/browser-extension/src/content/github.ts create mode 100644 apps/browser-extension/src/content/labels.ts create mode 100644 apps/browser-extension/src/content/main.ts create mode 100644 apps/browser-extension/src/content/popover.ts create mode 100644 apps/browser-extension/src/content/render.ts create mode 100644 apps/browser-extension/src/options/main.ts create mode 100644 apps/browser-extension/src/options/options.css create mode 100644 apps/browser-extension/src/options/theme.ts create mode 100644 apps/browser-extension/src/shared/catalogue.ts create mode 100644 apps/browser-extension/src/shared/dom.ts create mode 100644 apps/browser-extension/src/shared/errors.ts create mode 100644 apps/browser-extension/src/shared/icons.ts create mode 100644 apps/browser-extension/src/shared/protocol.ts create mode 100644 apps/browser-extension/src/shared/settings-key.ts create mode 100644 apps/browser-extension/src/shared/settings.ts create mode 100644 apps/browser-extension/store/LISTING.md create mode 100644 apps/browser-extension/store/PRIVACY-ANSWERS.md create mode 100644 apps/browser-extension/store/README.md create mode 100644 apps/browser-extension/store/SUBMISSION.md create mode 100644 apps/browser-extension/tests/application.test.mjs create mode 100644 apps/browser-extension/tests/support.mjs create mode 100644 apps/browser-extension/tsconfig.json create mode 100644 docs/integration/browser-extension.md create mode 100644 src/diffdevil/browser/acquisition.ts create mode 100644 src/diffdevil/browser/app.ts create mode 100644 src/diffdevil/browser/github-change.ts create mode 100644 src/diffdevil/browser/index.ts create mode 100644 src/diffdevil/browser/policy.ts create mode 100644 src/diffdevil/browser/runtime/crypto.ts create mode 100644 src/diffdevil/browser/runtime/util.ts create mode 100644 src/diffdevil/browser/text.ts create mode 100644 src/diffdevil/browser/view.ts create mode 100644 src/diffdevil/tests/browser.test.mjs create mode 100644 tools/browser-extension-build.mjs create mode 100644 tools/browser-extension-store.mjs create mode 100644 tools/browser-library-build.mjs diff --git a/.github/workflows/browser-extension.yml b/.github/workflows/browser-extension.yml new file mode 100644 index 00000000..a025b6a3 --- /dev/null +++ b/.github/workflows/browser-extension.yml @@ -0,0 +1,60 @@ +name: Browser extension +on: + pull_request: + paths: + - 'apps/browser-extension/**' + - 'src/diffdevil/**' + - 'design/**' + - 'tools/browser-*.mjs' + - 'tools/build.mjs' + - 'package*.json' + - '.github/workflows/browser-extension.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: browser-extension-${{ github.ref }} + cancel-in-progress: true +jobs: + browser: + name: Chromium / Node 22 + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '22' + package-manager-cache: false + - name: Restore locked dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Build portable engine and check extension types + run: npm run build && npm run extension:check + - name: Build font-free unpacked source candidate + run: node tools/browser-extension-build.mjs --without-fonts + - name: Focused browser-core and application checks + run: node --test src/diffdevil/tests/browser.test.mjs apps/browser-extension/tests/*.test.mjs + - name: Install the declared Chromium test runtime + run: node node_modules/playwright-core/cli.js install --with-deps chromium + - name: Rendered controller and source-acquisition fixtures + run: npm run extension:qa + - name: Actual installed MV3 worker and native storage + run: node apps/browser-extension/qa/installed.mjs --require + - name: Produce Store review assets, without publication + run: npm run extension:store + - name: Preserve exact evidence and unpublished artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: diffdevil-browser-extension-${{ github.event.pull_request.head.sha || github.sha }} + path: | + artifacts/browser-extension/unpacked/ + artifacts/browser-extension/build-receipt.json + artifacts/browser-extension/bundle-metafile.json + artifacts/browser-extension/qa/*.json + artifacts/browser-extension/qa/*.png + artifacts/browser-extension/store/ + retention-days: 7 + if-no-files-found: error diff --git a/LICENSES/README.md b/LICENSES/README.md index a31a8984..a8cba5b8 100644 --- a/LICENSES/README.md +++ b/LICENSES/README.md @@ -10,7 +10,7 @@ The portable diff engine, public TypeScript API and npm package, CLI, GitHub Act ## Application and hosted-service software — AGPL-3.0-only -Website and interactive demo application code, hosted runtime, GitHub App backend, hosted configuration, orchestration and management UI, and comparable service-side product code are selected for [GNU AGPL-3.0-only](AGPL-3.0-only.txt). These application/service surfaces may live in this same repository. The application surfaces are `apps/playground/` (local server, Worker adapter, browser code, styles, page assets), `apps/github-app/` (the managed-App runtime), `apps/website/` (the public website, its playground island, catalogue data and derived assets) and `apps/shared/` (presenters both applications use); each explicitly carries `AGPL-3.0-only`. MIT-covered engine code incorporated into an AGPL-covered application keeps its MIT notices. Future application paths must name their licence just as explicitly rather than inheriting it from a neighboring directory. +Website and interactive demo application code, hosted runtime, GitHub App backend, hosted configuration, orchestration and management UI, and comparable service-side product code are selected for [GNU AGPL-3.0-only](AGPL-3.0-only.txt). These application/service surfaces may live in this same repository. The application surfaces are `apps/playground/` (local server, Worker adapter, browser code, styles, page assets), `apps/github-app/` (the managed-App runtime), `apps/website/` (the public website, its playground island, catalogue data and derived assets), `apps/browser-extension/` (Chrome application, settings, browser UI, tests and QA), and `apps/shared/` (presenters both applications use); each explicitly carries `AGPL-3.0-only`. MIT-covered engine code incorporated into an AGPL-covered application keeps its MIT notices. Future application paths must name their licence just as explicitly rather than inheriting it from a neighboring directory. ## Documentation and examples @@ -25,8 +25,7 @@ The npm artifact includes MIT software, the CC BY 4.0 root README and documentat ## Third parties Vendored runtime dependencies in `actions/runtime/node_modules/` retain their own licences and notices, recorded in `actions/runtime/MANIFEST.json`; diffdevil's MIT notice does not replace them. Historical evidence in `docs/reference/` describes its original subject and date, not a newly licensed software component. - -### Website icon material +### Website and extension icon material The website resolves selected icon geometry at build time and embeds it in generated HTML. `@iconify-json/lucide@1.2.135` supplies Lucide UI geometry under the ISC licence, and `@iconify-json/simple-icons@1.2.97` supplies Simple Icons brand geometry under CC0-1.0. The dependency packages are build inputs rather than browser requests, but the embedded geometry keeps its original terms. The built website publishes this component map at `/docs/licences/` so the notices travel with that distribution. @@ -34,6 +33,8 @@ The GitHub, Discord, Bluesky, Google Chrome, and GitHub Actions names and marks The four `diffdevil/*` product glyphs embedded in `apps/website/src/data/icons.ts` are copied unchanged from `Wolfsblvt/wolfsblvt-icons@8596b6bc2ba7c4b950963b7ec26cee1a26974439`. They are original Wolfsblvt Works material under the [MIT licence](MIT.txt); incorporating them into the AGPL-covered website does not replace that MIT notice. `@wolfsblvt/icons` is the intended package route but is not yet published, so this exact source and licence coordinate remains the current vendored boundary. +The browser extension likewise vendors the accepted centre-seam `diffdevil/brand` geometry unchanged at `apps/browser-extension/assets/diffdevil-brand.svg` from the same icon-system commit. Its recorded SHA-256 is `d33cf3b1a7a8ec52706da3311ac82e0500ec6c2eee8e35baeaaa2d5ef2b9b34e`; that SVG remains MIT software inside the AGPL-covered application. Full-colour wordmark and symbol identity assets remain reserved. + #### Lucide ISC notice ```text @@ -47,11 +48,11 @@ copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ``` Some selected Lucide geometry is derived from Feather. The following notice accompanies those icons: diff --git a/actions/runtime/LICENSES/README.md b/actions/runtime/LICENSES/README.md index a31a8984..a8cba5b8 100644 --- a/actions/runtime/LICENSES/README.md +++ b/actions/runtime/LICENSES/README.md @@ -10,7 +10,7 @@ The portable diff engine, public TypeScript API and npm package, CLI, GitHub Act ## Application and hosted-service software — AGPL-3.0-only -Website and interactive demo application code, hosted runtime, GitHub App backend, hosted configuration, orchestration and management UI, and comparable service-side product code are selected for [GNU AGPL-3.0-only](AGPL-3.0-only.txt). These application/service surfaces may live in this same repository. The application surfaces are `apps/playground/` (local server, Worker adapter, browser code, styles, page assets), `apps/github-app/` (the managed-App runtime), `apps/website/` (the public website, its playground island, catalogue data and derived assets) and `apps/shared/` (presenters both applications use); each explicitly carries `AGPL-3.0-only`. MIT-covered engine code incorporated into an AGPL-covered application keeps its MIT notices. Future application paths must name their licence just as explicitly rather than inheriting it from a neighboring directory. +Website and interactive demo application code, hosted runtime, GitHub App backend, hosted configuration, orchestration and management UI, and comparable service-side product code are selected for [GNU AGPL-3.0-only](AGPL-3.0-only.txt). These application/service surfaces may live in this same repository. The application surfaces are `apps/playground/` (local server, Worker adapter, browser code, styles, page assets), `apps/github-app/` (the managed-App runtime), `apps/website/` (the public website, its playground island, catalogue data and derived assets), `apps/browser-extension/` (Chrome application, settings, browser UI, tests and QA), and `apps/shared/` (presenters both applications use); each explicitly carries `AGPL-3.0-only`. MIT-covered engine code incorporated into an AGPL-covered application keeps its MIT notices. Future application paths must name their licence just as explicitly rather than inheriting it from a neighboring directory. ## Documentation and examples @@ -25,8 +25,7 @@ The npm artifact includes MIT software, the CC BY 4.0 root README and documentat ## Third parties Vendored runtime dependencies in `actions/runtime/node_modules/` retain their own licences and notices, recorded in `actions/runtime/MANIFEST.json`; diffdevil's MIT notice does not replace them. Historical evidence in `docs/reference/` describes its original subject and date, not a newly licensed software component. - -### Website icon material +### Website and extension icon material The website resolves selected icon geometry at build time and embeds it in generated HTML. `@iconify-json/lucide@1.2.135` supplies Lucide UI geometry under the ISC licence, and `@iconify-json/simple-icons@1.2.97` supplies Simple Icons brand geometry under CC0-1.0. The dependency packages are build inputs rather than browser requests, but the embedded geometry keeps its original terms. The built website publishes this component map at `/docs/licences/` so the notices travel with that distribution. @@ -34,6 +33,8 @@ The GitHub, Discord, Bluesky, Google Chrome, and GitHub Actions names and marks The four `diffdevil/*` product glyphs embedded in `apps/website/src/data/icons.ts` are copied unchanged from `Wolfsblvt/wolfsblvt-icons@8596b6bc2ba7c4b950963b7ec26cee1a26974439`. They are original Wolfsblvt Works material under the [MIT licence](MIT.txt); incorporating them into the AGPL-covered website does not replace that MIT notice. `@wolfsblvt/icons` is the intended package route but is not yet published, so this exact source and licence coordinate remains the current vendored boundary. +The browser extension likewise vendors the accepted centre-seam `diffdevil/brand` geometry unchanged at `apps/browser-extension/assets/diffdevil-brand.svg` from the same icon-system commit. Its recorded SHA-256 is `d33cf3b1a7a8ec52706da3311ac82e0500ec6c2eee8e35baeaaa2d5ef2b9b34e`; that SVG remains MIT software inside the AGPL-covered application. Full-colour wordmark and symbol identity assets remain reserved. + #### Lucide ISC notice ```text @@ -47,11 +48,11 @@ copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ``` Some selected Lucide geometry is derived from Feather. The following notice accompanies those icons: diff --git a/actions/runtime/MANIFEST.json b/actions/runtime/MANIFEST.json index 403c12f0..38e79fd2 100644 --- a/actions/runtime/MANIFEST.json +++ b/actions/runtime/MANIFEST.json @@ -128,7 +128,7 @@ ], "sources": { "package-lock.json": "1c1b03505826ba0a7ac01dca2e589bf726fde74d5724d7268f3e343c9049799d", - "package.json": "6c55e505cbb271b2e3777615fa451eb598bc36e540e9ff3ec8db5ddb489e85fa", + "package.json": "13217fb5b9aaaa7aaca3653b9fe620c2bfab6eb11a6aecfdddefdbf165919a8a", "src/diffdevil/actions/analyze.ts": "e16cafafd0b0f3a142b33f9cab366fe395d97aec3da43f83eb40449735f0a14f", "src/diffdevil/actions/apply.ts": "40be2ca7e38568d269f612a2cd395575a4d3dac8a599aaacb16012f4cae548d5", "src/diffdevil/actions/inputs.ts": "d4d4a2ede4eb954c74fb540e5c8d92bd93737efb768c2246d81aa6681a5f6f51", @@ -139,6 +139,15 @@ "src/diffdevil/actions/run.ts": "9151b3820a118d7d1409b4d11a9229bc172d81a4716bb97d8cfe8c33b431d736", "src/diffdevil/actions/surface.ts": "67038aa4099b843a3f3119d479b6ce783c9ea723ee515b685c20a78ab6cf74c5", "src/diffdevil/actions/sync-labels.ts": "10a6c4199f640e276606b334038e20755d740c43f9a69e3e2be6261910cf487a", + "src/diffdevil/browser/acquisition.ts": "751ba0091cf06056fb49c80eb8316deaa66775641b22f8cc574472555d6af384", + "src/diffdevil/browser/app.ts": "2e809b2d5ef991a257c1c6c348b571cb4efc26040ba4e2c359fb76750aeea0d5", + "src/diffdevil/browser/github-change.ts": "8180d8b72af2f818d4ac285818387ca2094e137258f8e8561ed9da60041f64d0", + "src/diffdevil/browser/index.ts": "d8dc0bb369352c10488f29c53889a322a9fdaacb4c12b7f02e3b60cc632d1deb", + "src/diffdevil/browser/policy.ts": "a94abe398ce864d02d5aa4ef155a72b5301ef304ba4cd2b4faead5806e7c7cb6", + "src/diffdevil/browser/runtime/crypto.ts": "2b9cf91ba32b39cd42e74257802995e48d74e84298c14f95f9e84d84f8a8f98c", + "src/diffdevil/browser/runtime/util.ts": "c16e0697d98bdb8e1de151cf3f558da410be028ac2ff83e7c6c78d3ac0c88b80", + "src/diffdevil/browser/text.ts": "3b910c44e10eee8bff58b4d50dee195ca81c7e2c298e60f4c45e7346f5a59323", + "src/diffdevil/browser/view.ts": "13891bb1f8f59c0ac7278f3c0cd77ee4a97ce2525729080e696b3d5debd30f69", "src/diffdevil/cli/arguments.ts": "4716575ed32d6f574f3a8081cfaae5c2dd7faca2d5ea8df412e052b4e06ea46a", "src/diffdevil/cli/effects.ts": "53c97343b93c52f7555e6a12e41a1a9295dc6f50b9c598ad2d17fa94495d06d3", "src/diffdevil/cli/main.ts": "99c19d6648f09f70d071a5195469e33521167cb69764ec6d66c1aeae4a83330d", @@ -211,7 +220,7 @@ "src/diffdevil/sources/git.ts": "acfe6763fee61a2194c24cbdca562a6a61241b8f743af0ff5b2de62ffc322b41", "src/diffdevil/sources/patch.ts": "89b44e4c4048095d30de8dc3775654e89ab42857b00ad701fa8950336a72a266", "tools/build-actions.mjs": "6f1392fbdf85a9428679413d57e9f033d295f7b8f9ea74d64adafa3bd2b9f787", - "tools/build.mjs": "512be7fc660ea4d2c89e2a98e627b7068a9038fb299162716e8a75e0694d209c", + "tools/build.mjs": "5f744df87e2cbb2492939cc0a19c0c52c63ca2880efd8e980cc8480cdb0e9c62", "tools/schema-build.mjs": "b8cd59de3cb6eab1f67d23a55a0234a02325e59ce647cc0b05a267dcb90d0f88", "tsconfig.json": "3467925f201122c2aa9df0d750263792dcc629410fb201b99d8e76bc46473f75" }, @@ -223,7 +232,7 @@ "actions/apply/index.mjs": "10c11454aa6ebc9b9c0d98ebd53e02cfdcbf28ed803a817df12219904ee0ee4a", "actions/runtime/LICENSES/AGPL-3.0-only.txt": "0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0", "actions/runtime/LICENSES/MIT.txt": "e4a1d35cf5f6df9e2279fda467a76ee4f972ef4576ba047253425e85924d7876", - "actions/runtime/LICENSES/README.md": "f64afa1540f5d2139e568377517223299ac3a1563b858555f56cacf90b401e9a", + "actions/runtime/LICENSES/README.md": "312e26a2239ee9990026846d7a661ae926ef9596ff7afc284f0aff66841d43b8", "actions/runtime/README.md": "04969695fca45d5559a95871128b419d4af32af9325faacd7d794d8c5c6e341c", "actions/runtime/lib/actions/analyze.js": "4e4b5b4e79a5628040ba478b5f614d6f844cfade24e54fa1a6785f014d311b27", "actions/runtime/lib/actions/analyze.js.map": "9dd3ae50a3eeab35b5c6b79e4d6479d7d5030186a090eaf915c5f7551723bf13", @@ -245,6 +254,24 @@ "actions/runtime/lib/actions/surface.js.map": "a5c43f83d5e72fadfe9fe709197e2dbb6bf0bd4c1f9e4b316882bb605ab5496f", "actions/runtime/lib/actions/sync-labels.js": "fc0342d3b9bfc35dceae72b6faf09e99eb46ab32580dcd679f5fe5b717137f07", "actions/runtime/lib/actions/sync-labels.js.map": "ecd2fc20144a233d3b20e2b81dae07fd0685f5f48958db612cd0236ea0673e67", + "actions/runtime/lib/browser/acquisition.js": "1c4cadbf51a703e4e92498ace7078b197f68b87778df44531d38db22bf89b5c6", + "actions/runtime/lib/browser/acquisition.js.map": "7fdccd6e5f6469e25eb7e2cf31f2538d94b05d7405b6d6cabdaaf8f111f2b3f8", + "actions/runtime/lib/browser/app.js": "875d9143d8d7446a93e13f0f6f9d54cbc160214017d71cef255c9f03daa58010", + "actions/runtime/lib/browser/app.js.map": "6550b95678cbc71f83741b77c0c3c877aa911f50e555b2b6f4956ea824e82e09", + "actions/runtime/lib/browser/github-change.js": "1929da57882141144732eb7b6c6b1ece3ccf8ca2d39b49eeb28e87b7d52f001b", + "actions/runtime/lib/browser/github-change.js.map": "00dcc37ecddf7f8bfe665021bfce338359b9d0271650f4ad7a33be14331ae223", + "actions/runtime/lib/browser/index.js": "b955c8f4c915020d815694ac9d67a0cbcb535a858e5fffab8d3356041bd883a3", + "actions/runtime/lib/browser/index.js.map": "ed80273b0f5b57efb90c19528a122e15619399c1dc60df3e1fbe5a53ce7acbfe", + "actions/runtime/lib/browser/policy.js": "bb43226ac87b831e2f672c12e9b09d8fc68b00ba09e787ff8fe5dccc79d75130", + "actions/runtime/lib/browser/policy.js.map": "c063cf819c99a697eaca8d038eae4e5b64b4a9a1b4e66328aa0e4d7b34d854bc", + "actions/runtime/lib/browser/runtime/crypto.js": "e1b014ecc2a58e27b76e08dc351091ccb63298cfdc22d9265db3797365a94dc2", + "actions/runtime/lib/browser/runtime/crypto.js.map": "79d921b2a4eaff222a3488aa23f12d5a844a29cb06125233553b0915a9faf8b1", + "actions/runtime/lib/browser/runtime/util.js": "d59bd45103161a6519ea2f6b562455781751d20c48fea29e8f8dc34e0ef50277", + "actions/runtime/lib/browser/runtime/util.js.map": "29883061676baf7ed387debbefd07ff0838069288acbd51cdbd7b75fba4cfa1f", + "actions/runtime/lib/browser/text.js": "382487ee7aece4e23068e37fda0ed9ba1b1fbe9fd2a44dc65983ef0a4e4d7f1b", + "actions/runtime/lib/browser/text.js.map": "a0471b9d8ca4490b667c9ba217871dfcd3c93edd53e932584b3698d77b26f974", + "actions/runtime/lib/browser/view.js": "1e0030a78ea44adcd04e0ad3ebdfd121c62a6eba2ccc5b24a1c6de78a6a41ed9", + "actions/runtime/lib/browser/view.js.map": "135f80664dcee475335f2e81e1270aa4f9edf74a3a3275549d78eb97a3222de7", "actions/runtime/lib/cli/arguments.js": "fcf380886fcea63c945238c8d101376fa7d0f1036c882e9dccd79c005f7f17da", "actions/runtime/lib/cli/arguments.js.map": "5885d6b9cfaf770aaf0ea4f586a926ccdb5b2ca14eb294c8cf3bc4ae4d7b6f62", "actions/runtime/lib/cli/effects.js": "86d3dc2491c1ffc71f954c61d7af1012f5645f132bde8f5f1f6c81d0cdd98413", diff --git a/actions/runtime/lib/browser/acquisition.js b/actions/runtime/lib/browser/acquisition.js new file mode 100644 index 00000000..d9056a53 --- /dev/null +++ b/actions/runtime/lib/browser/acquisition.js @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +import { capture, fail, unwrap } from '../errors.js'; +import { contentId } from '../inert.js'; +import { analyzeChanges } from '../report.js'; +import { parseUnifiedDiff } from '../sources/patch.js'; +import { changeFromGitHubFile } from './github-change.js'; +import { githubInteger, githubRecord, githubSha, githubString, repositoryPath } from '../github/values.js'; +export const BROWSER_ADAPTER = 'diffdevil.browser/1'; +export const MAX_ACQUISITION_BYTES = 24 * 1024 * 1024; +export function readComparison(input) { + const value = githubRecord(input, 'Browser comparison'); + if (value.host !== 'github.com') + fail('E_BROWSER_HOST', 'The browser adapter supports github.com only.', 'source'); + const repository = githubString(value.repository, 'repository'); + repositoryPath(repository); + if (repository.split('/').some(part => part === '.' || part === '..')) + fail('E_BROWSER_IDENTITY', 'Invalid repository identity.', 'source'); + return { host: 'github.com', repository, pullRequest: githubInteger(value.pullRequest, 'pull request', 1), + base: githubSha(value.base, 'base SHA'), head: githubSha(value.head, 'head SHA'), + ...(value.changedFiles === undefined ? {} : { changedFiles: githubInteger(value.changedFiles, 'file count') }), + ...(value.additions === undefined ? {} : { additions: githubInteger(value.additions, 'additions') }), + ...(value.deletions === undefined ? {} : { deletions: githubInteger(value.deletions, 'deletions') }) }; +} +export function comparisonKey(input) { + const c = readComparison(input); + return `${c.host}/${c.repository.toLowerCase()}#${c.pullRequest}@${c.base}...${c.head}`; +} +/** A damaged block supplies no proof. Independently complete blocks still matter. */ +function recovered(text) { + const result = []; + const paths = new Set(); + for (const block of text.split(/(?=^diff --git )/mu)) { + if (!block.startsWith('diff --git ')) + continue; + let parsed; + try { + parsed = parseUnifiedDiff(block); + } + catch { + continue; + } + if (parsed.length !== 1) + continue; + const file = parsed[0]; + if (paths.has(file.path)) + fail('E_BROWSER_DUPLICATE', 'Duplicate file identity in the acquired diff.', 'source'); + paths.add(file.path); + result.push(file); + } + return result; +} +function unknownFile(file, reason) { + return { path: file.path, ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }), + kind: file.kind, changeType: file.changeType, incompleteReason: reason }; +} +/** Serialized provider evidence is the public boundary; no alternate diff arithmetic. */ +export function analyzeBrowserInput(json) { + return capture(() => { + if (typeof json !== 'string' || new TextEncoder().encode(json).byteLength > MAX_ACQUISITION_BYTES) + fail('E_BROWSER_LIMIT', 'Browser acquisition exceeds 24 MiB.', 'source'); + const input = githubRecord(JSON.parse(json), 'Browser acquisition'); + const comparison = readComparison(input.comparison); + if (typeof input.complete !== 'boolean') + fail('E_BROWSER_EVIDENCE', 'Transport completeness must be explicit.', 'source'); + let complete = input.complete; + let changes; + if (input.format === 'github-files') { + if (!Array.isArray(input.files)) + fail('E_BROWSER_INPUT', 'Expected the provider file array.', 'source'); + changes = input.files.map(changeFromGitHubFile); + if (typeof input.diff === 'string') { + const proofs = new Map(recovered(input.diff).map(file => [file.path, file])); + changes = changes.map(file => { + const proof = proofs.get(file.path); + if (!proof || proof.oldPath !== file.oldPath || proof.changeType !== file.changeType) + return file; + if (proof.kind === 'binary' || proof.kind === 'submodule') + return { ...file, kind: proof.kind }; + return proof.patch && file.additions === proof.additions && file.deletions === proof.deletions ? { ...file, kind: 'text', patch: proof.patch } : file; + }); + } + } + else if (input.format === 'diff') { + if (typeof input.text !== 'string') + fail('E_BROWSER_INPUT', 'Expected unified-diff text.', 'source'); + if (input.text.trim() && !input.text.startsWith('diff --git ') && !input.text.startsWith('--- ')) + fail('E_BROWSER_INPUT', 'The response is not a unified diff.', 'source'); + try { + changes = [...parseUnifiedDiff(input.text)]; + } + catch { + changes = recovered(input.text); + complete = false; + } + } + else + return fail('E_BROWSER_INPUT', 'Unsupported browser acquisition format.', 'source'); + if (new Set(changes.map(file => file.path)).size !== changes.length) + fail('E_BROWSER_DUPLICATE', 'Duplicate file identity.', 'source'); + if (comparison.changedFiles !== undefined && changes.length > comparison.changedFiles) + fail('E_BROWSER_EVIDENCE', 'Acquired files exceed the observed PR total.', 'source'); + const all = comparison.changedFiles !== undefined && changes.length === comparison.changedFiles; + if (!complete && input.format === 'github-files' && all && changes.length) + fail('E_BROWSER_EVIDENCE', 'Pagination contradicts the observed complete file count.', 'source'); + if (input.complete === false && input.format === 'diff' && changes.length) + changes[changes.length - 1] = unknownFile(changes.at(-1), 'BROWSER_TRUNCATED_DIFF'); + if (all) { + const added = changes.reduce((n, file) => n + (file.additions ?? file.patch?.additions ?? 0), 0); + const deleted = changes.reduce((n, file) => n + (file.deletions ?? file.patch?.deletions ?? 0), 0); + if (comparison.additions !== undefined && comparison.additions !== added || comparison.deletions !== undefined && comparison.deletions !== deleted) + changes = changes.map(file => unknownFile(file, 'BROWSER_COUNTER_MISMATCH')); + } + const fileSet = all ? { complete: true, total: { status: 'exact', value: changes.length } } + : { complete: false, total: comparison.changedFiles === undefined ? { status: 'unknown', lower: changes.length, reasons: [{ code: 'BROWSER_FILE_SET_UNPROVEN' }] } : { status: 'exact', value: comparison.changedFiles } }; + return unwrap(analyzeChanges(changes, { fileSet, source: { kind: input.format === 'diff' ? 'unified-diff' : 'github-api', comparison: 'three-dot', + repository: comparison.repository, pullRequest: comparison.pullRequest, base: comparison.base, head: comparison.head, + comparisonId: contentId('comparison', [BROWSER_ADAPTER, comparisonKey(comparison)]) } })); + }); +} +//# sourceMappingURL=acquisition.js.map \ No newline at end of file diff --git a/actions/runtime/lib/browser/acquisition.js.map b/actions/runtime/lib/browser/acquisition.js.map new file mode 100644 index 00000000..50d8232d --- /dev/null +++ b/actions/runtime/lib/browser/acquisition.js.map @@ -0,0 +1 @@ +{"version":3,"file":"acquisition.js","sourceRoot":"","sources":["../../../src/diffdevil/browser/acquisition.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: MIT\nimport { capture, fail, unwrap } from '../errors.js';\nimport { contentId } from '../inert.js';\nimport { analyzeChanges } from '../report.js';\nimport { parseUnifiedDiff, type ChangeInput } from '../sources/patch.js';\nimport { changeFromGitHubFile } from './github-change.js';\nimport { githubInteger, githubRecord, githubSha, githubString, repositoryPath } from '../github/values.js';\nimport type { FileSet, Report, Result } from '../model.js';\n\nexport const BROWSER_ADAPTER = 'diffdevil.browser/1';\nexport const MAX_ACQUISITION_BYTES = 24 * 1024 * 1024;\nexport interface BrowserComparison {\n readonly host: 'github.com'; readonly repository: string; readonly pullRequest: number;\n readonly base: string; readonly head: string; readonly changedFiles?: number;\n readonly additions?: number; readonly deletions?: number;\n}\nexport type BrowserInput = { readonly comparison: BrowserComparison; readonly complete: boolean } & (\n { readonly format: 'diff'; readonly text: string } |\n { readonly format: 'github-files'; readonly files: readonly unknown[]; readonly diff?: string }\n);\nexport function readComparison(input: unknown): BrowserComparison {\n const value = githubRecord(input, 'Browser comparison');\n if (value.host !== 'github.com') fail('E_BROWSER_HOST', 'The browser adapter supports github.com only.', 'source');\n const repository = githubString(value.repository, 'repository'); repositoryPath(repository);\n if (repository.split('/').some(part => part === '.' || part === '..')) fail('E_BROWSER_IDENTITY', 'Invalid repository identity.', 'source');\n return { host: 'github.com', repository, pullRequest: githubInteger(value.pullRequest, 'pull request', 1),\n base: githubSha(value.base, 'base SHA'), head: githubSha(value.head, 'head SHA'),\n ...(value.changedFiles === undefined ? {} : { changedFiles: githubInteger(value.changedFiles, 'file count') }),\n ...(value.additions === undefined ? {} : { additions: githubInteger(value.additions, 'additions') }),\n ...(value.deletions === undefined ? {} : { deletions: githubInteger(value.deletions, 'deletions') }) };\n}\nexport function comparisonKey(input: BrowserComparison): string {\n const c = readComparison(input); return `${c.host}/${c.repository.toLowerCase()}#${c.pullRequest}@${c.base}...${c.head}`;\n}\n/** A damaged block supplies no proof. Independently complete blocks still matter. */\nfunction recovered(text: string): ChangeInput[] {\n const result: ChangeInput[] = []; const paths = new Set();\n for (const block of text.split(/(?=^diff --git )/mu)) {\n if (!block.startsWith('diff --git ')) continue;\n let parsed: readonly ChangeInput[];\n try { parsed = parseUnifiedDiff(block); } catch { continue; }\n if (parsed.length !== 1) continue;\n const file = parsed[0]!;\n if (paths.has(file.path)) fail('E_BROWSER_DUPLICATE', 'Duplicate file identity in the acquired diff.', 'source');\n paths.add(file.path); result.push(file);\n }\n return result;\n}\nfunction unknownFile(file: ChangeInput, reason: string): ChangeInput {\n return { path: file.path, ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),\n kind: file.kind, changeType: file.changeType, incompleteReason: reason };\n}\n/** Serialized provider evidence is the public boundary; no alternate diff arithmetic. */\nexport function analyzeBrowserInput(json: string): Result {\n return capture(() => {\n if (typeof json !== 'string' || new TextEncoder().encode(json).byteLength > MAX_ACQUISITION_BYTES) fail('E_BROWSER_LIMIT', 'Browser acquisition exceeds 24 MiB.', 'source');\n const input = githubRecord(JSON.parse(json), 'Browser acquisition'); const comparison = readComparison(input.comparison);\n if (typeof input.complete !== 'boolean') fail('E_BROWSER_EVIDENCE', 'Transport completeness must be explicit.', 'source');\n let complete = input.complete; let changes: ChangeInput[];\n if (input.format === 'github-files') {\n if (!Array.isArray(input.files)) fail('E_BROWSER_INPUT', 'Expected the provider file array.', 'source');\n changes = input.files.map(changeFromGitHubFile);\n if (typeof input.diff === 'string') {\n const proofs = new Map(recovered(input.diff).map(file => [file.path, file]));\n changes = changes.map(file => {\n const proof = proofs.get(file.path);\n if (!proof || proof.oldPath !== file.oldPath || proof.changeType !== file.changeType) return file;\n if (proof.kind === 'binary' || proof.kind === 'submodule') return { ...file, kind: proof.kind };\n return proof.patch && file.additions === proof.additions && file.deletions === proof.deletions ? { ...file, kind: 'text', patch: proof.patch } : file;\n });\n }\n } else if (input.format === 'diff') {\n if (typeof input.text !== 'string') fail('E_BROWSER_INPUT', 'Expected unified-diff text.', 'source');\n if (input.text.trim() && !input.text.startsWith('diff --git ') && !input.text.startsWith('--- ')) fail('E_BROWSER_INPUT', 'The response is not a unified diff.', 'source');\n try { changes = [...parseUnifiedDiff(input.text)]; }\n catch { changes = recovered(input.text); complete = false; }\n } else return fail('E_BROWSER_INPUT', 'Unsupported browser acquisition format.', 'source');\n if (new Set(changes.map(file => file.path)).size !== changes.length) fail('E_BROWSER_DUPLICATE', 'Duplicate file identity.', 'source');\n if (comparison.changedFiles !== undefined && changes.length > comparison.changedFiles) fail('E_BROWSER_EVIDENCE', 'Acquired files exceed the observed PR total.', 'source');\n const all = comparison.changedFiles !== undefined && changes.length === comparison.changedFiles;\n if (!complete && input.format === 'github-files' && all && changes.length) fail('E_BROWSER_EVIDENCE', 'Pagination contradicts the observed complete file count.', 'source');\n if (input.complete === false && input.format === 'diff' && changes.length) changes[changes.length - 1] = unknownFile(changes.at(-1)!, 'BROWSER_TRUNCATED_DIFF');\n if (all) {\n const added = changes.reduce((n, file) => n + (file.additions ?? file.patch?.additions ?? 0), 0);\n const deleted = changes.reduce((n, file) => n + (file.deletions ?? file.patch?.deletions ?? 0), 0);\n if (comparison.additions !== undefined && comparison.additions !== added || comparison.deletions !== undefined && comparison.deletions !== deleted) changes = changes.map(file => unknownFile(file, 'BROWSER_COUNTER_MISMATCH'));\n }\n const fileSet: FileSet = all ? { complete: true, total: { status: 'exact', value: changes.length } }\n : { complete: false, total: comparison.changedFiles === undefined ? { status: 'unknown', lower: changes.length, reasons: [{ code: 'BROWSER_FILE_SET_UNPROVEN' }] } : { status: 'exact', value: comparison.changedFiles } };\n return unwrap(analyzeChanges(changes, { fileSet, source: { kind: input.format === 'diff' ? 'unified-diff' : 'github-api', comparison: 'three-dot',\n repository: comparison.repository, pullRequest: comparison.pullRequest, base: comparison.base, head: comparison.head,\n comparisonId: contentId('comparison', [BROWSER_ADAPTER, comparisonKey(comparison)]) } }));\n });\n}\n"]} \ No newline at end of file diff --git a/actions/runtime/lib/browser/app.js b/actions/runtime/lib/browser/app.js new file mode 100644 index 00000000..e3c6f96f --- /dev/null +++ b/actions/runtime/lib/browser/app.js @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +import { comparisonKey, readComparison } from './acquisition.js'; +/** Agreement is not approval. Call only for an authenticated immutable report delivery. */ +export function appStanding(local, app) { + if (!app) + return 'local'; + if (app.kind !== 'diffdevil.browser-report' || app.version !== 1) + return 'incompatible'; + try { + const a = readComparison(local.comparison); + const b = readComparison(app.comparison); + if (a.repository.toLowerCase() !== b.repository.toLowerCase() || a.pullRequest !== b.pullRequest) + return 'incompatible'; + if (comparisonKey(a) !== comparisonKey(b)) + return 'stale'; + if (['engine', 'schema', 'measurement', 'presenter'].some(key => local[key] !== app[key])) + return 'incompatible'; + if (local.policyDigest !== app.policyDigest) + return 'policy-mismatch'; + return local.reportId === app.reportId ? 'matching' : 'incompatible'; + } + catch { + return 'incompatible'; + } +} +//# sourceMappingURL=app.js.map \ No newline at end of file diff --git a/actions/runtime/lib/browser/app.js.map b/actions/runtime/lib/browser/app.js.map new file mode 100644 index 00000000..641fc944 --- /dev/null +++ b/actions/runtime/lib/browser/app.js.map @@ -0,0 +1 @@ +{"version":3,"file":"app.js","sourceRoot":"","sources":["../../../src/diffdevil/browser/app.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,OAAO,EAAE,aAAa,EAAE,cAAc,EAA0B,MAAM,kBAAkB,CAAC;AAMzF,2FAA2F;AAC3F,MAAM,UAAU,WAAW,CAAC,KAAwB,EAAE,GAAuB;IAC3E,IAAI,CAAC,GAAG;QAAE,OAAO,OAAO,CAAC;IACzB,IAAI,GAAG,CAAC,IAAI,KAAK,0BAA0B,IAAI,GAAG,CAAC,OAAO,KAAK,CAAC;QAAE,OAAO,cAAc,CAAC;IACxF,IAAI,CAAC;QACH,MAAM,CAAC,GAAG,cAAc,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;QAAC,MAAM,CAAC,GAAG,cAAc,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;QACrF,IAAI,CAAC,CAAC,UAAU,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC,UAAU,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC,WAAW,KAAK,CAAC,CAAC,WAAW;YAAE,OAAO,cAAc,CAAC;QACxH,IAAI,aAAa,CAAC,CAAC,CAAC,KAAK,aAAa,CAAC,CAAC,CAAC;YAAE,OAAO,OAAO,CAAC;QAC1D,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,aAAa,EAAE,WAAW,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,KAAK,CAAC,GAA8B,CAAC,KAAK,GAAG,CAAC,GAA8B,CAAC,CAAC;YAAE,OAAO,cAAc,CAAC;QACvK,IAAI,KAAK,CAAC,YAAY,KAAK,GAAG,CAAC,YAAY;YAAE,OAAO,iBAAiB,CAAC;QACtE,OAAO,KAAK,CAAC,QAAQ,KAAK,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,cAAc,CAAC;IACvE,CAAC;IAAC,MAAM,CAAC;QAAC,OAAO,cAAc,CAAC;IAAC,CAAC;AACpC,CAAC","sourcesContent":["// SPDX-License-Identifier: MIT\nimport { comparisonKey, readComparison, type BrowserComparison } from './acquisition.js';\nexport interface AppReportIdentity {\n readonly kind: 'diffdevil.browser-report'; readonly version: 1; readonly comparison: BrowserComparison;\n readonly reportId: string; readonly policyDigest: string; readonly engine: string; readonly schema: string; readonly measurement: string; readonly presenter: string;\n}\nexport type AppStanding = 'local' | 'matching' | 'policy-mismatch' | 'stale' | 'incompatible';\n/** Agreement is not approval. Call only for an authenticated immutable report delivery. */\nexport function appStanding(local: AppReportIdentity, app?: AppReportIdentity): AppStanding {\n if (!app) return 'local';\n if (app.kind !== 'diffdevil.browser-report' || app.version !== 1) return 'incompatible';\n try {\n const a = readComparison(local.comparison); const b = readComparison(app.comparison);\n if (a.repository.toLowerCase() !== b.repository.toLowerCase() || a.pullRequest !== b.pullRequest) return 'incompatible';\n if (comparisonKey(a) !== comparisonKey(b)) return 'stale';\n if (['engine', 'schema', 'measurement', 'presenter'].some(key => local[key as keyof AppReportIdentity] !== app[key as keyof AppReportIdentity])) return 'incompatible';\n if (local.policyDigest !== app.policyDigest) return 'policy-mismatch';\n return local.reportId === app.reportId ? 'matching' : 'incompatible';\n } catch { return 'incompatible'; }\n}\n"]} \ No newline at end of file diff --git a/actions/runtime/lib/browser/github-change.js b/actions/runtime/lib/browser/github-change.js new file mode 100644 index 00000000..7fa1ded0 --- /dev/null +++ b/actions/runtime/lib/browser/github-change.js @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +/** Pure provider normalization, shared by Node and the browser without network I/O. */ +import { fail } from '../errors.js'; +import { parsePatch } from '../sources/patch.js'; +import { validatePath } from '../paths.js'; +import { githubInteger, githubRecord, githubString } from '../github/values.js'; +export function changeFromGitHubFile(input) { + const file = githubRecord(input, 'changed file'); + const path = validatePath(githubString(file.filename, 'filename')); + const statuses = { added: 'added', removed: 'deleted', modified: 'modified', renamed: 'renamed', copied: 'copied', changed: 'type-changed', unchanged: 'modified' }; + const status = githubString(file.status, 'file status'), changeType = statuses[status]; + if (!changeType) + fail('E_GITHUB_RESPONSE', `Unsupported GitHub file status: ${status}.`, 'source'); + const additions = githubInteger(file.additions, 'raw additions'), deletions = githubInteger(file.deletions, 'raw deletions'); + if (!Number.isSafeInteger(additions + deletions) || file.changes !== undefined && file.changes !== additions + deletions) + fail('E_GITHUB_RESPONSE', 'GitHub raw file counters disagree.', 'source'); + const oldPath = changeType === 'renamed' || changeType === 'copied' ? validatePath(githubString(file.previous_filename, 'previous filename')) : undefined; + const common = { path, changeType, additions, deletions, ...(oldPath === undefined ? {} : { oldPath }) }; + if (file.patch !== undefined && typeof file.patch !== 'string') + fail('E_GITHUB_RESPONSE', 'GitHub patch must be text when present.', 'source'); + // Submodule fragments have no mode header; obtain the raw diff before treating them as text. + const ambiguousGitlink = typeof file.patch === 'string' && /^[+-]Subproject commit [0-9a-f]+(?:-dirty)?$/mu.test(file.patch); + if (typeof file.patch === 'string' && file.patch !== '' && !ambiguousGitlink) { + try { + const patch = parsePatch(file.patch); + if (patch.additions === additions && patch.deletions === deletions) + return { ...common, kind: 'text', patch }; + } + catch { /* Raw source counters still bound a text patch whose edit blocks are incomplete. */ } + } + if (ambiguousGitlink || additions + deletions === 0) + return { ...common, kind: 'unknown', incompleteReason: 'MATERIAL_KIND_UNKNOWN' }; + return { ...common, kind: 'text', incompleteReason: file.patch === undefined ? 'PATCH_OMITTED' : 'PATCH_INCOMPLETE' }; +} +//# sourceMappingURL=github-change.js.map \ No newline at end of file diff --git a/actions/runtime/lib/browser/github-change.js.map b/actions/runtime/lib/browser/github-change.js.map new file mode 100644 index 00000000..aedc9522 --- /dev/null +++ b/actions/runtime/lib/browser/github-change.js.map @@ -0,0 +1 @@ +{"version":3,"file":"github-change.js","sourceRoot":"","sources":["../../../src/diffdevil/browser/github-change.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: MIT\n/** Pure provider normalization, shared by Node and the browser without network I/O. */\nimport { fail } from '../errors.js';\nimport { parsePatch, type ChangeInput } from '../sources/patch.js';\nimport { validatePath } from '../paths.js';\nimport { githubInteger, githubRecord, githubString } from '../github/values.js';\n\nexport function changeFromGitHubFile(input: unknown): ChangeInput {\n const file = githubRecord(input, 'changed file');\n const path = validatePath(githubString(file.filename, 'filename'));\n const statuses: Record = { added: 'added', removed: 'deleted', modified: 'modified', renamed: 'renamed', copied: 'copied', changed: 'type-changed', unchanged: 'modified' };\n const status = githubString(file.status, 'file status'), changeType = statuses[status];\n if (!changeType) fail('E_GITHUB_RESPONSE', `Unsupported GitHub file status: ${status}.`, 'source');\n const additions = githubInteger(file.additions, 'raw additions'), deletions = githubInteger(file.deletions, 'raw deletions');\n if (!Number.isSafeInteger(additions + deletions) || file.changes !== undefined && file.changes !== additions + deletions) fail('E_GITHUB_RESPONSE', 'GitHub raw file counters disagree.', 'source');\n const oldPath = changeType === 'renamed' || changeType === 'copied' ? validatePath(githubString(file.previous_filename, 'previous filename')) : undefined;\n const common = { path, changeType, additions, deletions, ...(oldPath === undefined ? {} : { oldPath }) };\n if (file.patch !== undefined && typeof file.patch !== 'string') fail('E_GITHUB_RESPONSE', 'GitHub patch must be text when present.', 'source');\n // Submodule fragments have no mode header; obtain the raw diff before treating them as text.\n const ambiguousGitlink = typeof file.patch === 'string' && /^[+-]Subproject commit [0-9a-f]+(?:-dirty)?$/mu.test(file.patch);\n if (typeof file.patch === 'string' && file.patch !== '' && !ambiguousGitlink) {\n try {\n const patch = parsePatch(file.patch);\n if (patch.additions === additions && patch.deletions === deletions) return { ...common, kind: 'text', patch };\n } catch { /* Raw source counters still bound a text patch whose edit blocks are incomplete. */ }\n }\n if (ambiguousGitlink || additions + deletions === 0) return { ...common, kind: 'unknown', incompleteReason: 'MATERIAL_KIND_UNKNOWN' };\n return { ...common, kind: 'text', incompleteReason: file.patch === undefined ? 'PATCH_OMITTED' : 'PATCH_INCOMPLETE' };\n}\n"]} \ No newline at end of file diff --git a/actions/runtime/lib/browser/index.js b/actions/runtime/lib/browser/index.js new file mode 100644 index 00000000..632189a4 --- /dev/null +++ b/actions/runtime/lib/browser/index.js @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +/** Self-contained browser bundle; executable dependencies and validators ship locally. */ +export { analyzeBrowserInput, readComparison, comparisonKey, BROWSER_ADAPTER, MAX_ACQUISITION_BYTES } from './acquisition.js'; +export { compileBrowserPolicy, readPolicyText, requiredTemplates } from './policy.js'; +export { humanReport, HUMAN_VIEW_VERSION } from './view.js'; +export { appStanding } from './app.js'; +export { SEMANTICS } from '../model.js'; +export { stringify as stringifyPolicy } from 'yaml'; +export { measurementText, evidenceText } from './text.js'; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/actions/runtime/lib/browser/index.js.map b/actions/runtime/lib/browser/index.js.map new file mode 100644 index 00000000..799caebe --- /dev/null +++ b/actions/runtime/lib/browser/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/diffdevil/browser/index.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,0FAA0F;AAC1F,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,aAAa,EAAE,eAAe,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAE9H,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAEtF,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAE5D,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAEvC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,OAAO,EAAE,SAAS,IAAI,eAAe,EAAE,MAAM,MAAM,CAAC;AACpD,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC","sourcesContent":["// SPDX-License-Identifier: MIT\n/** Self-contained browser bundle; executable dependencies and validators ship locally. */\nexport { analyzeBrowserInput, readComparison, comparisonKey, BROWSER_ADAPTER, MAX_ACQUISITION_BYTES } from './acquisition.js';\nexport type { BrowserComparison, BrowserInput } from './acquisition.js';\nexport { compileBrowserPolicy, readPolicyText, requiredTemplates } from './policy.js';\nexport type { BrowserPolicy, PolicyLayers, PolicyMode } from './policy.js';\nexport { humanReport, HUMAN_VIEW_VERSION } from './view.js';\nexport type { HumanReportView, Rail, RailCell } from './view.js';\nexport { appStanding } from './app.js';\nexport type { AppReportIdentity, AppStanding } from './app.js';\nexport { SEMANTICS } from '../model.js';\nexport type { Report, NumericMeasurement, Result, Diagnostic } from '../model.js';\nexport { stringify as stringifyPolicy } from 'yaml';\nexport { measurementText, evidenceText } from './text.js';\n"]} \ No newline at end of file diff --git a/actions/runtime/lib/browser/policy.js b/actions/runtime/lib/browser/policy.js new file mode 100644 index 00000000..6c933549 --- /dev/null +++ b/actions/runtime/lib/browser/policy.js @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +import { capture, fail, unwrap } from '../errors.js'; +import { contentId, deepFreeze } from '../inert.js'; +import { compilePolicy } from '../policy/compile.js'; +import { normalizePolicy } from '../policy/normalize.js'; +import { readPolicyYaml } from '../policy/yaml.js'; +import { readPolicyDocument } from '../policy/validation.js'; +export function readPolicyText(text, name = 'personal.yml') { + return capture(() => readPolicyDocument(unwrap(readPolicyYaml(text, { name })).document)); +} +const parts = (path) => path.slice(1).split('/').map(key => key.replaceAll('~1', '/').replaceAll('~0', '~')); +function lookup(value, path) { return parts(path).reduce((parent, key) => parent && typeof parent === 'object' ? parent[key] : undefined, value); } +function assign(root, path, value) { + const keys = parts(path); + const leaf = keys.pop(); + let parent = root; + for (const key of keys) { + if (!parent[key] || typeof parent[key] !== 'object') + parent[key] = Object.create(null); + parent = parent[key]; + } + if (value === undefined) + delete parent[leaf]; + else + parent[leaf] = value; +} +/** Compose normalized declarations; never let implicit upper presets reset personal policy. */ +export function compileBrowserPolicy(json) { + return capture(() => { + if (typeof json !== 'string' || new TextEncoder().encode(json).byteLength > 3 * 1024 * 1024) + fail('E_BROWSER_POLICY_LIMIT', 'Policy input exceeds 3 MiB.', 'config'); + const input = JSON.parse(json); + if (!input || !['composed', 'repository', 'personal-only'].includes(input.mode)) + fail('E_CONFIG', 'Unknown browser policy mode.', 'config'); + const selected = []; + const repository = input.mode !== 'personal-only' && input.repository !== undefined; + if (input.mode !== 'repository' || !repository) + selected.push({ name: 'personal', text: input.personal }); + if (repository) + selected.push({ name: 'repository', text: input.repository }); + if (input.mode !== 'repository' && input.override !== undefined) + selected.push({ name: 'repository-personal-override', text: input.override }); + const document = { version: 1, presets: [] }; + const origins = new Map(); + let program; + for (const [index, layer] of selected.entries()) { + const authored = unwrap(readPolicyText(layer.text, layer.name)); + const normalized = unwrap(normalizePolicy(authored)); + const lastOrigins = new Map(normalized.origins.map(origin => [origin.path, origin])); + if (authored.presets !== undefined) + for (const [path, origin] of origins) + if (origin.layer.includes(':preset:')) { + assign(document, path, undefined); + origins.delete(path); + } + for (const [path, origin] of lastOrigins) { + const preset = origin.layer.startsWith('preset:'); + if (preset && index > 0 && authored.presets === undefined) + continue; + const previous = origins.get(path)?.layer; + assign(document, path, lookup(normalized.document, path)); + origins.set(path, { path, layer: preset ? `${layer.name}:${origin.layer}` : layer.name, ...(previous === undefined ? {} : { replaces: previous }) }); + } + program = unwrap(compilePolicy(document, { ...(input.templateFiles ? { templateFiles: input.templateFiles } : {}) })); + } + if (!program) + program = unwrap(compilePolicy(document)); + const effectiveOrigins = [...origins.values()]; + return deepFreeze({ program, document: readPolicyDocument(document), origins: effectiveOrigins, mode: input.mode, + digest: contentId('browser-policy', [program.id, input.mode, effectiveOrigins]), layers: selected.map(layer => layer.name) }); + }); +} +/** Host-provided, trusted-base files only. This discovery function does no I/O. */ +export function requiredTemplates(json) { + return capture(() => { + const input = JSON.parse(json); + const result = new Set(); + const texts = input.mode === 'repository' && input.repository !== undefined ? [input.repository] : [input.personal, ...(input.mode === 'personal-only' ? [] : [input.repository]), ...(input.mode === 'repository' ? [] : [input.override])]; + for (const text of texts) + if (text !== undefined) { + const document = unwrap(readPolicyText(text)); + for (const rule of Object.values(document.rules ?? {})) { + const path = rule.effects?.comment?.templateFile; + if (path) + result.add(path); + } + } + return [...result]; + }); +} +//# sourceMappingURL=policy.js.map \ No newline at end of file diff --git a/actions/runtime/lib/browser/policy.js.map b/actions/runtime/lib/browser/policy.js.map new file mode 100644 index 00000000..ec241c70 --- /dev/null +++ b/actions/runtime/lib/browser/policy.js.map @@ -0,0 +1 @@ +{"version":3,"file":"policy.js","sourceRoot":"","sources":["../../../src/diffdevil/browser/policy.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: MIT\nimport { capture, fail, unwrap } from '../errors.js';\nimport { contentId, deepFreeze } from '../inert.js';\nimport { compilePolicy, type CompiledPolicy } from '../policy/compile.js';\nimport { normalizePolicy } from '../policy/normalize.js';\nimport { readPolicyYaml } from '../policy/yaml.js';\nimport { readPolicyDocument } from '../policy/validation.js';\nimport type { PolicyDocument, PolicyOrigin } from '../policy/types.js';\nimport type { Result } from '../model.js';\nexport type PolicyMode = 'composed' | 'repository' | 'personal-only';\nexport interface PolicyLayers { readonly mode: PolicyMode; readonly personal: string; readonly repository?: string; readonly override?: string; readonly templateFiles?: Readonly> }\nexport interface BrowserPolicy { readonly program: CompiledPolicy; readonly document: PolicyDocument; readonly origins: readonly PolicyOrigin[]; readonly mode: PolicyMode; readonly digest: string; readonly layers: readonly string[] }\nexport function readPolicyText(text: string, name = 'personal.yml'): Result {\n return capture(() => readPolicyDocument(unwrap(readPolicyYaml(text, { name })).document));\n}\nconst parts = (path: string): string[] => path.slice(1).split('/').map(key => key.replaceAll('~1', '/').replaceAll('~0', '~'));\nfunction lookup(value: unknown, path: string): unknown { return parts(path).reduce((parent, key) => parent && typeof parent === 'object' ? (parent as Record)[key] : undefined, value); }\nfunction assign(root: Record, path: string, value: unknown): void {\n const keys = parts(path); const leaf = keys.pop()!; let parent = root;\n for (const key of keys) { if (!parent[key] || typeof parent[key] !== 'object') parent[key] = Object.create(null) as object; parent = parent[key] as Record; }\n if (value === undefined) delete parent[leaf]; else parent[leaf] = value;\n}\n/** Compose normalized declarations; never let implicit upper presets reset personal policy. */\nexport function compileBrowserPolicy(json: string): Result {\n return capture(() => {\n if (typeof json !== 'string' || new TextEncoder().encode(json).byteLength > 3 * 1024 * 1024) fail('E_BROWSER_POLICY_LIMIT', 'Policy input exceeds 3 MiB.', 'config');\n const input = JSON.parse(json) as PolicyLayers;\n if (!input || !['composed', 'repository', 'personal-only'].includes(input.mode)) fail('E_CONFIG', 'Unknown browser policy mode.', 'config');\n const selected: { name: string; text: string }[] = [];\n const repository = input.mode !== 'personal-only' && input.repository !== undefined;\n if (input.mode !== 'repository' || !repository) selected.push({ name: 'personal', text: input.personal });\n if (repository) selected.push({ name: 'repository', text: input.repository! });\n if (input.mode !== 'repository' && input.override !== undefined) selected.push({ name: 'repository-personal-override', text: input.override });\n const document: Record = { version: 1, presets: [] }; const origins = new Map(); let program: CompiledPolicy | undefined;\n for (const [index, layer] of selected.entries()) {\n const authored = unwrap(readPolicyText(layer.text, layer.name)); const normalized = unwrap(normalizePolicy(authored));\n const lastOrigins = new Map(normalized.origins.map(origin => [origin.path, origin]));\n if (authored.presets !== undefined) for (const [path, origin] of origins) if (origin.layer.includes(':preset:')) { assign(document, path, undefined); origins.delete(path); }\n for (const [path, origin] of lastOrigins) {\n const preset = origin.layer.startsWith('preset:');\n if (preset && index > 0 && authored.presets === undefined) continue;\n const previous = origins.get(path)?.layer; assign(document, path, lookup(normalized.document, path));\n origins.set(path, { path, layer: preset ? `${layer.name}:${origin.layer}` : layer.name, ...(previous === undefined ? {} : { replaces: previous }) });\n }\n program = unwrap(compilePolicy(document, { ...(input.templateFiles ? { templateFiles: input.templateFiles } : {}) }));\n }\n if (!program) program = unwrap(compilePolicy(document));\n const effectiveOrigins = [...origins.values()];\n return deepFreeze({ program, document: readPolicyDocument(document), origins: effectiveOrigins, mode: input.mode,\n digest: contentId('browser-policy', [program.id, input.mode, effectiveOrigins]), layers: selected.map(layer => layer.name) });\n });\n}\n/** Host-provided, trusted-base files only. This discovery function does no I/O. */\nexport function requiredTemplates(json: string): Result {\n return capture(() => {\n const input = JSON.parse(json) as PolicyLayers; const result = new Set();\n const texts = input.mode === 'repository' && input.repository !== undefined ? [input.repository] : [input.personal, ...(input.mode === 'personal-only' ? [] : [input.repository]), ...(input.mode === 'repository' ? [] : [input.override])];\n for (const text of texts) if (text !== undefined) {\n const document = unwrap(readPolicyText(text));\n for (const rule of Object.values(document.rules ?? {})) { const path = rule.effects?.comment?.templateFile; if (path) result.add(path); }\n }\n return [...result];\n });\n}\n"]} \ No newline at end of file diff --git a/actions/runtime/lib/browser/runtime/crypto.js b/actions/runtime/lib/browser/runtime/crypto.js new file mode 100644 index 00000000..419b6946 --- /dev/null +++ b/actions/runtime/lib/browser/runtime/crypto.js @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +/** + * Browser stand-in for the one `createHash('sha256')` call the engine makes when it + * derives content identities. SubtleCrypto is asynchronous, and the engine's identity + * derivation is synchronous, so this is a small plain SHA-256 (FIPS 180-4) over UTF-8. + */ +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); +const rotr = (x, n) => (x >>> n) | (x << (32 - n)); +function sha256(bytes) { + const length = bytes.length; + const bitLength = length * 8; + const padded = new Uint8Array(((length + 9 + 63) >> 6) << 6); + padded.set(bytes); + padded[length] = 0x80; + const view = new DataView(padded.buffer); + view.setUint32(padded.length - 4, bitLength >>> 0, false); + view.setUint32(padded.length - 8, Math.floor(bitLength / 0x100000000), false); + const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + const w = new Uint32Array(64); + for (let offset = 0; offset < padded.length; offset += 64) { + for (let i = 0; i < 16; i++) + w[i] = view.getUint32(offset + i * 4, false); + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + let a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7]; + for (let i = 0; i < 64; i++) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0; + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) >>> 0; + hh = g; + g = f; + f = e; + e = (d + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + h[0] = (h[0] + a) >>> 0; + h[1] = (h[1] + b) >>> 0; + h[2] = (h[2] + c) >>> 0; + h[3] = (h[3] + d) >>> 0; + h[4] = (h[4] + e) >>> 0; + h[5] = (h[5] + f) >>> 0; + h[6] = (h[6] + g) >>> 0; + h[7] = (h[7] + hh) >>> 0; + } + return Array.from(h, word => word.toString(16).padStart(8, '0')).join(''); +} +export function createHash(algorithm) { + if (algorithm !== 'sha256') + throw new Error(`Unsupported browser hash algorithm ${algorithm}.`); + const chunks = []; + const hash = { + update(data) { chunks.push(typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data)); return hash; }, + digest(encoding) { + if (encoding !== 'hex') + throw new Error('The browser hash shim digests to hex only.'); + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return sha256(bytes); + }, + }; + return hash; +} +export default { createHash }; +//# sourceMappingURL=crypto.js.map \ No newline at end of file diff --git a/actions/runtime/lib/browser/runtime/crypto.js.map b/actions/runtime/lib/browser/runtime/crypto.js.map new file mode 100644 index 00000000..6e19e399 --- /dev/null +++ b/actions/runtime/lib/browser/runtime/crypto.js.map @@ -0,0 +1 @@ +{"version":3,"file":"crypto.js","sourceRoot":"","sources":["../../../../src/diffdevil/browser/runtime/crypto.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B;;;;GAIG;AACH,MAAM,CAAC,GAAG,IAAI,WAAW,CAAC;IACxB,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU;IAC9F,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU;IAC9F,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU;IAC9F,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU;IAC9F,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU;IAC9F,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU;IAC9F,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU;IAC9F,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU;CAC/F,CAAC,CAAC;AACH,MAAM,IAAI,GAAG,CAAC,CAAS,EAAE,CAAS,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC;AAEnE,SAAS,MAAM,CAAC,KAAiB;IAC/B,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC;IAC5B,MAAM,SAAS,GAAG,MAAM,GAAG,CAAC,CAAC;IAC7B,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;IAC7D,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAClB,MAAM,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IACtB,MAAM,IAAI,GAAG,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACzC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,SAAS,KAAK,CAAC,EAAE,KAAK,CAAC,CAAC;IAC1D,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,WAAW,CAAC,EAAE,KAAK,CAAC,CAAC;IAC9E,MAAM,CAAC,GAAG,IAAI,WAAW,CAAC,CAAC,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,CAAC,CAAC,CAAC;IAC5H,MAAM,CAAC,GAAG,IAAI,WAAW,CAAC,EAAE,CAAC,CAAC;IAC9B,KAAK,IAAI,MAAM,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,IAAI,EAAE,EAAE,CAAC;QAC1D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE;YAAE,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC;QAC1E,KAAK,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC;YAC7B,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,CAAE,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,CAAE,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,CAAE,KAAK,CAAC,CAAC,CAAC;YAC3E,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAE,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAE,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAE,KAAK,EAAE,CAAC,CAAC;YAC1E,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,CAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAE,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC;QAClD,CAAC;QACD,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAE,EAAE,EAAE,GAAG,CAAC,CAAC,CAAC,CAAE,CAAC;QAC5F,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC;YAC5B,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YAClD,MAAM,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAC9B,MAAM,EAAE,GAAG,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,CAAE,GAAG,CAAC,CAAC,CAAC,CAAE,CAAC,KAAK,CAAC,CAAC;YAChD,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YAClD,MAAM,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YACxC,MAAM,EAAE,GAAG,CAAC,EAAE,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC;YAC5B,EAAE,GAAG,CAAC,CAAC;YAAC,CAAC,GAAG,CAAC,CAAC;YAAC,CAAC,GAAG,CAAC,CAAC;YAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC;YAAC,CAAC,GAAG,CAAC,CAAC;YAAC,CAAC,GAAG,CAAC,CAAC;YAAC,CAAC,GAAG,CAAC,CAAC;YAAC,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC;QACrF,CAAC;QACD,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;QAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;QAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;QAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;QACvG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;QAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;QAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;QAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAE,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC;IAC1G,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AAC5E,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,SAAiB;IAC1C,IAAI,SAAS,KAAK,QAAQ;QAAE,MAAM,IAAI,KAAK,CAAC,sCAAsC,SAAS,GAAG,CAAC,CAAC;IAChG,MAAM,MAAM,GAAiB,EAAE,CAAC;IAChC,MAAM,IAAI,GAAG;QACX,MAAM,CAAC,IAAyB,IAAI,MAAM,CAAC,IAAI,CAAC,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,CAAC;QACjJ,MAAM,CAAC,QAAgB;YACrB,IAAI,QAAQ,KAAK,KAAK;gBAAE,MAAM,IAAI,KAAK,CAAC,4CAA4C,CAAC,CAAC;YACtF,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE,CAAC,GAAG,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;YACnE,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC;YACpC,IAAI,MAAM,GAAG,CAAC,CAAC;YACf,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;gBAAC,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;gBAAC,MAAM,IAAI,KAAK,CAAC,MAAM,CAAC;YAAC,CAAC;YACjF,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;QACvB,CAAC;KACF,CAAC;IACF,OAAO,IAAI,CAAC;AACd,CAAC;AACD,eAAe,EAAE,UAAU,EAAE,CAAC","sourcesContent":["// SPDX-License-Identifier: MIT\n/**\n * Browser stand-in for the one `createHash('sha256')` call the engine makes when it\n * derives content identities. SubtleCrypto is asynchronous, and the engine's identity\n * derivation is synchronous, so this is a small plain SHA-256 (FIPS 180-4) over UTF-8.\n */\nconst K = new Uint32Array([\n 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,\n 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,\n 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,\n 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,\n 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,\n 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,\n 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,\n 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,\n]);\nconst rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));\n\nfunction sha256(bytes: Uint8Array): string {\n const length = bytes.length;\n const bitLength = length * 8;\n const padded = new Uint8Array(((length + 9 + 63) >> 6) << 6);\n padded.set(bytes);\n padded[length] = 0x80;\n const view = new DataView(padded.buffer);\n view.setUint32(padded.length - 4, bitLength >>> 0, false);\n view.setUint32(padded.length - 8, Math.floor(bitLength / 0x100000000), false);\n const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);\n const w = new Uint32Array(64);\n for (let offset = 0; offset < padded.length; offset += 64) {\n for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false);\n for (let i = 16; i < 64; i++) {\n const s0 = rotr(w[i - 15]!, 7) ^ rotr(w[i - 15]!, 18) ^ (w[i - 15]! >>> 3);\n const s1 = rotr(w[i - 2]!, 17) ^ rotr(w[i - 2]!, 19) ^ (w[i - 2]! >>> 10);\n w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0;\n }\n let a = h[0]!, b = h[1]!, c = h[2]!, d = h[3]!, e = h[4]!, f = h[5]!, g = h[6]!, hh = h[7]!;\n for (let i = 0; i < 64; i++) {\n const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);\n const ch = (e & f) ^ (~e & g);\n const t1 = (hh + S1 + ch + K[i]! + w[i]!) >>> 0;\n const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);\n const maj = (a & b) ^ (a & c) ^ (b & c);\n const t2 = (S0 + maj) >>> 0;\n hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;\n }\n h[0] = (h[0]! + a) >>> 0; h[1] = (h[1]! + b) >>> 0; h[2] = (h[2]! + c) >>> 0; h[3] = (h[3]! + d) >>> 0;\n h[4] = (h[4]! + e) >>> 0; h[5] = (h[5]! + f) >>> 0; h[6] = (h[6]! + g) >>> 0; h[7] = (h[7]! + hh) >>> 0;\n }\n return Array.from(h, word => word.toString(16).padStart(8, '0')).join('');\n}\n\nexport function createHash(algorithm: string) {\n if (algorithm !== 'sha256') throw new Error(`Unsupported browser hash algorithm ${algorithm}.`);\n const chunks: Uint8Array[] = [];\n const hash = {\n update(data: string | Uint8Array) { chunks.push(typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data)); return hash; },\n digest(encoding: string) {\n if (encoding !== 'hex') throw new Error('The browser hash shim digests to hex only.');\n const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }\n return sha256(bytes);\n },\n };\n return hash;\n}\nexport default { createHash };\n"]} \ No newline at end of file diff --git a/actions/runtime/lib/browser/runtime/util.js b/actions/runtime/lib/browser/runtime/util.js new file mode 100644 index 00000000..1a46e325 --- /dev/null +++ b/actions/runtime/lib/browser/runtime/util.js @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT +/** Fresh JSON crosses the browser facade, never caller-owned proxies/getters. */ +export const types = { isProxy: (_value) => false }; +export default { types }; +//# sourceMappingURL=util.js.map \ No newline at end of file diff --git a/actions/runtime/lib/browser/runtime/util.js.map b/actions/runtime/lib/browser/runtime/util.js.map new file mode 100644 index 00000000..95399522 --- /dev/null +++ b/actions/runtime/lib/browser/runtime/util.js.map @@ -0,0 +1 @@ +{"version":3,"file":"util.js","sourceRoot":"","sources":["../../../../src/diffdevil/browser/runtime/util.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,iFAAiF;AACjF,MAAM,CAAC,MAAM,KAAK,GAAG,EAAE,OAAO,EAAE,CAAC,MAAe,EAAW,EAAE,CAAC,KAAK,EAAE,CAAC;AACtE,eAAe,EAAE,KAAK,EAAE,CAAC","sourcesContent":["// SPDX-License-Identifier: MIT\n/** Fresh JSON crosses the browser facade, never caller-owned proxies/getters. */\nexport const types = { isProxy: (_value: unknown): boolean => false };\nexport default { types };\n"]} \ No newline at end of file diff --git a/actions/runtime/lib/browser/text.js b/actions/runtime/lib/browser/text.js new file mode 100644 index 00000000..2670017c --- /dev/null +++ b/actions/runtime/lib/browser/text.js @@ -0,0 +1,12 @@ +/** Formatting only; hosts do not recalculate evidence or classify bands. */ +export function measurementText(value) { + const n = (value) => new Intl.NumberFormat('en').format(value); + switch (value.status) { + case 'exact': return n(value.value); + case 'bounded': return `${n(value.lower)}–${n(value.upper)}`; + case 'unknown': return value.lower === undefined ? '?' : `≥${n(value.lower)}`; + case 'unmeasurable': return '∅'; + } +} +export function evidenceText(status) { return `${{ exact: '=', bounded: '≈', unknown: '?', unmeasurable: '∅' }[status]} ${status}`; } +//# sourceMappingURL=text.js.map \ No newline at end of file diff --git a/actions/runtime/lib/browser/text.js.map b/actions/runtime/lib/browser/text.js.map new file mode 100644 index 00000000..f44e6674 --- /dev/null +++ b/actions/runtime/lib/browser/text.js.map @@ -0,0 +1 @@ +{"version":3,"file":"text.js","sourceRoot":"","sources":["../../../src/diffdevil/browser/text.ts"],"names":[],"mappings":"AAEA,4EAA4E;AAC5E,MAAM,UAAU,eAAe,CAAC,KAAyB;IACvD,MAAM,CAAC,GAAG,CAAC,KAAa,EAAU,EAAE,CAAC,IAAI,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC/E,QAAQ,KAAK,CAAC,MAAM,EAAE,CAAC;QAAC,KAAK,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAAC,KAAK,SAAS,CAAC,CAAC,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC;QAAC,KAAK,SAAS,CAAC,CAAC,OAAO,KAAK,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC;QAAC,KAAK,cAAc,CAAC,CAAC,OAAO,GAAG,CAAC;IAAC,CAAC;AAC9O,CAAC;AACD,MAAM,UAAU,YAAY,CAAC,MAAoC,IAAY,OAAO,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,YAAY,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC,CAAC,CAAC","sourcesContent":["// SPDX-License-Identifier: MIT\nimport type { NumericMeasurement } from '../model.js';\n/** Formatting only; hosts do not recalculate evidence or classify bands. */\nexport function measurementText(value: NumericMeasurement): string {\n const n = (value: number): string => new Intl.NumberFormat('en').format(value);\n switch (value.status) { case 'exact': return n(value.value); case 'bounded': return `${n(value.lower)}–${n(value.upper)}`; case 'unknown': return value.lower === undefined ? '?' : `≥${n(value.lower)}`; case 'unmeasurable': return '∅'; }\n}\nexport function evidenceText(status: NumericMeasurement['status']): string { return `${{ exact: '=', bounded: '≈', unknown: '?', unmeasurable: '∅' }[status]} ${status}`; }\n"]} \ No newline at end of file diff --git a/actions/runtime/lib/browser/view.js b/actions/runtime/lib/browser/view.js new file mode 100644 index 00000000..724d1ee8 --- /dev/null +++ b/actions/runtime/lib/browser/view.js @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +import { capture, unwrap } from '../errors.js'; +import { contentId, deepFreeze } from '../inert.js'; +import { aggregateFiles, readReport, summarizeMeasurements } from '../report.js'; +import { evaluatePolicy } from '../policy/evaluate.js'; +export const HUMAN_VIEW_VERSION = 'diffdevil.human-view/1'; +/** A file projection preserves its evidence, then evaluates the same policy on its own facts. */ +function fileReport(report, file) { + const fileSet = { complete: true, total: { status: 'exact', value: 1 } }; + const totals = aggregateFiles([file], fileSet); + const body = { kind: report.kind, schemaVersion: report.schemaVersion, semantics: report.semantics, source: report.source, files: [file], fileSet, totals, + measurement: summarizeMeasurements([...Object.values(totals.raw), ...Object.values(totals.lines), ...Object.values(totals.files)], file.included ? file.measurement.reasons : []) }; + return unwrap(readReport({ ...body, reportId: contentId('report', body) })); +} +function rails(report, policy) { + return Object.entries(policy.document.bands ?? {}).map(([id, definition]) => { + const decision = report.bands?.[id]; + const selected = decision?.status === 'resolved' ? decision.id : undefined; + const rule = Object.values(policy.document.rules ?? {}).find(rule => 'band' in rule && rule.band === id && rule.effects?.labels && 'byBand' in rule.effects.labels); + const mapping = rule?.effects?.labels; + let lower = definition.minimum; + const cells = definition.ranges.map(range => { + const label = mapping?.byBand[range.id]; + const color = label ? policy.document.labelDefinitions?.[label]?.color : undefined; + const result = { id: range.id, name: label ?? range.id, selected: selected === range.id, + ...(lower === undefined ? {} : { lower }), ...(range.lt === undefined ? {} : { upper: range.lt }), + ...(label === undefined ? {} : { label }), ...(color === undefined ? {} : { color }) }; + lower = range.lt; + return result; + }); + const label = selected === undefined ? undefined : mapping?.byBand[selected]; + return { id, expression: definition.value, cells, ...(selected === undefined ? {} : { selected }), ...(label === undefined ? {} : { label }), + ...(mapping ? { group: mapping.group, members: policy.document.labelGroups?.[mapping.group] ?? [] } : {}) }; + }); +} +/** Shared, typed human projection. DOM and CLI hosts never recompute Changed. */ +export function humanReport(input, policy, path, errors = []) { + return capture(() => { + const parent = unwrap(readReport(input)); + const file = path === undefined ? undefined : parent.files.find(item => item.path === path); + if (path !== undefined && !file) + throw new Error('The requested file is absent from this comparison.'); + let report = file ? fileReport(parent, file) : parent; + if (policy) + report = unwrap(evaluatePolicy(policy.program, report)).report; + const focus = file ? report.files[0] : undefined; + const lines = focus?.lines ?? report.totals.lines; + return deepFreeze({ kind: HUMAN_VIEW_VERSION, report, ...(focus ? { focus } : {}), changed: lines.changed, added: lines.added, deleted: lines.deleted, modified: lines.modified, + raw: focus?.raw ?? report.totals.raw, evidence: focus?.measurement ?? report.measurement, rails: policy && focus?.included !== false ? rails(report, policy) : [], + ...(policy ? { policy: { digest: policy.digest, mode: policy.mode, origins: policy.origins, layers: policy.layers } } : {}), errors }); + }); +} +//# sourceMappingURL=view.js.map \ No newline at end of file diff --git a/actions/runtime/lib/browser/view.js.map b/actions/runtime/lib/browser/view.js.map new file mode 100644 index 00000000..4aad44c7 --- /dev/null +++ b/actions/runtime/lib/browser/view.js.map @@ -0,0 +1 @@ +{"version":3,"file":"view.js","sourceRoot":"","sources":["../../../src/diffdevil/browser/view.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: MIT\nimport { capture, unwrap } from '../errors.js';\nimport { contentId, deepFreeze } from '../inert.js';\nimport { aggregateFiles, readReport, summarizeMeasurements } from '../report.js';\nimport { evaluatePolicy } from '../policy/evaluate.js';\nimport type { BandLabels } from '../policy/types.js';\nimport type { Report, FileRecord, NumericMeasurement, Result, Diagnostic } from '../model.js';\nimport type { BrowserPolicy } from './policy.js';\nexport const HUMAN_VIEW_VERSION = 'diffdevil.human-view/1';\nexport interface RailCell { readonly id: string; readonly name: string; readonly lower?: number; readonly upper?: number; readonly color?: string; readonly label?: string; readonly selected: boolean }\nexport interface Rail { readonly id: string; readonly expression: string; readonly cells: readonly RailCell[]; readonly selected?: string; readonly label?: string; readonly group?: string; readonly members?: readonly string[] }\nexport interface HumanReportView {\n readonly kind: typeof HUMAN_VIEW_VERSION; readonly report: Report; readonly focus?: FileRecord;\n readonly changed: NumericMeasurement; readonly added: NumericMeasurement; readonly deleted: NumericMeasurement; readonly modified: NumericMeasurement;\n readonly raw: Report['totals']['raw']; readonly evidence: Report['measurement']; readonly rails: readonly Rail[];\n readonly policy?: { readonly digest: string; readonly mode: string; readonly origins: BrowserPolicy['origins']; readonly layers: readonly string[] };\n readonly errors: readonly Diagnostic[];\n}\n/** A file projection preserves its evidence, then evaluates the same policy on its own facts. */\nfunction fileReport(report: Report, file: FileRecord): Report {\n const fileSet = { complete: true, total: { status: 'exact' as const, value: 1 } }; const totals = aggregateFiles([file], fileSet);\n const body = { kind: report.kind, schemaVersion: report.schemaVersion, semantics: report.semantics, source: report.source, files: [file], fileSet, totals,\n measurement: summarizeMeasurements([...Object.values(totals.raw), ...Object.values(totals.lines), ...Object.values(totals.files)], file.included ? file.measurement.reasons : []) };\n return unwrap(readReport({ ...body, reportId: contentId('report', body) }));\n}\nfunction rails(report: Report, policy: BrowserPolicy): Rail[] {\n return Object.entries(policy.document.bands ?? {}).map(([id, definition]) => {\n const decision = report.bands?.[id]; const selected = decision?.status === 'resolved' ? decision.id : undefined;\n const rule = Object.values(policy.document.rules ?? {}).find(rule => 'band' in rule && rule.band === id && rule.effects?.labels && 'byBand' in rule.effects.labels);\n const mapping = rule?.effects?.labels as BandLabels | undefined; let lower = definition.minimum;\n const cells: RailCell[] = definition.ranges.map(range => {\n const label = mapping?.byBand[range.id]; const color = label ? policy.document.labelDefinitions?.[label]?.color : undefined;\n const result = { id: range.id, name: label ?? range.id, selected: selected === range.id,\n ...(lower === undefined ? {} : { lower }), ...(range.lt === undefined ? {} : { upper: range.lt }),\n ...(label === undefined ? {} : { label }), ...(color === undefined ? {} : { color }) };\n lower = range.lt; return result;\n });\n const label = selected === undefined ? undefined : mapping?.byBand[selected];\n return { id, expression: definition.value, cells, ...(selected === undefined ? {} : { selected }), ...(label === undefined ? {} : { label }),\n ...(mapping ? { group: mapping.group, members: policy.document.labelGroups?.[mapping.group] ?? [] } : {}) };\n });\n}\n/** Shared, typed human projection. DOM and CLI hosts never recompute Changed. */\nexport function humanReport(input: Report, policy?: BrowserPolicy, path?: string, errors: readonly Diagnostic[] = []): Result {\n return capture(() => {\n const parent = unwrap(readReport(input)); const file = path === undefined ? undefined : parent.files.find(item => item.path === path);\n if (path !== undefined && !file) throw new Error('The requested file is absent from this comparison.');\n let report = file ? fileReport(parent, file) : parent;\n if (policy) report = unwrap(evaluatePolicy(policy.program, report)).report;\n const focus = file ? report.files[0] : undefined; const lines = focus?.lines ?? report.totals.lines;\n return deepFreeze({ kind: HUMAN_VIEW_VERSION, report, ...(focus ? { focus } : {}), changed: lines.changed, added: lines.added, deleted: lines.deleted, modified: lines.modified,\n raw: focus?.raw ?? report.totals.raw, evidence: focus?.measurement ?? report.measurement, rails: policy && focus?.included !== false ? rails(report, policy) : [],\n ...(policy ? { policy: { digest: policy.digest, mode: policy.mode, origins: policy.origins, layers: policy.layers } } : {}), errors });\n });\n}\n"]} \ No newline at end of file diff --git a/apps/browser-extension/ARCHITECTURE.md b/apps/browser-extension/ARCHITECTURE.md new file mode 100644 index 00000000..aceebe6e --- /dev/null +++ b/apps/browser-extension/ARCHITECTURE.md @@ -0,0 +1,59 @@ +# Browser-extension architecture + +## Meaning + +The browser is a new host for the existing measurement and policy system. DOM markup is placement and acquisition evidence, not a second analysis engine. This contribution is independent of the unmerged terminal-presenter candidate and does not modify the website or its public marketing surface. + +## Boundaries and ownership + +`src/diffdevil/browser/` is reusable MIT code. Its serialized acquisition and policy entry points validate data, invoke the existing parser/normalizer/compiler/evaluator and return the typed human projection. `github/changes.ts` is the original pure provider-file normalization extracted from `github/source.ts`; Node and browser consumers share it. + +`apps/browser-extension/src/content/` owns github.com lifecycle and presentation. Its production controller accepts explicit platform dependencies for deterministic testing; the real bootstrap uses the actual location, acquisition and extension messaging. The content bundle imports the lightweight measurement formatter, not the compiler or parser. `src/background/` owns processing, settings transactions, bounded rebuildable storage and tightly scoped public API reads. `src/options/` is a separate full-tab application. Neither UI reconstructs numbers from CLI output or parses terminal styling. + +The public `@wolfsblvt/diffdevil/browser` export is a closed ESM browser bundle. Fixed standalone validators are bundled ahead of time. No Node aliases, remote executable code, dynamic evaluation or schema compilation are required from consumers. Browser SHA-256 parity is tested against Node. Browser code cannot inspect proxies; the facade accepts serialized input, parses it into its own inert realm and uses the existing engine's validation boundary rather than trusting caller-owned executable objects. + +## Comparison and acquisition + +A comparison key contains host, normalized repository, PR number and full immutable base/head SHAs. A cache entry cannot become current merely because it was drawn at the same URL. Provider-generated JSON and narrowly selected immutable attributes can identify the comparison; arbitrary issue prose, abbreviated commit labels and GitHub line counters alone cannot. + +The signed-in route reads the complete PR diff using same-origin GET and re-reads a fresh PR document after acquisition. The public fallback reads PR metadata, paginated files, then metadata again without credentials. The latter uses at most thirty 100-file pages, honors transport/evidence limits and rejects a changed comparison. Explicit provider counters are cross-checked. A diff cut at a complete file boundary is still incomplete unless independent cardinality proves the file set. A malformed final hunk cannot bless exact facts; complete independent blocks remain useful when recoverable. + +The signed-in diff and HTML response limit is 8 MiB. Serialized worker/acquisition requests are bounded at 24 MiB. Policies and individual templates are bounded at 256 KiB; at most sixteen explicit template paths are acquired. The API adapter has a 20 MiB aggregate pagination limit and a 20-second request timeout. These are execution limits, not statements that larger PRs have no changes. A source-limit failure is visible and never substituted with a fabricated empty report. + +The content script's same-origin request is subject to the real browser's cookies, CORS, redirect and enterprise policies. The API host permission is not a claim that a content script can ignore those rules. The local Web sandbox cannot verify signed-in private GitHub access. Modern GitHub markup is an unstable provider interface; fixtures demonstrate the implemented selectors, not a promise about every future layout. + +## Policy composition + +Each participating policy is parsed and normalized through the real core. For higher layers, only explicitly declared normalized origins are overlaid. An implicit repository preset must not reset personal defaults. Named declarations and arrays replace; path members merge. Explicit presets replace inherited preset-origin values, not separately authored declarations. Every composed prefix compiles, so an upper override cannot hide a broken lower policy while valid upper expressions may reference lower declarations. + +Composed: personal < repository at base < explicit personal override. Repository: repository, or personal if absent; personal overrides are ignored. Personal only: personal < explicit personal override, never repository. Empty include-only controls omit the member: an explicit `includeOnly: []` means include nothing in the core and is not a synonym for no restriction. + +Guided UI values lower to ordinary policy declarations. Nonempty advanced YAML replaces that guided personal document. Names and optional colour metadata can decorate personally owned bands, but never overwrite a repository-authored band's provenance or facts. Partial label mappings are local handoff metadata, not an invented incomplete core label effect. A file projection evaluates its own core facts. Excluded files retain real measurements but have no virtual size classification. + +Repository policy, including referenced templates, is read at the trusted base, not the pull-request proposal. A 404 is cached as absence only when exact-base access is independently confirmed. Invalid/unavailable policy leaves the report readable without a band. Users can deliberately choose Personal only rather than having the extension make that decision silently. + +## Lifecycle and cache + +Generation counters and AbortControllers prevent an old PR/head response from replacing the current view. Policy/settings changes use distinct context identities. Equivalent in-flight analysis is deduplicated; at most three analysis operations are accepted concurrently. The worker retains at most six report contexts and 150 focused projections per context. Source memory is transient. Twenty-four visible paths are requested per projection batch; failed/missing paths do not generate an endless retry loop. + +A scoped repository-root MutationObserver watches provider changes. A cheap outer watcher only rebinds a detached root. There is no interval polling, broad rescanning for every page mutation, MutationObserver loop on injected nodes or global body opacity. Soft navigation, head changes, lazy files, root replacement, themes and explicit disabling are separate transitions. Per-file whole-PR facts are not injected into commit-only/narrowed views. + +Rebuildable IndexedDB entries have identities, estimated serialized byte sizes and access timestamps. A 4–128 MiB LRU budget applies to normalized reports and trusted policy entries. Oversized entries are not persisted. The browser's independent quota may still reject a write; cache failure does not cancel a valid in-memory analysis. A generation boundary prevents a late operation repopulating data cleared by the user. Clearing cache never clears user configuration. Browser restart may retain cache but loses in-memory contexts; the controller detects that boundary and reacquires a context. + +Small synchronized preferences and larger local policy/overrides use a serialized transaction with a durable previous-state journal. Interrupted writes recover before the next load/save. A failed rollback retains the journal and reports the failure; it does not silently delete policy. Reset is an explicit destructive operation. It also clears the settings page's local theme hint. + +## Human and native surfaces + +The Changed row is primary; raw churn remains a secondary, independently configurable row. GitHub's native diffstat is independently dimmed or hidden. Monochrome/full-colour/none changes only the product mark, not measurement evidence. The monochrome default resolves the accepted shared centre-seam `diffdevil/brand` glyph copied unchanged from its pinned MIT source; this repository does not redraw its geometry. + +The report is one non-modal anchored element. It does not lock page scrolling, request fullscreen or turn into a sidebar. It fits on the chosen side without covering its own trigger, has constrained inner scrolling, handles resize/zoom-sized viewports, closes on anchor disappearance and restores focus appropriately. All provider/path/policy text is inserted as text, not executable HTML. + +The native label handoff opens and filters GitHub's visible picker. It never clicks the actual selection, sends a hidden mutation or creates a label. Native DOM readback can say a label was observed; it cannot prove the actor or response of a hidden provider operation. Other managed labels remain the user's native reconciliation decision. + +## Optional App contract + +`appStanding` compares the complete comparison, report, effective-policy, engine, schema, semantics and presenter identities. An older head is stale; a current report with different policy is not agreement; a version mismatch is incompatible. No guessed report-delivery endpoint exists. The current application always says local. A concrete authenticated producer/transport is a preserved external integration boundary, not permission to scrape a convincing-looking comment and display a checkmark. + +## Sources + +Provider constraints: [Chrome network requests](https://developer.chrome.com/docs/extensions/develop/concepts/network-requests), [storage](https://developer.chrome.com/docs/extensions/reference/api/storage), [GitHub PR files](https://docs.github.com/en/rest/pulls/pulls#list-pull-requests-files), [GitHub API versions](https://docs.github.com/en/rest/about-the-rest-api/api-versions). Read against the implementation on 2026-09-19. These describe platform interfaces; they do not establish this candidate's live-browser acceptance. diff --git a/apps/browser-extension/CHANGELOG.md b/apps/browser-extension/CHANGELOG.md new file mode 100644 index 00000000..ef2d2b1a --- /dev/null +++ b/apps/browser-extension/CHANGELOG.md @@ -0,0 +1,13 @@ +# Source-candidate changes + +## 1.0.0 browser source candidate + +Added a portable, self-contained MIT browser API over the existing engine, with conservative acquisition, layered real policies, typed aggregate/file presentation and explicit optional App identity standing. Extracted the existing pure GitHub-file normalizer for reuse rather than forking measurements. + +Added a Chrome Manifest V3 application with worker analysis, guarded public-source fallback, trusted-base policy acquisition, bounded rebuildable cache and recoverable settings transactions. Added GitHub lifecycle handling, full-PR and lazy per-file measurements, inspectable non-modal reports, dynamic bands, virtual label metadata and explicit native-picker handoff. + +Added a complete options application with authored dark/light themes, search, Basic/Advanced discovery, literal stable anchors, icon choices, band/path/YAML and repository-policy editors, validation, per-setting reset, cache management, redacted diagnostics and portable import/export. Unsaved edits survive navigation within settings. + +Added core, application, rendered-browser, acquisition and installed-browser qualification paths, candidate evidence receipts, Store listing/submission material and reproducible icon/screenshot/promotion assets. Nothing was merged, deployed or published by the local build. + +The canonical centre-seam `diffdevil/brand` glyph is integrated from its pinned accepted MIT source. Authenticated App report delivery remains a separate integration boundary; local results remain explicit. Installed/live/Windows evidence must be accepted separately. diff --git a/apps/browser-extension/LICENSES.md b/apps/browser-extension/LICENSES.md new file mode 100644 index 00000000..b4e8e60a --- /dev/null +++ b/apps/browser-extension/LICENSES.md @@ -0,0 +1,21 @@ +# Extension licences and notices + +## Component boundaries + +The extension application, UI, settings, browser integration and application tests are AGPL-3.0-only. The reusable browser adapter, engine, policy compiler, report contracts and presentation API under src/diffdevil are MIT. Reusable build tools retain the repository’s MIT tooling boundary. Original documentation prose is CC BY 4.0, attributed to Wolfsblvt Works. The repository’s LICENSES/README.md is the authoritative component map. + +## Product identity + +The diffdevil name, symbol, wordmark, screenshots and visual assets remain reserved under the repository’s identity terms. Software licences do not grant trademark rights. The monochrome interface mark is the accepted centre-seam `diffdevil/brand` glyph copied unchanged from `Wolfsblvt/wolfsblvt-icons@8596b6bc2ba7c4b950963b7ec26cee1a26974439` under MIT. Full-colour identity artwork remains reserved. + +## Bundled dependencies + +The distribution includes applicable notices for Chevrotain, YAML, Ajv, fast-deep-equal, fast-uri and json-schema-traverse in its licenses directory. These dependencies retain their own upstream licences. Build-time esbuild and resvg are not remotely loaded executable dependencies. + +## Fonts + +A normal source build obtains IBM Plex Sans and Mono from the pinned Fontsource packages and includes their SIL Open Font License notices. The font-free handoff build uses declared system fallbacks and contains no font files. Font redistribution is not implied by the original software or identity licences. + +## Corresponding source + +Corresponding extension source, reusable engine source and build instructions belong to the Wolfsblvt/diffdevil repository. An unpublished local candidate must be accompanied by its exact source patch or snapshot, not represented as an accepted or published release. No Store publication, trademark permission or additional commercial licence is implied by this build. diff --git a/apps/browser-extension/PRIVACY.md b/apps/browser-extension/PRIVACY.md new file mode 100644 index 00000000..4da1a503 --- /dev/null +++ b/apps/browser-extension/PRIVACY.md @@ -0,0 +1,37 @@ +# Privacy in diffdevil for GitHub + +## What this notice covers + +This notice describes the browser extension’s implemented data handling. It does not cover GitHub’s own service or the independent diffdevil GitHub App. This source candidate is not a published Chrome Web Store listing. + +## Information read on GitHub + +The extension reads the repository and pull-request identity, immutable base and head revisions, change counters, file identities, available diff material and relevant native label controls. When the selected policy mode uses repository configuration, it reads .diffdevil.yml and explicitly referenced templates from the exact trusted base revision. Proposed pull-request configuration is not used as trusted policy. + +Requests use the signed-in GitHub page when available. A public GitHub API fallback deliberately omits credentials and uses no token. The extension does not collect cookies, ask for personal access tokens, install a local daemon, or enumerate browser history. Outside a supported GitHub pull-request route it does not analyze page content. GitHub receives ordinary requests to its own resources and operates under its own terms and privacy practices. + +## Local processing and storage + +Analysis runs locally in the extension worker. Raw diffs and referenced template contents are held in memory for analysis and are not persisted as an analysis cache. There is no telemetry, advertising identifier, analytics service, source-upload endpoint, or request to a Wolfsblvt Works backend. No hosted analysis is triggered. + +The bounded rebuildable cache contains normalized reports, file paths, revision identities, trusted repository-policy text and exact-base missing-file results. These can reveal private repository information to someone with access to the browser profile. Cache data is not independently encrypted. Least-recently-used eviction and a configurable size limit bound the retained data. + +Small display and guided-policy preferences use Chrome synchronized storage. When browser synchronization is enabled, the browser provider may synchronize those preferences across the user’s signed-in browsers. Large advanced YAML and repository overrides remain in local extension storage. “Local analysis” does not mean synchronized preferences never leave the device. + +Runtime context and last-analysis identity are session-scoped. Diagnostic history retains codes and timestamps, not raw patches, response bodies or policy expressions. A deliberate support snapshot excludes repository identities, paths, policy text and source. A deliberate settings export contains the user’s configuration and may include private repository names or policy text; share it carefully. + +## Controls and deletion + +Settings can disable augmentation, select Personal only mode, change the cache limit, clear reports, clear repository-policy caches, clear overrides and reset all extension data. Destructive resets require confirmation. Rebuildable data does not change repository truth. Already rendered facts on an open page can remain until that page refreshes or closes. Uninstalling removes extension storage according to the browser’s own behavior. + +Incognito operation is disabled in this candidate rather than silently mixing private-session analysis with persistent ordinary-profile caches. GitHub Enterprise hosts and Firefox are not supported by this build. + +## Repository interactions + +The extension never applies a label automatically. Its native handoff opens and searches GitHub’s visible label picker; the user owns the native selection. No hidden POST, automatic label mutation, label creation, App analysis request or quota-consuming hosted action is performed. Copying and opening product links happen only after user interaction. + +## Permissions and support + +The extension requests storage, a content script on github.com to follow pull-request navigation, and host access to api.github.com for public read-only fallback. It does not request cookies, browsing history, tabs enumeration, all-site access, unlimited storage or clipboard permissions. The ordinary user-initiated clipboard capability can be declined by the browser. + +Support is available through Issues in the Wolfsblvt/diffdevil repository. Do not attach private source, credentials or unredacted configuration to a public issue. Before Store publication, the maintainer must verify this notice and the Store declarations against the exact packaged release. This notice is not a privacy certification or a guarantee of Store approval. diff --git a/apps/browser-extension/QA.md b/apps/browser-extension/QA.md new file mode 100644 index 00000000..ed99346f --- /dev/null +++ b/apps/browser-extension/QA.md @@ -0,0 +1,60 @@ +# Browser-extension qualification + +## Meaning + +Different evidence layers answer different questions. A production DOM test with explicit fixtures is not an installed extension, a live GitHub page, a private-repository test or a Windows result. The build and handoff keep those distinctions visible. + +## Reproduce + +```sh +npm ci --ignore-scripts +npm run build +npm run extension:check +node tools/browser-extension-build.mjs --without-fonts +node --test src/diffdevil/tests/browser.test.mjs +npm run extension:test +npm run extension:qa +npm run extension:qa:installed -- --require +npm run extension:store +npm run check:actions +``` + +Install the appropriate Playwright browser first with `npx playwright install chromium`, or set `CHROMIUM_EXECUTABLE` to an allowed installation. The installed runner supports `HEADED=1`. It never alters managed policies. Without `--require`, an installation prohibition writes a `blocked-by-managed-policy` receipt without pretending to have run installed assertions; with `--require`, it fails the gate with exit status 2. + +## Implemented automated coverage + +The shared-core tests exercise replacement-aware/raw parity, all evidence states, partial source and binary behavior, SHA validation, counter disagreement, file projections, exclusions, real policy dialect/composition/provenance, malformed lower policy, independent bands, runtime SHA parity and App identity standing. + +The application tests use actual production classes with explicitly injected storage and fetch contracts. They cover defaults and policy lowering, include-only semantics, partial label mappings, settings validation and overrides, queueing/journal recovery, quota rollback, LRU invariants, sender/PR scopes, public API pagination and freshness, policy absence versus inaccessible base, bounded bodies, manifest permissions and closed executable bundles. + +The rendered-browser suite executes the real options and content controllers. It checks literal anchors, deep-link focus, Advanced discovery, search/view/draft preservation, invalid edits, reset confirmation, themes and phone widths; lazy modern/legacy file placement, repeated navigation, root replacement, whole-PR versus commit-only views, late-head rejection, bounded retries, native diffstat restoration, icon modes and conservative evidence; anchored interaction by click, Escape, outside pointer and keyboard; text-only hostile paths; native-picker filtering without a provider mutation; and absence of external requests or uncaught page errors in the fixture run. + +A separate browser acquisition suite exercises the production signed-in adapter with authored HTTP responses. It checks immutable-base policy/template acquisition, after-fetch head validation, cache reuse, private/inaccessible 404 handling, truncated policy, deliberate Personal only, traversal rejection, HTML/error response rejection, byte limits, cancellation and independently confirmed empty comparisons. These are adapter tests, not live network, cookies or CORS evidence. + +The installed runner is fully authored. On an unrestricted browser it verifies actual MV3 startup/CSP, real synchronized/local storage separation, engine execution, IndexedDB normalization, browser/worker restart, cache reuse and explicit clear/reset behavior. It uses a synthetic report submitted through the real worker and does not impersonate live provider evidence. + +## This source session + +The source was reconstructed and verified against main commit `86dcf193a3186467d5b5f24a4246767adaf0a069`, tree `0658aa5f8b0e97f1774d05abb2bfc4b9f22bf733`. The local recovery commit is synthetic; it is not that upstream commit's ancestry. The extension does not depend on unmerged presenter PR #25 or claim that earlier bounded PR #26 contains this full application. + +The supplied receipts describe Linux Node 22.16.0, TypeScript 5.8.3 and managed Chromium 143. Core and application checks passed, as did rendered DOM and acquisition fixtures. The exact final counts, versions, hashes and durations belong to the generated receipts and room return, not a permanently frozen number in this manual. + +Managed Chromium blocks extension installation and general navigation here. The installed probe records that prohibition; no policy was changed. Live public/private GitHub layouts, actual authenticated redirects, native extension storage/worker behavior, browser zoom settings, Windows runtime and native provider selection are not accepted by the DOM fixture evidence. Responsive viewport tests are not a substitute for installed Chrome zoom acceptance. + +The ordinary repository suite also runs unrelated application tests. In this sandbox, the existing `apps/github-app/d1-state.test.mjs` cannot load its locked `miniflare` package. The other registered tests ran; the root suite remains non-green rather than suppressing that file or fabricating D1 behavior. Wrangler/Astro dry-run/build gates likewise require their ordinary locked development dependencies. Run the normal repository CI before accepting the source. + +## Acceptance matrix for an unrestricted browser + +Use an allowed Chrome profile with a disposable fixture repository. Cover both public and authorized private repositories, all four PR tabs, full Files changed, a commit-only diff, soft navigation between PRs, force-push/base updates during an in-flight read, file expansion and root replacement. Exact numbers should match the same CLI source and policy, with incompleteness deliberately exercised rather than hidden. + +Check real cookies, redirects, public API rate limits and inaccessible-base policy errors. Verify no raw patch persists in IndexedDB/local/sync storage; inspect actual report/path and policy cache contents, size eviction and browser quota failure. Restart the browser and worker, then repeat cached analysis and clear/reset. Check two tabs with different comparisons and policies; obsolete results must not cross scopes. + +At 80%, 100%, 125%, 150% and 200% browser zoom, exercise aggregate/file popovers near all viewport edges. Use keyboard only, Escape/outside/toggle dismissal, focus restoration, light/dark/automatic themes, forced colours and a screen reader. Confirm that native focus indicators remain legible when raw stats are dimmed. + +For a writable repository, click only the handoff first and inspect the network: no label mutation should be sent. Make the actual selection yourself in GitHub, confirm the exact existing label, and confirm unrelated labels are untouched. Repeat without write permission and on a tab without a native picker. Never count a fixture DOM insertion as that provider acceptance. + +## Evidence outputs + +`artifacts/browser-extension/build-receipt.json` hashes the unpacked distribution and records glyph/font standing. `qa/receipt.json` covers rendered controllers and screenshots; `qa/acquisition-receipt.json` covers acquisition fixtures; `qa/installed-receipt.json` covers the separate installed gate. `store/asset-manifest.json` records dimensions, formats, provenance and hashes. Timing samples are single sandbox observations, not comparative performance claims or guarantees. + +Screenshots carry visible authored-fixture/source-candidate disclosures. The Store generator accepts only a successful DOM receipt and produces reproducible derivative assets from those captures. Generated artifacts and packaging are review evidence, not publication or acceptance authority. diff --git a/apps/browser-extension/QUALIFICATION.md b/apps/browser-extension/QUALIFICATION.md new file mode 100644 index 00000000..75c0820a --- /dev/null +++ b/apps/browser-extension/QUALIFICATION.md @@ -0,0 +1,44 @@ +# Source-candidate qualification + +## Standing + +This is a full authored local extension source candidate, not a published extension or an accepted live-GitHub integration. Its source, reproducible build, configuration application, tests, Store assets and evidence are carried together. Provider publication and final acceptance remain separate. + +The initial verified source is `Wolfsblvt/diffdevil@86dcf193a3186467d5b5f24a4246767adaf0a069`, tree `0658aa5f8b0e97f1774d05abb2bfc4b9f22bf733`. This contribution does not depend on unmerged presenter PR #25. The earlier draft PR #26 contains a different bounded browser-core attempt; it is not evidence that this full application has been transferred or reviewed. + +## Executed evidence + +On Linux, Node 22.16.0 and TypeScript 5.8.3, the final local pass includes: + +- Successful core build, strict extension type-check and font-free MV3 packaging. +- 69 focused passing tests: 24 shared browser-core tests and 45 application tests, with no failures or skipped tests. +- 42 passing Chromium DOM/controller checks and 13 passing browser acquisition-adapter checks, with explicit authored data and transport fixtures. +- Successful repository preparation check, covering 52 JSON assets and 189 declared conformance cases. +- Successful supplied conformance run. The exact category counts are in the evidence log. +- A freshly regenerated Action distribution: 1,159 generated files and 12 locked runtime packages. The generated-file freshness check passes. +- Ten successful install-free Action consumer executions covering all four entry points, under Node 22 with mock HTTP. Node 24 was not exercised locally. +- Eight generated Store images with source, size, format and SHA-256 receipts. Screenshots and promotional PNGs are 24-bit RGB; the Store icon has its required transparent padding. No Store listing was created. + +The root test run recorded 588 passing tests and one failing test module: the unrelated GitHub App D1 test could not import the unavailable offline `miniflare` dependency. This is not a completely green repository suite. Its test was not removed, disabled or replaced. + +The installed npm-consumer check reached ordinary offline installation and failed with `ENOTCACHED` for registry metadata for `ajv`. This is an unexecuted consumer gate, not a successful installation. No network install or publication was claimed. + +## What browser evidence proves + +The rendered checks execute production rendering, settings and lifecycle code in Chromium with declared test dependencies. They verify keyboard interactions, literal deep links, Basic/Advanced search, unsaved-editor preservation, lazy file headers, head changes, stale-result rejection, label-picker handoff, themes, responsive layout and bounded popovers. The acquisition checks exercise production source acquisition against authored HTTP responses. + +The container's managed Chromium policy blocks extension installation and ordinary browsing. The actual installed-extension runner records `blocked-by-managed-policy`; its installed tests did not run. Policy was not changed or bypassed. DOM/controller fixtures are not presented as installed MV3, native extension-storage, live authenticated private-GitHub, current production CORS, Windows or Chrome Web Store acceptance evidence. + +## Remaining qualification + +An unrestricted authorized browser must run the installed MV3/native-storage suite and the live journeys described in QA.md. That includes representative public and accessible private pull requests, the current GitHub DOM on all supported tabs, real lazy rendering and a real head refresh, keyboard/zoom/theme behavior, and the native label-picker handoff with visible readback. The authored CI job requires the installed suite rather than accepting a managed-policy skip as success. + +Trusted App-report delivery is not implemented because there is no accepted authenticated transport for this browser client. Its identity/standing interface and explicit preferences are preserved; local analysis remains functional and never invents App approval. Managed-App label writes and Firefox remain separate work. + +The compact default is the accepted shared centre-seam `diffdevil/brand` glyph, pinned to `Wolfsblvt/wolfsblvt-icons@8596b6bc2ba7c4b950963b7ec26cee1a26974439` and verified by SHA-256 during build. The full-colour option uses the accepted theme-specific micro symbol. + +## Custody and reproduction + +The delivery manifest records the exact local source commit/tree and any actual remote branch/PR state. A created remote branch is not proof of a transferred source tree. Source ZIP, patch, font-free unpacked build, Store pack and evidence pack each have independent SHA-256 digests. No font files, browser profile, credentials, development dependency installation or downloaded toolchain archive are part of that handoff. The full source snapshot retains the repository’s already-tracked install-free Action dependency closure. + +Run the commands in README.md and QA.md against the exact source. The existing root Verify workflow remains intact. The new read-only browser job builds and tests the extension, requires actual installed MV3 verification, creates review assets and uploads evidence. It does not write repository source or publish anything. diff --git a/apps/browser-extension/README.md b/apps/browser-extension/README.md new file mode 100644 index 00000000..2a40c530 --- /dev/null +++ b/apps/browser-extension/README.md @@ -0,0 +1,79 @@ +# diffdevil for GitHub + +A Chrome Manifest V3 application that puts replacement-aware **Changed** counts beside GitHub's pull-request and file statistics. Analysis uses the same diffdevil parser, measurements, policy compiler and report contracts as the reusable engine. It does not run a separate approximation in a DOM scraper. + +This is an authored source candidate, not a published Store extension. The implemented browser UI, settings, data management, provider acquisition, build, fixtures and Store-preparation pipeline are included. Installed Chrome, live GitHub and Windows acceptance are distinct from the deterministic browser evidence. See [QA](QA.md) and the generated receipts. + +## Build and load + +From the repository root, with Node 22.12 or newer: + +```sh +npm ci --ignore-scripts +npm run extension:build +``` + +The result is `artifacts/browser-extension/unpacked`. In an unrestricted Chrome profile, open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select that directory. Open or reload a GitHub pull-request page. The toolbar action opens the full settings application. An organization's managed browser policy may prevent installation; do not bypass it. + +A build without bundled font files uses the normal system-font fallbacks: + +```sh +npm run build +npm run extension:check +node tools/browser-extension-build.mjs --without-fonts +``` + +The build is local. It does not publish npm, deploy a service, open a Store listing or upload source. Root `npm run build` also produces a self-contained MIT browser export at `dist/browser/index.js`. + +## What appears on a pull request + +**Changed** counts modified line positions once and keeps added-only, deleted-only and modified components separate. **Raw** remains GitHub-style additions and deletions. The virtual band pill and rail describe your effective policy, not an applied GitHub label or an App approval. File classification evaluates that file's actual facts; it does not copy the pull-request tier. + +The aggregate follows the complete PR across Conversation, Commits, Checks and Files changed. Per-file augmentation is confined to the full-PR Files changed comparison: commit-only and explicitly narrowed comparisons must not inherit whole-PR file measurements. Newly rendered modern and legacy file headers are attached lazily. Unacquired file paths retain their native presentation rather than receiving invented facts. + +Click Changed to inspect the non-modal report. Click again, press Escape, click outside or use Close to dismiss it. The report restores focus where appropriate, stays within the viewport and closes when its anchor is replaced. It includes revisions, evidence, files, metrics, bands, scopes, rules, policy provenance and report identities. + +Unknown, bounded and unmeasurable values remain explicit. Missing provider material, binary changes, an unavailable policy and a moving head do not become an exact zero or a convenient size tier. + +## Your settings + +The options application uses the shared diffdevil surface tokens, accepted wordmarks and both authored themes. Every setting has its own human name and a literal visible deep link, for example `#display.brandIcon` or `#policy.advancedYaml`. Search covers Advanced settings without changing the selected Basic view. A deep link reveals and focuses its target without permanently switching the view. Basic shows the number of active advanced changes. + +Unsaved YAML, path, band and repository-editor values survive searching, switching Basic/Advanced and changing theme. Saving uses the real schema/compiler. A rejected edit does not overwrite the saved policy. Reset is per-setting; destructive whole-data and override operations require confirmation. + +The default icon is the accepted quiet monochrome **centre-seam** `diffdevil/brand` glyph from `Wolfsblvt/wolfsblvt-icons@8596b6bc2ba7c4b950963b7ec26cee1a26974439` (MIT, SHA-256 `d33cf3b1a7a8ec52706da3311ac82e0500ec6c2eee8e35baeaaa2d5ef2b9b34e`). It is copied unchanged into the package and verified during build. Full colour uses the accepted micro symbol. None renders no icon slot. + +## Policy and labels + +Composed mode uses personal defaults, then the trusted repository `.diffdevil.yml`, then an explicit personal repository override. Repository mode uses repository policy when present and personal defaults when absent. Personal only deliberately excludes repository policy and can retain an explicit per-repository personal override. + +Repository policy and explicit templates are read from the exact base SHA, never the proposed PR head. A private or inaccessible-base 404 does not prove policy absence. Invalid or unavailable repository policy is visible and is not silently replaced with personal classification. The factual report remains usable. [Architecture](ARCHITECTURE.md) explains declaration replacement, preset origins and cache identities. + +Guided bands can have custom names, thresholds, colours and optional existing-label mappings. Advanced YAML is the full diffdevil policy dialect, not an extension subset. A nonempty advanced document replaces guided personal policy, while preserving those guided preferences for later reuse. It does not authorize a provider write. + +Where GitHub exposes its native writable label picker, an explicit handoff opens it and filters the mapped label. The user makes GitHub's actual selection. The extension does not select, create or reconcile labels, issue a hidden POST, remove another label or claim success before observing the native DOM change. + +## Local and App standing + +The extension works without the hosted App. The portable identity classifier distinguishes matching, policy-mismatched, stale and incompatible App report identities, but this candidate has no authenticated report-delivery integration with the hosted service. It therefore presents **local**, never an invented matching checkmark. App preferences preserve explicit user choices for that future verified delivery boundary and are described as inactive until such a source exists. No hosted analysis, quota-consuming operation or report endpoint is guessed. + +## Data and permissions + +The manifest requests storage, the `github.com` content-script scope needed for client-side PR navigation, and `api.github.com` for a public read-only fallback. There is no PAT, account, cookie permission, browser-history API, localhost daemon, all-sites host permission, telemetry or source-upload backend. Incognito is deliberately unsupported to avoid mixing its observations into a normal profile's persistent cache. + +Raw diffs are processed in memory and discarded. Normalized reports can contain private paths and revision identities; trusted policy text and immutable missing-file results are rebuildable cache data. The LRU budget is bounded. Small settings can synchronize through Chrome; advanced YAML and repository overrides are local. A support snapshot is redacted; a deliberate full settings export is not. Read [Privacy](PRIVACY.md) before using private repositories. + +## Verification and distribution + +```sh +npm run extension:test +npm run extension:qa +npm run extension:qa:installed -- --require +npm run extension:store +``` + +These commands follow a built candidate. The DOM/acquisition runs need Playwright Chromium (`npx playwright install chromium`) or an explicitly configured `CHROMIUM_EXECUTABLE`. Installed QA requires a browser/profile that permits extensions and records a distinct blocked state when managed policy forbids them. A blocked probe is not a passing installed test. + +The Store generator produces a 128px icon, five 1280×800 RGB screenshots, 440×280 and 1400×560 RGB promotional images, editable SVG compositions and a hash manifest. Captures retain the authored-fixture/source-candidate disclosure. Listing copy, privacy answers and a submission checklist live under [store](store/README.md). No public Store link is invented. + +The application is AGPL-3.0-only, reusable core/browser code is MIT, original prose is CC BY 4.0, and accepted visual identity remains reserved. [Licences](LICENSES.md) and the root component map define the boundaries. diff --git a/apps/browser-extension/SECURITY.md b/apps/browser-extension/SECURITY.md new file mode 100644 index 00000000..2160dbce --- /dev/null +++ b/apps/browser-extension/SECURITY.md @@ -0,0 +1,31 @@ +# Extension security boundary + +## Meaning + +The extension is read-only toward GitHub except for handing the user to GitHub's existing visible controls. It analyzes potentially adversarial repository text. This document records implemented defenses and unresolved verification, not a security certification. + +## Threats and controls + +A PR may contain executable-looking strings, misleading counters, pathological patches, invalid policy, binary files, moving refs and hostile paths. Inputs are size-bounded, parsed by the shared core, validated and carried as data. DOM rendering uses text nodes. A schema-valid record is not enough to establish exact evidence; independent provider completeness and counter checks remain necessary. + +Repository policy is the exact trusted base document. Proposed head changes cannot authorize their own policy or template behavior. Template paths reject absolute paths, parent traversal, backslashes, control characters and malformed segments. Public policy lookup rejects directories, symlinks and unsupported encoding. Template content is bounded and interpreted by the core template language, not JavaScript. + +Worker messages must come from this extension. Settings writes, imports, diagnostics and destructive actions require the exact options page. GitHub source operations require a top-level supported PR page and are restricted to that repository and PR. File-view contexts have the same scope. Public network requests construct URLs from validated fields and only use GitHub's API origin, omit credentials and reject redirects. The worker is not an arbitrary fetch proxy. No external messaging interface, PAT, localhost control channel or cookie-reading permission is provided. + +Executable resources ship locally. The extension-page CSP excludes runtime string compilation and remote scripts. Schema validators are precompiled; the public browser export has no external executable imports. The content script does not include the compiler, reducing its page-lifecycle footprint. Native browser/storage APIs still require installed-profile verification; an authored transport test does not establish Chrome's own enforcement. + +## Private data + +Raw diffs are transient. Rebuildable numeric reports can still expose file paths, repository identifiers, revisions and policy text. Browser-profile access is therefore a trust boundary. There is no independent at-rest encryption in this implementation, and the source does not claim that a Chrome profile is universally encrypted. Incognito is disabled rather than mixing its data into normal persistent storage. Store privacy/security review must account for these actual choices. + +Support snapshots omit repository/path identity, raw source and policy text. User-requested full settings exports and report copies deliberately retain their content and are labeled differently. No diagnostic error body or policy expression is persisted as telemetry. There is no developer-controlled collection endpoint. + +## Mutations and state claims + +The native label handoff does not mutate provider truth. It cannot create labels or run App analysis. A label is reported only as observed in GitHub's native DOM, not as an extension-applied write. Virtual pills and local provenance are explicitly different. An App checkmark must not be inferred from user-editable page text; the authenticated delivery boundary remains unimplemented rather than guessed. + +Cache is disposable. Epochs prevent late writes after a clear. Settings journals preserve recovery from partial local/synchronized writes; corrupt stored values cause an error rather than being silently reset. The memory, concurrency, transport and LRU limits in Architecture are intentional constraints, but they are not a browser-process denial-of-service audit. + +## Disclosure and acceptance + +Use the repository's private security reporting channel when available; do not include private source or credentials in a public issue. Live authenticated GitHub, enterprise policy enforcement, installed worker restarts, native IndexedDB quotas and Windows operation must be accepted against the exact release before publication. The supplied installed runner respects managed policy and records a blocked result instead of disabling it. diff --git a/apps/browser-extension/assets/diffdevil-brand.svg b/apps/browser-extension/assets/diffdevil-brand.svg new file mode 100644 index 00000000..e80b2ad8 --- /dev/null +++ b/apps/browser-extension/assets/diffdevil-brand.svg @@ -0,0 +1,15 @@ + + + + + + + diff --git a/apps/browser-extension/manifest.json b/apps/browser-extension/manifest.json new file mode 100644 index 00000000..4e069b69 --- /dev/null +++ b/apps/browser-extension/manifest.json @@ -0,0 +1,61 @@ +{ + "manifest_version": 3, + "name": "diffdevil for GitHub", + "version": "1.0.0", + "version_name": "1.0.0 unpublished source candidate", + "description": "See replacement-aware Changed lines, personal size bands and inspectable diff reports directly in GitHub pull requests.", + "minimum_chrome_version": "120", + "incognito": "not_allowed", + "permissions": [ + "storage" + ], + "host_permissions": [ + "https://api.github.com/*" + ], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": [ + "https://github.com/*" + ], + "js": [ + "content.js" + ], + "css": [ + "content.css" + ], + "run_at": "document_idle" + } + ], + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + "action": { + "default_title": "diffdevil settings" + }, + "icons": { + "16": "assets/icon-16.png", + "32": "assets/icon-32.png", + "48": "assets/icon-48.png", + "128": "assets/icon-128.png" + }, + "web_accessible_resources": [ + { + "resources": [ + "assets/diffdevil-brand.svg", + "assets/diffdevil-symbol-micro-dark.svg", + "assets/diffdevil-symbol-micro-light.svg" + ], + "matches": [ + "https://github.com/*" + ] + } + ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'none'; base-uri 'none'; connect-src https://api.github.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'" + } +} diff --git a/apps/browser-extension/options.html b/apps/browser-extension/options.html new file mode 100644 index 00000000..d9e99b41 --- /dev/null +++ b/apps/browser-extension/options.html @@ -0,0 +1,27 @@ + + + + + + Settings · diffdevil for GitHub + + + +
+ diffdevil + Settings +
+
+

#your-view

Your view of the diff.

Clearer measurements. Your policy. No source upload.

Local analysis · GitHub only
+
+ +
+
+

Loading your settings…

+

#extension-details

Extension details

+

Keep your configuration portable.

Export settings for another browser, or use the same policy with the CLI and Actions. Exports can include personal repository names and policy text.

+
+ + + + diff --git a/apps/browser-extension/qa/acquisition.mjs b/apps/browser-extension/qa/acquisition.mjs new file mode 100644 index 00000000..4fc1250f --- /dev/null +++ b/apps/browser-extension/qa/acquisition.mjs @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import assert from 'node:assert/strict'; +import { chromium } from 'playwright-core'; +import { build } from 'esbuild'; +import { mkdir, readFile, writeFile, access } from 'node:fs/promises'; +import { resolve, join } from 'node:path'; +import { githubHtml, comparison, diff } from './fixtures.mjs'; +const out = resolve('artifacts/browser-extension/qa'); await mkdir(out, { recursive: true }); +await build({ entryPoints: ['apps/browser-extension/qa/dom-entry.ts'], outfile: join(out, 'acquisition-test-entry.js'), bundle: true, platform: 'browser', format: 'iife', globalName: 'ExtensionQA', target: 'chrome120', alias: { '@wolfsblvt/diffdevil/browser/text': resolve('dist/lib/browser/text.js'), '@wolfsblvt/diffdevil/browser': resolve('dist/browser/index.js') } }); +let executablePath = process.env.CHROMIUM_EXECUTABLE; if (!executablePath) try { await access('/usr/bin/chromium'); executablePath = '/usr/bin/chromium'; } catch {} +const browser = await chromium.launch({ ...(executablePath ? { executablePath } : {}), headless: true, args: ['--no-sandbox', '--disable-gpu'] }); +const receipt = { kind: 'diffdevil.acquisition-qa/1', browser: browser.version(), qualification: 'Production signed-in acquisition adapter executed in a browser with authored provider responses and explicit worker transport. Not live GitHub, account access, network/CORS or installed-extension evidence.', checks: [] }; +async function run(options = {}) { + const page = await browser.newPage(); + try { + await page.setContent(githubHtml({ data: options.empty ? { ...comparison, changedFiles: 0, additions: 0, deletions: 0 } : comparison })); + await page.evaluate(({ options, comparison, diff, afterHtml }) => { + window.transport = []; window.analysisInputs = []; window.fetches = []; + const source = options.empty ? { ...comparison, changedFiles: 0, additions: 0, deletions: 0 } : comparison; + window.chrome = { runtime: { sendMessage: async message => { + window.transport.push(message.type); + if (message.type === 'cache.lookup') return { ok: true, value: { settings: {}, reportCached: Boolean(options.cached), selected: { mode: options.personalOnly ? 'personal-only' : 'composed', personal: 'version: 1\n' }, ...(options.cachedPolicy ? { policy: { status: 'absent', at: 1 } } : {}) } }; + if (message.type === 'policy.templates') return { ok: true, value: options.template ? [options.template] : [] }; + if (message.type === 'source.public') return { ok: false, code: 'PUBLIC_UNAVAILABLE', message: 'Synthetic private comparison has no public API route.' }; + if (message.type === 'analysis.run') { window.analysisInputs.push(message.input); return { ok: true, value: { key: 'acquisition-test', comparison: source, view: null, files: [], refreshedAt: 1, cached: Boolean(options.cached) } }; } + return { ok: false, code: 'UNEXPECTED_MESSAGE', message: message.type }; + } } }; + window.fetch = async (path, init) => { + window.fetches.push({ path, credentials: init.credentials, method: init.method ?? 'GET' }); + if (init.signal.aborted) throw init.signal.reason; + let body = ''; let status = 200; let type = 'text/html'; let url = `https://github.com${path}`; + if (path.endsWith('.diff')) { + body = options.empty ? '' : diff; type = 'text/plain'; + if (options.htmlDiff) { body = 'Sign in'; type = 'text/html'; url = 'https://github.com/login'; } + if (options.oversize) body = 'x'.repeat(8 * 1024 * 1024 + 1); + } else if (path.includes('/blob/')) { + if (options.missingPolicy && path.endsWith('.diffdevil.yml')) status = 404; + else body = ``; + } else if (path.includes('/commit/')) status = options.inaccessibleBase ? 404 : 200; + else body = afterHtml; + const response = new Response(body, { status, headers: { 'content-type': type } }); Object.defineProperty(response, 'url', { value: url }); return response; + }; + window.controller = new AbortController(); if (options.aborted) window.controller.abort(new Error('Cancelled fixture')); + }, { options, comparison, diff, afterHtml: githubHtml({ data: options.moved ? { ...comparison, head: 'c'.repeat(40) } : options.empty ? { ...comparison, changedFiles: 0, additions: 0, deletions: 0 } : comparison }) }); + await page.addScriptTag({ path: join(out, 'acquisition-test-entry.js') }); + return await page.evaluate(async () => { + let result; let error; + try { result = await ExtensionQA.acquire(ExtensionQA.route('https://github.com/example/cinder/pull/42/files'), document, window.controller.signal); } catch (exception) { error = { code: exception.code, message: exception.message }; } + return { ok: Boolean(result), error, inputs: window.analysisInputs, requests: window.fetches, messages: window.transport }; + }); + } finally { await page.close(); } +} +async function check(name, action) { try { await action(); receipt.checks.push({ name, passed: true }); } catch (error) { receipt.checks.push({ name, passed: false, error: String(error.stack ?? error) }); throw error; } } +try { + await check('Signed-in diff, trusted-base policy and after-acquisition identity reach the real boundary', async () => { + const result = await run(); assert.equal(result.ok, true); assert.equal(result.inputs[0].acquisition.text, diff); assert.equal(result.inputs[0].policy.text, 'version: 1'); assert.equal(result.requests.length, 3); assert.ok(result.requests.every(request => request.method === 'GET' && request.credentials === 'same-origin')); assert.ok(result.requests.some(request => request.path.includes(`/blob/${comparison.base}/.diffdevil.yml`))); assert.ok(!result.requests.some(request => request.path.includes(`/blob/${comparison.head}/`))); + }); + await check('Force-push during acquisition prevents analysis from being submitted', async () => { const result = await run({ moved: true }); assert.equal(result.error.code, 'COMPARISON_MOVED'); assert.equal(result.inputs.length, 0); }); + await check('Cache hit still rechecks the comparison but does not reacquire raw source', async () => { const result = await run({ cached: true, cachedPolicy: true }); assert.equal(result.ok, true); assert.equal(result.requests.length, 1); assert.equal(result.inputs[0].acquisition, undefined); }); + await check('A missing policy is absent only after independent exact-base access proof', async () => { const result = await run({ missingPolicy: true }); assert.equal(result.inputs[0].policy.status, 'absent'); assert.ok(result.requests.some(request => request.path.endsWith(`/commit/${comparison.base}`))); }); + await check('A private/inaccessible-base 404 is unavailable, not an absent policy', async () => { const result = await run({ missingPolicy: true, inaccessibleBase: true }); assert.equal(result.inputs[0].policy.status, 'unavailable'); }); + await check('Truncated blob payload is never accepted as a complete policy', async () => { const result = await run({ truncatedPolicy: true }); assert.equal(result.inputs[0].policy.status, 'unavailable'); }); + await check('Personal-only explicitly skips repository-policy acquisition', async () => { const result = await run({ personalOnly: true }); assert.equal(result.ok, true); assert.ok(!result.requests.some(request => request.path.includes('/blob/'))); }); + await check('Explicit templates are read at the same immutable trusted base', async () => { const result = await run({ template: '.github/report.md' }); assert.equal(result.inputs[0].templates['.github/report.md'], 'Changed: {{ totals.lines.changed }}'); assert.ok(result.requests.some(request => request.path.includes(`/blob/${comparison.base}/.github/report.md`))); }); + await check('Template traversal never becomes a provider request', async () => { const result = await run({ template: '../credentials' }); assert.equal(Object.keys(result.inputs[0].templates).length, 0); assert.ok(!result.requests.some(request => request.path.includes('credentials'))); }); + await check('HTML authentication response cannot be parsed as a zero-line diff', async () => { const result = await run({ htmlDiff: true }); assert.equal(result.error.code, 'PUBLIC_UNAVAILABLE'); assert.equal(result.inputs.length, 0); }); + await check('Oversized diff is stopped at the acquisition limit rather than persisted', async () => { const result = await run({ oversize: true }); assert.equal(result.inputs.length, 0); assert.equal(result.error.code, 'PUBLIC_UNAVAILABLE'); }); + await check('Cancellation prevents a report submission', async () => { const result = await run({ aborted: true }); assert.equal(result.inputs.length, 0); assert.equal(result.ok, false); }); + await check('Independently observed empty comparison forwards an empty diff, not an error page', async () => { const result = await run({ empty: true }); assert.equal(result.ok, true); assert.equal(result.inputs[0].comparison.changedFiles, 0); assert.equal(result.inputs[0].acquisition.text, ''); }); +} catch { process.exitCode = 1; } +finally { await browser.close(); receipt.passed = receipt.checks.filter(check => check.passed).length; receipt.failed = receipt.checks.length - receipt.passed; await writeFile(join(out, 'acquisition-receipt.json'), JSON.stringify(receipt, null, 2) + '\n'); console.log(JSON.stringify(receipt, null, 2)); } diff --git a/apps/browser-extension/qa/dom-entry.ts b/apps/browser-extension/qa/dom-entry.ts new file mode 100644 index 00000000..41c395e8 --- /dev/null +++ b/apps/browser-extension/qa/dom-entry.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Test exports of production code, not a separate demonstration implementation. +export { startContent } from '../src/content/controller.js'; +export { acquire } from '../src/content/acquire.js'; +export { Popover } from '../src/content/popover.js'; +export { projection, reportPanel } from '../src/content/render.js'; +export { route, pageComparison, filePath, blobText, fullFilesView } from '../src/content/github.js'; +export { labelHandoff, observedLabels } from '../src/content/labels.js'; +export { productIcon } from '../src/shared/icons.js'; diff --git a/apps/browser-extension/qa/fixtures.mjs b/apps/browser-extension/qa/fixtures.mjs new file mode 100644 index 00000000..d6369a77 --- /dev/null +++ b/apps/browser-extension/qa/fixtures.mjs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Authored fixtures, not captures of GitHub/private source. Counts use the real engine. +export const comparison = { host: 'github.com', repository: 'example/cinder', pullRequest: 42, base: 'a'.repeat(40), head: 'b'.repeat(40), changedFiles: 2, additions: 160, deletions: 118 }; +export function filePatch(path, modified, added, deleted) { + return `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1,${modified + deleted + 2} +1,${modified + added + 2} @@\n` + + Array.from({ length: modified }, (_, i) => `-old_${i}\n`).join('') + + Array.from({ length: modified }, (_, i) => `+new_${i}\n`).join('') + + ' unchanged_separator\n' + Array.from({ length: added }, (_, i) => `+added_${i}\n`).join('') + + ' another_separator\n' + Array.from({ length: deleted }, (_, i) => `-removed_${i}\n`).join(''); +} +export const diff = filePatch('src/cache.ts', 20, 10, 2) + filePatch('src/renderer.ts', 80, 50, 16); +export const fileHeader = (path, modern = false) => `
${path}+${path === 'src/cache.ts' ? 30 : 130} −${path === 'src/cache.ts' ? 22 : 96}
  export function ${path === 'src/cache.ts' ? 'readCache' : 'renderReport'}(input) {\n−   return previousBehavior(input);\n+   return currentBehavior(input);\n  }
`; +export function githubHtml({ dark = true, modern = false, files = true, data = comparison } = {}) { + return `
GitHubexample / cinderSearch or jump to…

Make the report cache predictable #42

Open example wants to merge 3 commits into main from cache-lifecycle

+160 −118
Changes from all commitsFile filter ▾ Conversations ▾ Review changes
${files ? fileHeader('src/cache.ts', modern) + fileHeader('src/renderer.ts', modern) : ''}
Authored GitHub-style fixture · Local source candidate · Not a live repository capture
`; +} +export const githubCss = ` +:root{--fgColor-default:#1f2328;--fgColor-muted:#59636e;--fgColor-success:#1a7f37;--fgColor-danger:#cf222e;--fgColor-attention:#9a6700;--fgColor-accent:#0969da;--bgColor-default:#fff;--bgColor-muted:#f6f8fa;--borderColor-default:#d1d9e0;--focus-outlineColor:#0969da;color-scheme:light} +:root[data-color-mode=dark]{--fgColor-default:#f0f6fc;--fgColor-muted:#9198a1;--fgColor-success:#3fb950;--fgColor-danger:#f85149;--fgColor-attention:#d29922;--fgColor-accent:#4493f8;--bgColor-default:#0d1117;--bgColor-muted:#151b23;--borderColor-default:#3d444d;--focus-outlineColor:#1f6feb;color-scheme:dark} +*{box-sizing:border-box}body{margin:0;background:var(--bgColor-default);color:var(--fgColor-default);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input{font:inherit}.fixture-top{display:flex;align-items:center;gap:28px;padding:18px 32px;border-bottom:1px solid var(--borderColor-default);background:var(--bgColor-muted);height:70px}.fixture-top>strong{font-size:20px}.fixture-search{margin-left:auto;color:var(--fgColor-muted);border:1px solid var(--borderColor-default);border-radius:6px;padding:5px 14px;min-width:290px;font-size:12px}main{max-width:1180px;margin:0 auto;padding:28px 28px 48px}.fixture-pr h1{font-size:26px;font-weight:400;line-height:1.3;margin:0 0 12px}.fixture-pr h1>span{color:var(--fgColor-muted)}.fixture-pr-meta{color:var(--fgColor-muted);margin:0 0 14px;font-size:13px}.fixture-open{display:inline-block;padding:4px 12px;margin-right:8px;border-radius:20px;background:#238636;color:white}.fixture-pr-meta code{font-size:12px;background:var(--bgColor-muted);padding:2px 5px;border-radius:4px}.gh-header-meta{display:inline-flex;align-items:center}.fixture-tabs{display:flex;gap:26px;border-bottom:1px solid var(--borderColor-default);margin:18px 0 16px;font-size:13px;overflow:auto;white-space:nowrap}.fixture-tabs a{color:inherit;text-decoration:none;padding:10px 0}.fixture-tabs .current{border-bottom:2px solid #f78166;font-weight:600}.Counter{border-radius:12px;background:var(--bgColor-muted);padding:1px 6px;color:var(--fgColor-muted);font-size:11px}.fixture-toolbar{display:flex;justify-content:space-between;gap:16px;color:var(--fgColor-muted);font-size:12px;margin-bottom:14px}.fixture-file{border:1px solid var(--borderColor-default);border-radius:6px;overflow:hidden;margin:18px 0}.file-header,[data-testid=file-header]{background:var(--bgColor-muted);padding:9px 14px;border-bottom:1px solid var(--borderColor-default);display:flex;align-items:center;flex-wrap:wrap;gap:8px}.fixture-file-name{font:12px/1.4 ui-monospace,Consolas,monospace;min-width:155px}.fixture-file .diffstat{font-size:11px}.diffstat{white-space:nowrap;font-size:12px}.color-fg-success{color:var(--fgColor-success)}.color-fg-danger{color:var(--fgColor-danger)}.diffstat-block-added,.diffstat-block-deleted{display:inline-block;width:8px;height:8px;margin-left:2px;background:var(--fgColor-success)}.diffstat-block-deleted{background:var(--fgColor-danger)}.fixture-code{font:12px/1.8 ui-monospace,Consolas,monospace;margin:0;padding:10px 0;background:var(--bgColor-default);overflow:auto}.fixture-code span{display:inline-block;width:100%;padding:0 18px}.line-removed{background:color-mix(in srgb,var(--fgColor-danger) 12%,transparent)}.line-added{background:color-mix(in srgb,var(--fgColor-success) 12%,transparent)}.sidebar-labels{font-size:12px;color:var(--fgColor-muted);margin-top:20px}.sidebar-labels h3{font-size:12px;margin:0 0 6px}.sidebar-labels summary{cursor:pointer;font-size:11px}.sidebar-labels input{padding:6px;background:var(--bgColor-default);border:1px solid var(--borderColor-default);color:inherit;margin:8px 0}.IssueLabel{display:inline-block;border:1px solid var(--borderColor-default);border-radius:20px;padding:1px 8px;margin-top:8px}.fixture-disclaimer{position:fixed;bottom:0;left:0;right:0;text-align:center;color:var(--fgColor-muted);font-size:10px;background:var(--bgColor-default);padding:5px;border-top:1px solid var(--borderColor-default)}@media(max-width:600px){main{padding:18px 12px}.fixture-search{display:none}.fixture-top{padding:14px 16px}.fixture-top>strong{font-size:15px}.fixture-pr h1{font-size:21px}.fixture-toolbar{font-size:10px}.fixture-tabs{gap:16px}.fixture-file-name{min-width:100%;}.fixture-code{font-size:10px}.fixture-pr-meta{font-size:11px}} +`; diff --git a/apps/browser-extension/qa/installed.mjs b/apps/browser-extension/qa/installed.mjs new file mode 100644 index 00000000..e48a1bbc --- /dev/null +++ b/apps/browser-extension/qa/installed.mjs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import assert from 'node:assert/strict'; +import { chromium } from 'playwright-core'; +import { readFile, writeFile, mkdir, mkdtemp, rm, readdir } from 'node:fs/promises'; +import { resolve, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createHash } from 'node:crypto'; +import { diff as patch, comparison } from './fixtures.mjs'; +const output = resolve('artifacts/browser-extension/qa'); await mkdir(output, { recursive: true }); +const receipt = { kind: 'diffdevil.installed-qa/1', status: 'not-run', checks: [], policyEvidence: [], errors: [], qualification: 'Installed MV3 worker, native Chrome storage and IndexedDB, using an explicitly synthetic report. No live/private GitHub or provider mutation claim.' }; +const requireInstalled = process.argv.includes('--require'); +let context; let profile; +async function persist() { await writeFile(join(output, 'installed-receipt.json'), JSON.stringify(receipt, null, 2) + '\n'); } +async function check(name, operation) { try { await operation(); receipt.checks.push({ name, passed: true }); } catch (error) { receipt.checks.push({ name, passed: false, error: String(error.stack ?? error) }); throw error; } } +// Inspect only known policy directories. Never change policy, hide it from Chrome, +// switch to another unmanaged installation to evade it, or invent an allowlist. +for (const directory of ['/etc/chromium/policies/managed', '/etc/opt/chrome/policies/managed']) { + let names; try { names = await readdir(directory); } catch { continue; } + for (const name of names.filter(name => name.endsWith('.json'))) { + const content = await readFile(join(directory, name), 'utf8'); let policy; try { policy = JSON.parse(content); } catch { continue; } + if (policy.ExtensionInstallBlocklist?.includes('*') || policy.ExtensionInstallBlacklist?.includes('*') || policy.ExtensionSettings?.['*']?.installation_mode === 'blocked') receipt.policyEvidence.push({ file: join(directory, name), sha256: createHash('sha256').update(content).digest('hex'), extensionInstallationBlocked: true }); + } +} +if (receipt.policyEvidence.length) { + receipt.status = 'blocked-by-managed-policy'; await persist(); console.log(JSON.stringify(receipt, null, 2)); + process.exitCode = requireInstalled ? 2 : 0; +} else { + const extension = resolve('artifacts/browser-extension/unpacked'); + const launch = () => chromium.launchPersistentContext(profile, { ...(process.env.CHROMIUM_EXECUTABLE ? { executablePath: process.env.CHROMIUM_EXECUTABLE } : { channel: 'chromium' }), headless: process.env.HEADED !== '1', viewport: { width: 1280, height: 800 }, ignoreDefaultArgs: ['--disable-extensions'], args: [`--disable-extensions-except=${extension}`, `--load-extension=${extension}`, ...(process.platform === 'linux' ? ['--no-sandbox'] : [])] }); + try { + profile = await mkdtemp(join(tmpdir(), 'diffdevil-installed-')); + context = await launch(); + const worker = context.serviceWorkers()[0] ?? await context.waitForEvent('serviceworker', { timeout: 20_000 }); + const base = worker.url().replace(/\/background\.js(?:[?#].*)?$/u, ''); const page = await context.newPage(); + page.on('pageerror', error => receipt.errors.push(error.message)); + await page.goto(`${base}/options.html`); await page.locator('.setting').first().waitFor(); + const call = async message => { + const response = await page.evaluate(message => chrome.runtime.sendMessage(message), message); + assert.equal(response?.ok, true, response?.message ?? 'Missing worker response'); return response.value; + }; + await check('Actual MV3 service worker and options page initialize under manifest CSP', async () => { + assert.ok(worker.url().endsWith('/background.js')); assert.equal(await page.title(), 'Settings · diffdevil for GitHub'); + const manifest = await page.evaluate(() => chrome.runtime.getManifest()); assert.equal(manifest.manifest_version, 3); assert.deepEqual(manifest.permissions, ['storage']); + assert.ok(!manifest.content_security_policy.extension_pages.includes('unsafe-eval')); + }); + await check('Native storage separates local policy from synchronized preferences', async () => { + await call({ type: 'settings.save', patch: { 'display.rawChurn': false, 'policy.advancedYaml': 'version: 1\n', 'policy.repositoryOverrides': '{"example/cinder":{"mode":"personal-only"}}' } }); + const data = await page.evaluate(async () => ({ sync: await chrome.storage.sync.get(null), local: await chrome.storage.local.get(null) })); + assert.ok(!JSON.stringify(data.sync).includes('example/cinder')); assert.ok(!JSON.stringify(data.sync).includes('policy.advancedYaml')); + assert.ok(JSON.stringify(data.local).includes('example/cinder')); + }); + let packet; + await check('Real worker executes the portable engine and writes normalized IndexedDB cache', async () => { + packet = await call({ type: 'analysis.run', input: { comparison, acquisition: { comparison, format: 'diff', text: patch, complete: true }, policy: { status: 'absent', at: Date.now() } } }); + assert.equal(packet.view.changed.value, 178); assert.equal(packet.view.raw.churn.value, 278); + const info = await call({ type: 'diagnostics.get' }); assert.equal(info.cache.reportEntries, 1); + const files = await call({ type: 'analysis.files', key: packet.key, paths: ['src/cache.ts', 'src/renderer.ts'] }); assert.equal(files['src/cache.ts'].changed.value, 32); + const entries = await page.evaluate(() => new Promise((resolve, reject) => { + const opening = indexedDB.open('diffdevil-rebuildable-v1', 1); + opening.onerror = () => reject(opening.error?.message); + opening.onsuccess = () => { const db = opening.result; const tx = db.transaction('entries'); const read = tx.objectStore('entries').getAll(); read.onsuccess = () => { resolve(read.result); db.close(); }; read.onerror = () => reject(read.error?.message); }; + })); + assert.ok(entries.length); assert.ok(!JSON.stringify(entries).includes('diff --git')); assert.ok(!JSON.stringify(entries).includes('old_0')); + }); + await check('User settings and numeric cache survive actual browser/worker restart', async () => { + await context.close(); context = await launch(); const restarted = await context.newPage(); restarted.on('pageerror', error => receipt.errors.push(error.message)); await restarted.goto(`${base}/options.html`); await restarted.locator('.setting').first().waitFor(); + const settings = await restarted.evaluate(() => chrome.runtime.sendMessage({ type: 'settings.get' })); assert.equal(settings.value['display.rawChurn'], false); + const cache = await restarted.evaluate(comparison => chrome.runtime.sendMessage({ type: 'cache.lookup', comparison }), comparison); assert.equal(cache.value.reportCached, true); + const files = await restarted.evaluate(key => chrome.runtime.sendMessage({ type: 'analysis.files', key, paths: ['src/cache.ts'] }), packet.key); assert.equal(files.ok, false); assert.equal(files.code, 'CONTEXT_EXPIRED'); + const again = await restarted.evaluate(comparison => chrome.runtime.sendMessage({ type: 'analysis.run', input: { comparison, policy: { status: 'absent', at: Date.now() } } }), comparison); assert.equal(again.ok, true); assert.equal(again.value.cached, true); assert.equal(again.value.view.changed.value, 178); + const refused = await restarted.evaluate(() => chrome.runtime.sendMessage({ type: 'data.action', action: 'data.resetAll' })); assert.equal(refused.code, 'CONFIRMATION_REQUIRED'); + const cleared = await restarted.evaluate(() => chrome.runtime.sendMessage({ type: 'data.action', action: 'data.clearAnalysisCache' })); assert.equal(cleared.ok, true); assert.equal(cleared.value.cache.reportEntries, 0); + const retained = await restarted.evaluate(() => chrome.runtime.sendMessage({ type: 'settings.get' })); assert.equal(retained.value['display.rawChurn'], false); + const reset = await restarted.evaluate(() => chrome.runtime.sendMessage({ type: 'data.action', action: 'data.resetAll', confirmed: true })); assert.equal(reset.ok, true); assert.equal(reset.value.cache.entries, 0); + const defaults = await restarted.evaluate(() => chrome.runtime.sendMessage({ type: 'settings.get' })); assert.equal(defaults.value['display.rawChurn'], true); + }); + await check('No uncaught extension page error was observed', async () => assert.deepEqual(receipt.errors, [])); + receipt.status = 'passed'; + } catch (error) { receipt.status = 'failed'; receipt.errors.push(String(error.stack ?? error)); process.exitCode = 1; } + finally { await context?.close(); if (profile) await rm(profile, { recursive: true, force: true }); await persist(); console.log(JSON.stringify(receipt, null, 2)); } +} diff --git a/apps/browser-extension/qa/run.mjs b/apps/browser-extension/qa/run.mjs new file mode 100644 index 00000000..7f598b15 --- /dev/null +++ b/apps/browser-extension/qa/run.mjs @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Real production DOM/controllers + a declared deterministic transport/storage harness. + * Does NOT claim installed MV3, private GitHub, native permissions or live selectors. + */ +import assert from 'node:assert/strict'; +import { chromium } from 'playwright-core'; +import { build } from 'esbuild'; +import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { createHash } from 'node:crypto'; +import { productionModules, memoryAreas, unwrap } from '../tests/support.mjs'; +import { comparison, diff, filePatch, fileHeader, githubHtml, githubCss } from './fixtures.mjs'; +const out = resolve('artifacts/browser-extension/qa'); await mkdir(out, { recursive: true }); +const loaded = await productionModules(); const m = loaded.module; +const areas = memoryAreas(); const preferences = new m.Preferences(areas); let settings = await preferences.load(); +const contexts = new Map(); let fileCalls = 0; let failFiles = false; let settingsWrites = 0; +const measurements = []; +const checks = []; const errors = []; const network = []; const screenshots = []; +const timed = (name, action) => { const start = performance.now(); const value = action(); measurements.push({ name, milliseconds: performance.now() - start }); return value; }; +function packet(data = comparison, text = diff, input) { + const report = timed('parse-and-analyze', () => unwrap(m.analyzeBrowserInput(JSON.stringify(input ?? { comparison: data, format: 'diff', text, complete: true })))); + const policy = timed('compile-personal-policy', () => unwrap(m.compileBrowserPolicy(JSON.stringify({ mode: 'composed', personal: m.personalYaml(settings) })))); + const view = timed('project-aggregate', () => m.decorateView(unwrap(m.humanReport(report, policy)), settings)); + const key = `${m.comparisonKey(data)}:${policy.digest}`; + const result = { key, comparison: data, view, files: report.files.map(file => ({ id: file.id, path: file.path, ...(file.oldPath ? { oldPath: file.oldPath } : {}) })), refreshedAt: Date.UTC(2026, 8, 19, 12), cached: false }; + contexts.set(key, { report, policy, packet: result }); return result; +} +let currentPacket = packet(); +const totals = () => { const bytes = [...contexts.values()].reduce((sum, item) => sum + Buffer.byteLength(JSON.stringify(item.report)), 0); return { entries: contexts.size, bytes, reportEntries: contexts.size, reportBytes: bytes, policyEntries: 0, policyBytes: 0, maximumBytes: Number(settings['cache.maximumSize']) * 1024 * 1024 }; }; +async function diagnostics() { return { version: '1.0.0 fixture', engine: '1.0.0', schema: '1.0', presenter: m.HUMAN_VIEW_VERSION, measurement: m.SEMANTICS.replacementLines, cache: totals(), syncBytes: await areas.sync.getBytesInUse(), localBytes: await areas.local.getBytesInUse(), errors: [], last: { ...currentPacket.comparison, at: currentPacket.refreshedAt } }; } +async function rpc(message) { + try { + let value; + switch (message.type) { + case 'settings.get': value = await preferences.load(); break; + case 'settings.save': settings = value = await preferences.save(message.patch, Boolean(message.replace)); settingsWrites++; break; + case 'analysis.files': { + fileCalls++; if (failFiles) throw Object.assign(new Error('Fixture file transport unavailable.'), { code: 'FILE_FIXTURE_FAILURE' }); + const context = contexts.get(message.key); if (!context) throw Object.assign(new Error('Context expired'), { code: 'CONTEXT_EXPIRED' }); + value = Object.fromEntries(message.paths.filter(path => context.report.files.some(file => file.path === path || file.oldPath === path)).map(path => [path, m.decorateView(unwrap(m.humanReport(context.report, context.policy, path)), settings)])); break; + } + case 'diagnostics.get': value = await diagnostics(); break; + case 'data.action': + if (message.action === 'diagnostics.copySupportSnapshot') value = { version: '1.0.0', cache: totals(), sourcePresent: true, policy: { redacted: true } }; + else { if (message.action === 'data.resetAll') await preferences.reset(); if (message.action === 'data.clearRepositoryOverrides') await preferences.save({ 'policy.repositoryOverrides': '{}' }); settings = await preferences.load(); value = await diagnostics(); } + break; + default: throw new Error(`Unexpected fixture message: ${message.type}`); + } + return { ok: true, value }; + } catch (error) { return { ok: false, code: error.code ?? 'TEST_OPERATION_FAILED', message: error.message }; } +} +const bundled = async (file, globalName) => (await build({ entryPoints: [file], write: false, bundle: true, platform: 'browser', format: 'iife', target: 'chrome120', globalName, alias: { '@wolfsblvt/diffdevil/browser/text': resolve('dist/lib/browser/text.js'), '@wolfsblvt/diffdevil/browser': resolve('dist/browser/index.js') } })).outputFiles[0].text; +const optionsCode = await bundled('apps/browser-extension/src/options/main.ts', 'OptionsUnderTest'); +const domCode = await bundled('apps/browser-extension/qa/dom-entry.ts', 'DiffdevilUnderTest'); +const optionsCss = await readFile('artifacts/browser-extension/unpacked/options.css', 'utf8'); +const contentCss = await readFile('apps/browser-extension/src/content/content.css', 'utf8'); +const assets = {}; +for (const file of await readdir('artifacts/browser-extension/unpacked/assets')) if (/\.(svg|png)$/u.test(file)) assets[`assets/${file}`] = `data:image/${file.endsWith('.svg') ? 'svg+xml' : 'png'};base64,${(await readFile(`artifacts/browser-extension/unpacked/assets/${file}`)).toString('base64')}`; +const executablePath = process.env.CHROMIUM_PATH ?? (existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined); +const browser = await chromium.launch({ ...(executablePath ? { executablePath } : {}), headless: true, args: ['--no-sandbox', '--disable-gpu'] }); +async function environment({ dark = true, mobile = false } = {}) { + const context = await browser.newContext({ viewport: mobile ? { width: 375, height: 740 } : { width: 1280, height: 800 }, colorScheme: dark ? 'dark' : 'light', reducedMotion: 'reduce' }); + const page = await context.newPage(); page.setDefaultTimeout(4000); + page.on('pageerror', error => errors.push({ page: page.url(), message: error.message })); + page.on('request', request => { if (!request.url().startsWith('data:') && !request.url().startsWith('about:')) network.push(request.url()); }); + await page.exposeFunction('__rpc', rpc); + await page.evaluate(({ assets }) => { + const listeners = new Set(); globalThis.__emitSettings = () => { for (const listener of listeners) listener({ 'diffdevil.settings.v1': { newValue: true } }, 'local'); }; + globalThis.__emitError = () => { for (const listener of listeners) listener({ errors: { newValue: [] } }, 'local'); }; + globalThis.chrome = { runtime: { id: 'test-extension', getURL: path => assets[path] ?? `chrome-extension://test-extension/${path}`, getManifest: () => ({ version: '1.0.0' }), sendMessage: message => globalThis.__rpc(message), openOptionsPage: async () => {} }, storage: { onChanged: { addListener: callback => listeners.add(callback), removeListener: callback => listeners.delete(callback) } } }; + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async text => { globalThis.__clipboard = text; } } }); + }, { assets }); + return { page, context }; +} +async function check(name, run) { const start = performance.now(); try { await run(); checks.push({ name, status: 'passed', milliseconds: Math.round((performance.now() - start) * 100) / 100 }); } catch (error) { checks.push({ name, status: 'failed', message: error.message }); throw error; } } +async function capture(page, name) { const path = join(out, `${name}.png`); await page.screenshot({ path, animations: 'disabled' }); screenshots.push({ file: `${name}.png`, sha256: createHash('sha256').update(await readFile(path)).digest('hex'), width: page.viewportSize().width, height: page.viewportSize().height }); } +async function optionsPage(dark = true, mobile = false) { + const env = await environment({ dark, mobile }); + const html = (await readFile('apps/browser-extension/options.html', 'utf8')).replace(/]*>[\s\S]*?<\/script>/gu, '').replace(/]*>/gu, '').replace('src="assets/diffdevil-wordmark-dark.svg"', `src="${assets['assets/diffdevil-wordmark-dark.svg']}"`); + await env.page.setContent(html); await env.page.addStyleTag({ content: optionsCss }); + await env.page.addScriptTag({ content: optionsCode }); await env.page.locator('[id="display.enabled"]').waitFor(); + await env.page.evaluate(() => { const notice = document.createElement('div'); notice.id = 'qa-note'; notice.textContent = 'Rendered source candidate · Test storage · Not an installed-extension capture'; notice.style.cssText = 'position:fixed;bottom:0;left:0;right:0;text-align:center;padding:4px;z-index:9999;background:var(--bg);color:var(--fg-3);font:10px sans-serif;border-top:1px solid var(--hair)'; document.body.append(notice); }); + return env; +} +async function contentPage({ dark = true, modern = false, files = true, mobile = false, data = comparison, value = currentPacket } = {}) { + const env = await environment({ dark, mobile }); await env.page.setContent(githubHtml({ dark, modern, files, data })); await env.page.addStyleTag({ content: githubCss + contentCss }); await env.page.addScriptTag({ content: domCode }); + await env.page.evaluate(({ packet, settings }) => { + globalThis.fixture = { href: 'https://github.com/example/cinder/pull/42/files', packet, settings, calls: 0, delay: 0 }; + globalThis.controller = DiffdevilUnderTest.startContent({ href: () => fixture.href, acquire: async () => { fixture.calls++; const packet = fixture.packet; const settings = fixture.settings; const delay = fixture.delay; if (delay) await new Promise(resolve => setTimeout(resolve, delay)); return { packet, settings }; } }); + }, { packet: value, settings }); + await env.page.locator('.ddx-root[data-ddx="aggregate"]').waitFor(); return env; +} +let failure; +try { + const { page, context } = await optionsPage(); + await check('Basic settings render real defaults and literal anchors', async () => { assert.equal(await page.locator('[id="display.enabled"] .setting-anchor>a').textContent(), '#display.enabled'); assert.equal(await page.locator('[id="display.enabled"] input').isChecked(), true); assert.equal(await page.locator('[id="policy.advancedYaml"]').count(), 0); }); + await capture(page, 'settings-dark'); + await check('Searching an advanced stable ID reveals it without changing the selected view', async () => { await page.locator('#settings-search').fill('#display.hideNativeDiffstat'); assert.equal(await page.locator('.setting').count(), 1); assert.equal(await page.locator('[data-view="basic"]').getAttribute('aria-pressed'), 'true'); assert.equal(await page.locator('.advanced-tag').textContent(), 'Advanced'); }); + await check('Advanced value writes persist and Basic shows the active count', async () => { await page.locator('[id="control-display.hideNativeDiffstat"]').check(); await page.waitForFunction(() => document.querySelector('#advanced-badge').textContent === '1'); await page.locator('#settings-search').fill(''); assert.equal(await page.locator('#show-changed').textContent(), '1 advanced setting active'); }); + await check('Literal fragment reveals and focuses a single advanced setting', async () => { await page.evaluate(() => { location.hash = '#policy.advancedYaml'; }); await page.locator('[id="control-policy.advancedYaml"]').waitFor(); assert.equal(await page.locator('[data-view="basic"]').getAttribute('aria-pressed'), 'true'); assert.equal(await page.locator('[id="display.density"]').count(), 0); await page.waitForFunction(() => document.activeElement?.id === 'control-policy.advancedYaml'); }); + await check('Unsaved YAML survives a search and returning to the same fragment', async () => { await page.locator('[id="control-policy.advancedYaml"]').fill('version: 1\n# retained draft\n'); await page.locator('#settings-search').fill('brand icon'); await page.locator('#settings-search').fill(''); assert.equal(await page.locator('[id="control-policy.advancedYaml"]').inputValue(), 'version: 1\n# retained draft\n'); }); + await check('Invalid YAML is rejected by the production compiler without changing saved data', async () => { const before = settingsWrites; await page.locator('[id="control-policy.advancedYaml"]').fill('version: 1\nunknownThing: true\n'); await page.getByRole('button', { name: 'Validate and save', exact: true }).click(); await page.locator('[id="policy.advancedYaml"] .validation-message').filter({ hasText: /./ }).waitFor(); assert.equal(settingsWrites, before); assert.equal(settings['policy.advancedYaml'], ''); }); + await check('Copy link retains the public fragment instead of a generated alias', async () => { await page.locator('[id="policy.advancedYaml"] .copy-link').click(); assert.ok((await page.evaluate(() => globalThis.__clipboard)).endsWith('#policy.advancedYaml')); }); + await page.locator('#settings-search').fill('diffdevil icon'); await capture(page, 'settings-icon-dark'); + await check('All three icon choices are real saved values, including no-icon layout', async () => { await page.locator('input[name="display.brandIcon"][value="none"]').check(); await page.waitForFunction(() => document.querySelector('#save-status')?.textContent.startsWith('Saved')); await page.waitForTimeout(30); assert.equal(settings['display.brandIcon'], 'none'); assert.equal(await page.locator('[data-icon-preview="none"] .ddx-brand').count(), 0); await page.locator('input[name="display.brandIcon"][value="full-color"]').check(); await page.waitForTimeout(30); assert.equal(settings['display.brandIcon'], 'full-color'); assert.equal(await page.locator('[data-icon-preview="full-color"] img').count(), 1); }); + await check('Theme changes preserve unsaved policy drafts and use the light identity asset', async () => { await page.locator('#theme-control [data-theme="light"]').click(); await page.waitForFunction(() => document.documentElement.dataset.theme === 'light'); await page.locator('#settings-search').fill(''); assert.equal(await page.locator('[id="control-policy.advancedYaml"]').inputValue(), 'version: 1\nunknownThing: true\n'); assert.ok((await page.locator('#wordmark').getAttribute('src')).startsWith('data:image/svg+xml')); }); + await page.locator('#settings-search').fill('bands and label mappings'); await capture(page, 'settings-bands-light'); + await check('Guided bands validate ascending thresholds and retain invalid edits for correction', async () => { await page.locator('.band-editor input[aria-label="S lt"]').fill('10'); const before = settingsWrites; await page.getByRole('button', { name: 'Save bands', exact: true }).click(); await page.locator('[id="policy.bands"] .validation-message').filter({ hasText: /./ }).waitFor(); assert.equal(settingsWrites, before); await page.getByRole('button', { name: 'Discard edits', exact: true }).click(); assert.equal(await page.locator('.band-editor input[aria-label="S lt"]').inputValue(), '100'); }); + await page.locator('#settings-search').fill(''); await page.evaluate(() => { location.hash = '#policy.repositoryOverrides'; }); + await check('Repository mode and explicit YAML save through the real settings validator', async () => { await page.locator('input[aria-label="Repository identifier"]').fill('Private/Example'); await page.locator('select[aria-label="Repository policy mode"]').selectOption('personal-only'); await page.locator('textarea[aria-label="Repository override YAML"]').fill('version: 1\nsize:\n metric: raw.churn\n'); await page.getByRole('button', { name: 'Save repository override' }).click(); await page.locator('.repository-chip').filter({ hasText: 'private/example' }).waitFor(); assert.equal(m.overrides(settings['policy.repositoryOverrides'])['private/example'].mode, 'personal-only'); }); + await check('Declining destructive confirmation preserves repository overrides', async () => { await page.locator('#settings-search').fill('clear repository overrides'); page.once('dialog', dialog => dialog.dismiss()); await page.getByRole('button', { name: 'Clear repository overrides', exact: true }).click(); assert.ok(m.overrides(settings['policy.repositoryOverrides'])['private/example']); }); + await check('Confirming override deletion changes only that local setting', async () => { page.once('dialog', dialog => dialog.accept()); await page.getByRole('button', { name: 'Clear repository overrides', exact: true }).click(); await page.waitForTimeout(100); assert.deepEqual(Object.keys(m.overrides(settings['policy.repositoryOverrides'])), []); assert.equal(settings['display.hideNativeDiffstat'], true); }); + await check('Keyboard navigation reaches visible form controls with focus indication', async () => { await page.locator('#settings-search').fill('raw churn'); await page.locator('[id="display.rawChurn"] .setting-anchor>a').focus(); await page.keyboard.press('Tab'); assert.equal(await page.evaluate(() => document.activeElement.textContent), 'Copy link'); }); + await check('Narrow settings viewport does not produce page-level horizontal overflow', async () => { await page.setViewportSize({ width: 375, height: 740 }); await page.locator('#settings-search').fill('bands'); const dimensions = await page.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth })); assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions)); }); + await capture(page, 'settings-mobile'); await context.close(); + + settings = await preferences.save(m.DEFAULTS, true); currentPacket = packet(); + const scene = await contentPage({ files: false }); const github = scene.page; + await check('Aggregate arrives before lazy-rendered files with canonical Changed and raw churn', async () => { assert.equal(await github.locator('[data-ddx="aggregate"] .ddx-value').textContent(), '178'); assert.equal(await github.locator('[data-ddx="aggregate"] .ddx-raw').textContent(), 'Raw +160 −118'); assert.equal(await github.locator('[data-ddx="aggregate"] .ddx-local').textContent(), 'local'); }); + await check('Legacy and React file headers receive their own classifications lazily', async () => { await github.locator('#fixture-files').evaluate((host, html) => { host.innerHTML = html; }, fileHeader('src/cache.ts') + fileHeader('src/renderer.ts', true)); await github.locator('[data-ddx="file"]').nth(1).waitFor(); assert.deepEqual(await github.locator('[data-ddx="file"] .ddx-value').allTextContents(), ['32', '146']); assert.deepEqual(await github.locator('[data-ddx="file"] .ddx-pill').allTextContents(), ['size/S', 'size/M']); }); + await check('Repeated soft navigation and unrelated mutations do not duplicate or reacquire', async () => { await github.evaluate(() => { for (let i = 0; i < 20; i++) { document.dispatchEvent(new Event('soft-nav:payload')); const div = document.createElement('div'); div.textContent = 'unrelated'; document.body.append(div); div.remove(); } }); await github.waitForTimeout(100); assert.equal(await github.locator('.ddx-root').count(), 3); assert.equal(await github.evaluate(() => fixture.calls), 1); }); + await check('Native diffstat is muted without container opacity or lost keyboard focus', async () => { const result = await github.locator('.gh-header-meta .diffstat').evaluate(element => ({ opacity: getComputedStyle(element).opacity, color: getComputedStyle(element).color, child: getComputedStyle(element.querySelector('.color-fg-success')).color, muted: getComputedStyle(document.documentElement).getPropertyValue('--fgColor-muted').trim() })); assert.equal(result.opacity, '1'); assert.equal(result.color, result.child); }); + await capture(github, 'github-dark'); + const aggregate = github.locator('[data-ddx="aggregate"] .ddx-trigger'); + await check('Report popover is one persistent, non-modal anchored surface', async () => { await aggregate.click(); const panel = github.locator('.ddx-popover'); await panel.waitFor(); assert.equal(await panel.getAttribute('role'), 'dialog'); assert.equal(await panel.getAttribute('aria-modal'), null); assert.ok((await panel.innerText()).includes('Changed')); assert.equal(await github.evaluate(() => document.body.style.overflow), ''); assert.equal(await github.locator('.ddx-popover').count(), 1); }); + await check('Escape dismisses and returns focus to the exact trigger', async () => { await github.keyboard.press('Escape'); assert.equal(await github.locator('.ddx-popover').count(), 0); assert.equal(await aggregate.evaluate(node => document.activeElement === node), true); }); + await check('Same-trigger toggle and outside click both dismiss without a backdrop', async () => { await aggregate.click(); await aggregate.click(); assert.equal(await github.locator('.ddx-popover').count(), 0); await aggregate.click(); await github.locator('.fixture-top>strong').click(); assert.equal(await github.locator('.ddx-popover').count(), 0); }); + await check('Space opens the same detailed report from the native button', async () => { await aggregate.focus(); await github.keyboard.press('Space'); await github.locator('.ddx-popover').waitFor(); assert.equal(await aggregate.getAttribute('aria-expanded'), 'true'); }); + await capture(github, 'report-dark'); await github.keyboard.press('Escape'); + await check('Native label handoff searches exactly but never selects or mutates labels', async () => { await github.getByRole('button', { name: 'Find size/M', exact: true }).click(); await github.waitForFunction(() => document.querySelector('#labels-select-menu input').value === 'size/M'); assert.deepEqual(await github.locator('.IssueLabel').allTextContents(), ['documentation']); assert.equal(await github.locator('#labels-select-menu').evaluate(node => node.open), true); }); + await check('Provider label observation is distinct from optimistic application', async () => { await github.locator('.sidebar-labels').evaluate(host => { const label = document.createElement('span'); label.className = 'IssueLabel'; label.textContent = 'size/M'; host.append(label); }); await github.waitForFunction(() => [...document.querySelectorAll('.ddx-label-status')].some(node => node.textContent.includes('Observed on GitHub'))); }); + await check('Replacing GitHub’s main root removes stale anchors and rebinds once', async () => { await aggregate.click(); await github.locator('#repo-content-pjax-container').evaluate(element => { const copy = element.cloneNode(true); for (const node of copy.querySelectorAll('.ddx-root,.ddx-status')) node.remove(); element.replaceWith(copy); }); await github.waitForTimeout(150); assert.equal(await github.locator('.ddx-popover').count(), 0); assert.equal(await github.locator('.ddx-root').count(), 3); assert.equal(await github.evaluate(() => fixture.calls), 1); }); + await check('Commit-only views do not inherit whole-PR file measurements', async () => { await github.evaluate(() => { fixture.href = 'https://github.com/example/cinder/pull/42/commits/' + 'd'.repeat(40); window.dispatchEvent(new Event('popstate')); }); await github.waitForTimeout(50); assert.equal(await github.locator('[data-ddx="file"]').count(), 0); assert.equal(await github.locator('[data-ddx="aggregate"]').count(), 1); await github.evaluate(() => { fixture.href = 'https://github.com/example/cinder/pull/42/files'; window.dispatchEvent(new Event('popstate')); }); await github.locator('[data-ddx="file"]').nth(1).waitFor(); }); + await check('Errors written to diagnostics do not create a storage-refresh feedback loop', async () => { const before = await github.evaluate(() => fixture.calls); await github.evaluate(() => __emitError()); await github.waitForTimeout(40); assert.equal(await github.evaluate(() => fixture.calls), before); }); + settings = await preferences.save({ 'display.brandIcon': 'none' }); + await github.evaluate(settings => { fixture.settings = settings; __emitSettings(); }, settings); await github.waitForTimeout(120); + await check('No-icon setting removes the mark and its layout slot everywhere', async () => { assert.equal(await github.locator('.ddx-root .ddx-brand').count(), 0); assert.equal(await github.locator('[data-ddx="aggregate"] .ddx-trigger').evaluate(node => node.firstElementChild.className), 'ddx-primary-label'); }); + settings = await preferences.save({ 'display.brandIcon': 'full-color' }); + await github.evaluate(settings => { fixture.settings = settings; __emitSettings(); }, settings); await github.waitForTimeout(100); + await check('Full-colour icons use the dark micro asset, not master logo art', async () => { assert.equal(await github.locator('.ddx-root .ddx-brand img').count(), 3); assert.equal(await github.locator('[data-ddx="aggregate"] .ddx-brand img').getAttribute('src'), assets['assets/diffdevil-symbol-micro-dark.svg']); }); + await github.evaluate(() => { document.documentElement.dataset.colorMode = 'light'; }); await github.waitForTimeout(100); + await check('GitHub theme change swaps only the micro variant and preserves report facts', async () => { assert.equal(await github.locator('[data-ddx="aggregate"] .ddx-brand img').getAttribute('src'), assets['assets/diffdevil-symbol-micro-light.svg']); assert.equal(await github.locator('[data-ddx="aggregate"] .ddx-value').textContent(), '178'); }); + await capture(github, 'github-light'); await github.locator('[data-ddx="aggregate"] .ddx-trigger').click(); await capture(github, 'report-light'); + await check('Popover fits a narrow viewport and survives resize', async () => { await github.setViewportSize({ width: 375, height: 600 }); await github.waitForTimeout(100); const box = await github.locator('.ddx-popover').boundingBox(); assert.ok(box.x >= 0 && box.y >= 0 && box.x + box.width <= 375.5 && box.y + box.height <= 600.5, JSON.stringify(box)); }); + await capture(github, 'report-mobile'); await github.keyboard.press('Escape'); await github.setViewportSize({ width: 1280, height: 800 }); + settings = await preferences.save({ 'display.hideNativeDiffstat': true, 'display.fileChanged': false }); + await github.evaluate(settings => { fixture.settings = settings; __emitSettings(); }, settings); await github.waitForTimeout(100); + await check('Native hide is independent of the extension’s file-Changed toggle', async () => { assert.equal(await github.locator('[data-ddx="file"]').count(), 0); for (const value of await github.locator('.diffstat').evaluateAll(nodes => nodes.map(node => getComputedStyle(node).display))) assert.equal(value, 'none'); }); + settings = await preferences.save({ 'display.enabled': false }); + await github.evaluate(settings => { fixture.settings = settings; __emitSettings(); }, settings); await github.waitForTimeout(100); + await check('Disabling augmentation restores native UI and removes all injected surfaces', async () => { assert.equal(await github.locator('.ddx-root,.ddx-popover,.ddx-native-dim,.ddx-native-hidden').count(), 0); }); + await github.evaluate(() => controller.stop()); await scene.context.close(); + + settings = await preferences.save(m.DEFAULTS, true); currentPacket = packet(); + const stale = await contentPage(); + const nextComparison = { ...comparison, head: 'c'.repeat(40), additions: 18, deletions: 11 }; + const next = packet(nextComparison, filePatch('src/cache.ts', 3, 4, 1) + filePatch('src/renderer.ts', 5, 6, 2)); + await check('A late old-head completion cannot overwrite a newer comparison', async () => { + await stale.page.evaluate(() => { fixture.delay = 180; void controller.refresh(); }); + await stale.page.waitForTimeout(30); + await stale.page.evaluate(({ next, data }) => { fixture.packet = next; fixture.delay = 0; document.querySelector('script[type="application/json"]').textContent = JSON.stringify({ payload: { pullRequest: { number: 42, baseRefOid: data.base, headRefOid: data.head, changedFiles: 2, additions: 18, deletions: 11 } } }); document.dispatchEvent(new Event('soft-nav:payload')); }, { next, data: nextComparison }); + await stale.page.waitForFunction(() => document.querySelector('[data-ddx="aggregate"] .ddx-value')?.textContent === '21'); await stale.page.waitForTimeout(220); + assert.equal(await stale.page.locator('[data-ddx="aggregate"] .ddx-value').textContent(), '21'); + }); + await check('File acquisition failure is bounded instead of an infinite retry loop', async () => { failFiles = true; const before = fileCalls; await stale.page.evaluate(() => { void controller.refresh(); }); await stale.page.waitForTimeout(150); assert.equal(fileCalls - before, 1); await stale.page.waitForTimeout(150); assert.equal(fileCalls - before, 1); assert.ok((await stale.page.locator('.ddx-status').textContent()).includes('Fixture file transport')); failFiles = false; }); + await check('An explicit retry recovers file evidence after a transient failure', async () => { await stale.page.getByRole('button', { name: 'Retry', exact: true }).click(); await stale.page.locator('[data-ddx="file"]').nth(1).waitFor(); assert.equal(await stale.page.locator('.ddx-status').count(), 0); }); + await check('Navigation away from pull requests leaves no augmentation or stale popover', async () => { await stale.page.evaluate(() => { fixture.href = 'https://github.com/example/cinder/issues/99'; window.dispatchEvent(new Event('popstate')); }); await stale.page.waitForTimeout(60); assert.equal(await stale.page.locator('.ddx-root,.ddx-popover').count(), 0); }); + await stale.page.evaluate(() => controller.stop()); await stale.context.close(); + + const unknownComparison = { ...comparison, changedFiles: 3, additions: undefined, deletions: undefined }; + const unknown = packet(unknownComparison); + const uncertain = await contentPage({ value: unknown, data: unknownComparison }); + await check('Incomplete file-set evidence is visibly unknown and selects no unjustified band', async () => { assert.match(await uncertain.page.locator('[data-ddx="aggregate"] .ddx-evidence').textContent(), /unknown/u); assert.equal(await uncertain.page.locator('[data-ddx="aggregate"] .ddx-selected').count(), 0); assert.match(await uncertain.page.locator('[data-ddx="aggregate"] .ddx-value').textContent(), /≥178/u); }); + await capture(uncertain.page, 'evidence-unknown'); await uncertain.page.evaluate(() => controller.stop()); await uncertain.context.close(); + + const injection = await environment(); await injection.page.setContent('
'); await injection.page.addStyleTag({ content: githubCss + contentCss }); await injection.page.addScriptTag({ content: domCode }); + const dangerousPath = 'src/.ts'; + const hostileComp = { ...comparison, changedFiles: 1, additions: 1, deletions: 0 }; + const hostile = packet(hostileComp, '', { comparison: hostileComp, format: 'github-files', complete: true, files: [{ filename: dangerousPath, status: 'added', additions: 1, deletions: 0, patch: '@@ -0,0 +1 @@\n+x' }] }); + const hostileContext = contexts.get(hostile.key); const hostileView = unwrap(m.humanReport(hostileContext.report, hostileContext.policy, dangerousPath)); + await check('Hostile path text is rendered as text rather than executable HTML', async () => { await injection.page.evaluate(view => { const popover = new DiffdevilUnderTest.Popover(); const panel = DiffdevilUnderTest.reportPanel(view, popover); document.querySelector('#target').append(panel); }, hostileView); assert.ok((await injection.page.locator('#target').innerText()).includes(dangerousPath)); assert.equal(await injection.page.locator('#target img').count(), 0); assert.equal(await injection.page.evaluate(() => globalThis.injected), undefined); }); + await injection.context.close(); + await check('UI tests generated no external network requests', async () => { assert.deepEqual(network, []); }); + await check('Rendered source generated no uncaught page errors', async () => { assert.deepEqual(errors, []); }); +} catch (error) { failure = error; } +finally { + await browser.close(); await loaded.cleanup(); + const result = { kind: 'diffdevil.extension.dom-qa/1', mode: 'production DOM/controllers with deterministic test transport and storage', browser: 'Chromium via Playwright', installedExtensionVerified: false, liveGitHubVerified: false, checks, passed: checks.filter(check => check.status === 'passed').length, failed: checks.filter(check => check.status === 'failed').length, pageErrors: errors, networkRequests: network, screenshots, timings: measurements, timingQualification: 'Single sandbox samples, including JIT effects; not a performance benchmark or live latency measurement.' }; + await writeFile(join(out, 'receipt.json'), `${JSON.stringify(result, null, 2)}\n`); + console.log(JSON.stringify({ passed: result.passed, failed: result.failed, screenshots: screenshots.length, qualification: result.mode }, null, 2)); +} +if (failure) throw failure; diff --git a/apps/browser-extension/src/background/authorization.ts b/apps/browser-extension/src/background/authorization.ts new file mode 100644 index 00000000..8c0f72c4 --- /dev/null +++ b/apps/browser-extension/src/background/authorization.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { ExtensionError } from '../shared/errors.js'; +import { repositoryKey } from '../shared/settings.js'; +import type { Message } from '../shared/protocol.js'; +/** No arbitrary-URL proxy and no content-script access to settings mutations. */ +export function authorize(sender: chrome.runtime.Sender, message: Message, extensionId: string): { options: boolean; repository?: string; pullRequest?: number } { + if (sender.id !== extensionId) throw new ExtensionError('SENDER_REJECTED', 'The message did not originate in this extension.'); + const url = new URL(sender.url ?? sender.tab?.url ?? 'about:blank'); + if (url.protocol === 'chrome-extension:' && url.hostname === extensionId && url.pathname === '/options.html') return { options: true }; + if (url.origin !== 'https://github.com' || sender.frameId !== undefined && sender.frameId !== 0) throw new ExtensionError('SENDER_REJECTED', 'A supported top-level GitHub page is required.'); + const route = /^\/([^/]+\/[^/]+)\/pull\/([1-9]\d*)(?:\/|$)/u.exec(url.pathname); + if (!route) throw new ExtensionError('SENDER_REJECTED', 'This request requires a GitHub pull-request page.'); + if (['settings.save', 'data.action', 'diagnostics.get'].includes(message.type)) throw new ExtensionError('OPTIONS_REQUIRED', 'Manage extension data from Settings.'); + const repository = repositoryKey(route[1]!); const pullRequest = Number(route[2]); + const target = message.type === 'analysis.run' ? message.input.comparison : message.type === 'cache.lookup' ? message.comparison : message.type === 'source.public' || message.type === 'source.policy' ? message : undefined; + if (target && repositoryKey(target.repository) !== repository || target && 'pullRequest' in target && target.pullRequest !== pullRequest) throw new ExtensionError('SENDER_SCOPE', 'A page may acquire only its own pull request.'); + return { options: false, repository, pullRequest }; +} diff --git a/apps/browser-extension/src/background/cache.ts b/apps/browser-extension/src/background/cache.ts new file mode 100644 index 00000000..87657c40 --- /dev/null +++ b/apps/browser-extension/src/background/cache.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { bytes } from '../shared/settings.js'; +import type { CacheInfo } from '../shared/protocol.js'; +export interface CacheEntry { key: string; kind: 'report' | 'policy'; size: number; touched: number; value: unknown } +/** Backend-independent budget decisions are tested with the same production entries. */ +export function evictions(entries: readonly CacheEntry[], maximum: number, incoming?: CacheEntry): readonly string[] { + const existing = entries.filter(entry => entry.key !== incoming?.key).sort((a, b) => a.touched - b.touched || a.key.localeCompare(b.key)); + let size = existing.reduce((n, entry) => n + entry.size, incoming?.size ?? 0); const removed: string[] = []; + for (const entry of existing) { if (size <= maximum) break; removed.push(entry.key); size -= entry.size; } + return removed; +} +const read = (request: IDBRequest): Promise => new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); +const done = (transaction: IDBTransaction): Promise => new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error ?? new Error('Cache transaction aborted.')); }); +/** Disposable, size-bounded cache. Generations prevent late writes after a clear. */ +export class AnalysisCache { + private connection?: Promise; + private maximum = 16 * 1024 * 1024; + private revision = 0; + get generation(): number { return this.revision; } + constructor(private readonly factory: IDBFactory) {} + private database(): Promise { + return this.connection ??= new Promise((resolve, reject) => { + const request = this.factory.open('diffdevil-rebuildable-v1', 1); + request.onupgradeneeded = () => { const store = request.result.createObjectStore('entries', { keyPath: 'key' }); store.createIndex('kind', 'kind'); }; + request.onsuccess = () => { request.result.onversionchange = () => { request.result.close(); delete this.connection; }; resolve(request.result); }; + request.onerror = () => { delete this.connection; reject(request.error); }; + request.onblocked = () => { delete this.connection; reject(new Error('Another extension page is holding an older cache database.')); }; + }); + } + async configure(mebibytes: number): Promise { + if (!Number.isSafeInteger(mebibytes) || mebibytes < 4 || mebibytes > 128) throw new Error('Cache capacity must be 4–128 MiB.'); + const maximum = mebibytes * 1024 * 1024; if (maximum === this.maximum) return; + const db = await this.database(); const tx = db.transaction('entries', 'readwrite'); const finished = done(tx); const store = tx.objectStore('entries'); + for (const key of evictions(await read(store.getAll()) as CacheEntry[], maximum)) store.delete(key); await finished; this.maximum = maximum; + } + async get(key: string): Promise { + const db = await this.database(); const tx = db.transaction('entries', 'readwrite'); const finished = done(tx); const store = tx.objectStore('entries'); + const entry = await read(store.get(key)) as CacheEntry | undefined; + if (entry) store.put({ ...entry, touched: Date.now() }); await finished; return entry?.value as T | undefined; + } + async put(key: string, kind: CacheEntry['kind'], value: unknown, generation = this.revision): Promise { + if (generation !== this.revision) return; + const entry: CacheEntry = { key, kind, value, touched: Date.now(), size: bytes(value) + bytes(key) + 96 }; + if (entry.size > this.maximum) return; + const db = await this.database(); if (generation !== this.revision) return; + const tx = db.transaction('entries', 'readwrite'); const finished = done(tx); const store = tx.objectStore('entries'); + const entries = await read(store.getAll()) as CacheEntry[]; + if (generation !== this.revision) { tx.abort(); await finished.catch(() => undefined); return; } + for (const key of evictions(entries, this.maximum, entry)) store.delete(key); store.put(entry); await finished; + } + async clear(kind?: CacheEntry['kind']): Promise { + this.revision++; const db = await this.database(); const tx = db.transaction('entries', 'readwrite'); const finished = done(tx); const store = tx.objectStore('entries'); + if (!kind) store.clear(); else for (const key of await read(store.index('kind').getAllKeys(kind))) store.delete(key); await finished; + } + async summary(): Promise { + const db = await this.database(); const tx = db.transaction('entries', 'readonly'); const finished = done(tx); const entries = await read(tx.objectStore('entries').getAll()) as CacheEntry[]; await finished; + const reports = entries.filter(entry => entry.kind === 'report'); const policies = entries.filter(entry => entry.kind === 'policy'); + return { entries: entries.length, bytes: entries.reduce((sum, entry) => sum + entry.size, 0), reportEntries: reports.length, reportBytes: reports.reduce((sum, entry) => sum + entry.size, 0), policyEntries: policies.length, policyBytes: policies.reduce((sum, entry) => sum + entry.size, 0), maximumBytes: this.maximum }; + } +} diff --git a/apps/browser-extension/src/background/preferences.ts b/apps/browser-extension/src/background/preferences.ts new file mode 100644 index 00000000..3a2b5d10 --- /dev/null +++ b/apps/browser-extension/src/background/preferences.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { DEFAULTS, type Settings } from '../shared/catalogue.js'; +import { bytes, SETTINGS_KEY, validateSettings } from '../shared/settings.js'; +const LOCAL = new Set(['policy.advancedYaml', 'policy.repositoryOverrides']); +const JOURNAL = 'diffdevil.settings.pending.v1'; +/** Recoverable settings changes across local and sync; never silently erase policy. */ +export class Preferences { + private tail: Promise = Promise.resolve(); + constructor(private readonly areas: Pick) {} + private queue(operation: () => Promise): Promise { const result = this.tail.then(operation); this.tail = result.catch(() => undefined); return result; } + private async recover(): Promise { + const value = (await this.areas.local.get(JOURNAL))[JOURNAL]; if (value === undefined) return; + if (!value || typeof value !== 'object' || (value as Record).version !== 1 || !Object.hasOwn(value, 'sync') || !Object.hasOwn(value, 'local')) throw new Error('The settings recovery journal is invalid. Your data has not been overwritten.'); + const previous = value as { sync: unknown; local: unknown }; + for (const name of ['sync', 'local'] as const) { + if (previous[name] === null) await this.areas[name].remove(SETTINGS_KEY); + else await this.areas[name].set({ [SETTINGS_KEY]: previous[name] }); + } + await this.areas.local.remove(JOURNAL); + } + private async read(): Promise { + const values = await Promise.all([this.areas.sync.get(SETTINGS_KEY), this.areas.local.get(SETTINGS_KEY)]); + const object = (value: unknown): Record => { if (value === undefined) return {}; if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Stored settings are damaged. They have not been overwritten.'); return value as Record; }; + return validateSettings({ ...object(values[0]?.[SETTINGS_KEY]), ...object(values[1]?.[SETTINGS_KEY]) }); + } + load(): Promise { return this.queue(async () => { await this.recover(); return this.read(); }); } + save(patch: unknown, replace = false): Promise { + return this.queue(async () => { + await this.recover(); const settings = validateSettings(patch, replace ? { ...DEFAULTS } : await this.read()); + const sync = Object.fromEntries(Object.entries(settings).filter(([id]) => !LOCAL.has(id))); + const local = Object.fromEntries(Object.entries(settings).filter(([id]) => LOCAL.has(id))); + if (bytes(sync) > 7500) throw new Error('Portable settings exceed the sync budget. Keep larger policies in local Advanced YAML.'); + const [oldSync, oldLocal] = await Promise.all([this.areas.sync.get(SETTINGS_KEY), this.areas.local.get(SETTINGS_KEY)]); + await this.areas.local.set({ [JOURNAL]: { version: 1, sync: oldSync[SETTINGS_KEY] ?? null, local: oldLocal[SETTINGS_KEY] ?? null } }); + try { await this.areas.local.set({ [SETTINGS_KEY]: local }); await this.areas.sync.set({ [SETTINGS_KEY]: sync }); await this.areas.local.remove(JOURNAL); } + catch (error) { try { await this.recover(); } catch { throw new Error('Saving failed and recovery remains pending. The recovery journal is retained.'); } throw error; } + return settings; + }); + } + reset(): Promise { return this.queue(async () => { await Promise.all([this.areas.local.clear(), this.areas.sync.clear(), this.areas.session.clear()]); }); } +} diff --git a/apps/browser-extension/src/background/public-source.ts b/apps/browser-extension/src/background/public-source.ts new file mode 100644 index 00000000..ffa34ac4 --- /dev/null +++ b/apps/browser-extension/src/background/public-source.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { readComparison, type BrowserComparison } from '@wolfsblvt/diffdevil/browser'; +import { repositoryKey } from '../shared/settings.js'; +import { boundedText, ExtensionError, object, safePath } from '../shared/errors.js'; +import type { PolicySource, PublicPull } from '../shared/protocol.js'; +const API = 'https://api.github.com'; +const safeInteger = (value: unknown): number => { if (!Number.isSafeInteger(value) || Number(value) < 0) throw new ExtensionError('PROVIDER_COUNTER', 'GitHub did not return valid comparison counters.'); return Number(value); }; +export class PublicSource { + constructor(private readonly fetcher: typeof fetch = fetch) {} + private async get(path: string, maximum: number): Promise<{ response: Response; value: unknown }> { + const response = await this.fetcher(`${API}${path}`, { credentials: 'omit', cache: 'no-store', redirect: 'error', headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, signal: AbortSignal.timeout(20_000) }); + if (response.status === 403 || response.status === 429) throw new ExtensionError('GITHUB_RATE_LIMIT', 'GitHub declined the public API request. No token is collected; retry after the provider limit resets.'); + if (!response.ok && response.status !== 404) throw new ExtensionError('GITHUB_HTTP', `GitHub returned HTTP ${response.status}.`); + const text = await boundedText(response, maximum); return { response, value: text ? JSON.parse(text) as unknown : null }; + } + async pull(repository: string, pullRequest: number, includeFiles = false): Promise { + const repo = repositoryKey(repository); if (!Number.isSafeInteger(pullRequest) || pullRequest < 1) throw new ExtensionError('PR_IDENTITY', 'Invalid pull-request number.'); + const path = `/repos/${repo}/pulls/${pullRequest}`; + const snapshot = async (): Promise => { + const result = await this.get(path, 1024 * 1024); + if (result.response.status === 404) throw new ExtensionError('PUBLIC_UNAVAILABLE', 'This pull request is not publicly accessible. Its signed-in GitHub route is required.'); + const value = object(result.value); const base = object(value.base); const head = object(value.head); + if (value.number !== pullRequest || repositoryKey(String(object(base.repo).full_name)) !== repo) throw new ExtensionError('PR_IDENTITY', 'GitHub returned a different pull request.'); + return readComparison({ host: 'github.com', repository, pullRequest, base: base.sha, head: head.sha, changedFiles: safeInteger(value.changed_files), additions: safeInteger(value.additions), deletions: safeInteger(value.deletions) }); + }; + const before = await snapshot(); if (!includeFiles) return { comparison: before }; + const files: unknown[] = []; let acquiredBytes = 0; + for (let page = 1; files.length < Math.min(before.changedFiles ?? 0, 3000); page++) { + const result = await this.get(`${path}/files?per_page=100&page=${page}`, 6 * 1024 * 1024); + if (!result.response.ok || !Array.isArray(result.value)) throw new ExtensionError('PROVIDER_FILES', 'GitHub did not return the file collection.'); + acquiredBytes += new TextEncoder().encode(JSON.stringify(result.value)).byteLength; + if (acquiredBytes > 20 * 1024 * 1024) throw new ExtensionError('SOURCE_LIMIT', 'The provider file collection exceeds 20 MiB.'); + files.push(...result.value); if (result.value.length < 100) break; + } + const after = await snapshot(); if (JSON.stringify(before) !== JSON.stringify(after)) throw new ExtensionError('COMPARISON_MOVED', 'The pull-request comparison changed during acquisition. Refresh the new comparison.'); + return { comparison: after, files }; + } + async policy(repository: string, base: string, path: string): Promise { + const repo = repositoryKey(repository); if (!/^[\da-f]{40}$/u.test(base)) throw new ExtensionError('BASE_IDENTITY', 'A complete immutable base SHA is required.'); + const result = await this.get(`/repos/${repo}/contents/${safePath(path)}?ref=${base}`, 512 * 1024); + if (result.response.status === 404) { + const commit = await this.get(`/repos/${repo}/git/commits/${base}`, 1024 * 1024); + if (!commit.response.ok || object(commit.value).sha !== base) throw new ExtensionError('POLICY_ACCESS', 'Access to the exact base could not be confirmed. A 404 is not proof of absent private policy.'); + return { status: 'absent', at: Date.now() }; + } + const value = object(result.value); if (value.type !== 'file' || value.encoding !== 'base64' || typeof value.content !== 'string' || typeof value.sha !== 'string' || !/^[a-f0-9]{40}$/u.test(value.sha)) throw new ExtensionError('POLICY_BODY', 'GitHub did not return complete policy text.'); + const data = Uint8Array.from(atob(value.content.replace(/\s/gu, '')), char => char.charCodeAt(0)); + if (data.length > 256 * 1024) throw new ExtensionError('POLICY_LIMIT', 'Policy exceeds 256 KiB.'); + return { status: 'present', text: new TextDecoder('utf-8', { fatal: true }).decode(data), blob: value.sha, at: Date.now() }; + } +} diff --git a/apps/browser-extension/src/background/worker.ts b/apps/browser-extension/src/background/worker.ts new file mode 100644 index 00000000..3fe1110e --- /dev/null +++ b/apps/browser-extension/src/background/worker.ts @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { analyzeBrowserInput, comparisonKey, compileBrowserPolicy, humanReport, readComparison, requiredTemplates, BROWSER_ADAPTER, HUMAN_VIEW_VERSION, SEMANTICS, type BrowserPolicy, type Report, type Diagnostic, type HumanReportView, type BrowserComparison } from '@wolfsblvt/diffdevil/browser'; +import { Preferences } from './preferences.js'; +import { AnalysisCache } from './cache.js'; +import { PublicSource } from './public-source.js'; +import { authorize } from './authorization.js'; +import { bytes, decorateView, selectedPolicy } from '../shared/settings.js'; +import { advancedChanges, type Settings } from '../shared/catalogue.js'; +import { ExtensionError } from '../shared/errors.js'; +import type { AnalysisInput, Diagnostics, Message, Packet, PolicySource } from '../shared/protocol.js'; +declare const __ENGINE_VERSION__: string; +const preferences = new Preferences(chrome.storage); const cache = new AnalysisCache(indexedDB); const provider = new PublicSource(); +interface Cached { report: Report; at: number } +interface Context { packet: Packet; report: Report; policy?: BrowserPolicy; settings: Settings; files: Map } +const contexts = new Map(); +const inFlight = new Map>(); +const reportKey = (comparison: BrowserComparison): string => `report:${comparisonKey(comparison)}:${__ENGINE_VERSION__}:${SEMANTICS.replacementLines}`; +const policyKey = (comparison: BrowserComparison): string => `policy:${comparison.host}/${comparison.repository.toLowerCase()}@${comparison.base}/.diffdevil.yml`; +const errorDiagnostic = (code: string, message: string): Diagnostic => ({ code, message, severity: 'error', phase: 'config' }); +async function recordError(error: unknown): Promise { + const state = await chrome.storage.local.get('diagnosticCodes'); const previous = Array.isArray(state.diagnosticCodes) ? state.diagnosticCodes : []; + await chrome.storage.local.set({ diagnosticCodes: [...previous.slice(-19), { code: error instanceof ExtensionError ? error.code : 'EXTENSION_OPERATION', at: Date.now() }] }); +} +async function optionalCache(operation: () => Promise): Promise { + try { return await operation(); } catch { await recordError(new ExtensionError('CACHE_UNAVAILABLE', 'Rebuildable cache unavailable.')); return undefined; } +} +async function analyze(input: AnalysisInput, settings: Settings): Promise { + const comparison = readComparison(input.comparison); const generation = cache.generation; + await optionalCache(() => cache.configure(Number(settings['cache.maximumSize']))); + const rawKey = reportKey(comparison); const cached = await optionalCache(() => cache.get(rawKey)); let report = cached?.report; + if (!report) { + if (!input.acquisition) throw new ExtensionError('CACHE_MISS', 'Source is required for this uncached comparison.'); + if (comparisonKey(input.acquisition.comparison) !== comparisonKey(comparison)) throw new ExtensionError('SOURCE_IDENTITY', 'The source does not match the requested comparison.'); + const result = analyzeBrowserInput(JSON.stringify(input.acquisition)); + if (!result.ok) throw new ExtensionError(result.diagnostics[0]?.code ?? 'SOURCE_INVALID', result.diagnostics.map(item => item.message).join('\n')); + report = result.value; + const saved: Cached = { report, at: Date.now() }; + await optionalCache(() => cache.put(rawKey, 'report', saved, generation)); + } + if (!input.policy || !['present', 'absent', 'unavailable'].includes(input.policy.status) || input.policy.status === 'present' && typeof input.policy.text !== 'string') throw new ExtensionError('POLICY_EVIDENCE', 'Policy acquisition standing is invalid.'); + const selected = selectedPolicy(settings, comparison.repository); let policy: BrowserPolicy | undefined; let errors: readonly Diagnostic[] = []; + if (selected.mode !== 'personal-only' && input.policy.status === 'unavailable') errors = [errorDiagnostic('REPOSITORY_POLICY_UNAVAILABLE', 'The exact base policy could not be read. Facts remain visible. Select Personal only explicitly to classify without repository policy.')]; + else { + const result = compileBrowserPolicy(JSON.stringify({ ...selected, ...(input.policy.status === 'present' ? { repository: input.policy.text } : {}), ...(input.templates ? { templateFiles: input.templates } : {}) })); + if (result.ok) policy = result.value; else errors = result.diagnostics; + } + if (input.policy.status !== 'unavailable') await optionalCache(() => cache.put(policyKey(comparison), 'policy', input.policy, generation)); + const projection = humanReport(report, policy, undefined, errors); + if (!projection.ok) throw new ExtensionError('REPORT_VIEW', projection.diagnostics.map(item => item.message).join('\n')); + const invalidDigest = policy ? policy.digest : Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify([selected, errors])))), byte => byte.toString(16).padStart(2, '0')).join(''); + const key = `${rawKey}:${invalidDigest}`; + const packet: Packet = { key, comparison, view: decorateView(projection.value, settings), files: report.files.map(file => ({ path: file.path, ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }) })), refreshedAt: cached?.at ?? Date.now(), cached: Boolean(cached) }; + if (generation === cache.generation) { + contexts.delete(key); contexts.set(key, { packet, report, ...(policy ? { policy } : {}), settings, files: new Map() }); + while (contexts.size > 6) contexts.delete(contexts.keys().next().value!); + await chrome.storage.session.set({ lastComparison: { ...comparison, at: packet.refreshedAt } }); + } + return packet; +} +async function diagnostics(): Promise { + const [info, localBytes, syncBytes, errors, last] = await Promise.all([cache.summary(), chrome.storage.local.getBytesInUse(null), chrome.storage.sync.getBytesInUse(null), chrome.storage.local.get('diagnosticCodes'), chrome.storage.session.get('lastComparison')]); + return { version: chrome.runtime.getManifest().version, engine: __ENGINE_VERSION__, schema: '1.0', measurement: SEMANTICS.replacementLines, presenter: HUMAN_VIEW_VERSION, cache: info, localBytes, syncBytes, + errors: Array.isArray(errors.diagnosticCodes) ? errors.diagnosticCodes as Diagnostics['errors'] : [], ...(last.lastComparison ? { last: last.lastComparison as NonNullable } : {}) }; +} +async function handle(message: Message, sender: chrome.runtime.Sender): Promise { + const scope = authorize(sender, message, chrome.runtime.id); + switch (message.type) { + case 'settings.get': return preferences.load(); + case 'settings.save': { const result = await preferences.save(message.patch, message.replace === true); contexts.clear(); await optionalCache(() => cache.configure(Number(result['cache.maximumSize']))); return result; } + case 'policy.templates': { const result = requiredTemplates(JSON.stringify(message.layers)); return result.ok ? result.value.slice(0, 16) : []; } + case 'source.public': return provider.pull(message.repository, message.pullRequest, message.files === true); + case 'source.policy': return provider.policy(message.repository, message.base, message.path); + case 'cache.lookup': { + const comparison = readComparison(message.comparison); const settings = await preferences.load(); + const [report, policy] = await Promise.all([optionalCache(() => cache.get(reportKey(comparison))), optionalCache(() => cache.get(policyKey(comparison)))]); + return { settings, selected: selectedPolicy(settings, comparison.repository), reportCached: Boolean(report), ...(policy ? { policy } : {}) }; + } + case 'analysis.run': { + const settings = await preferences.load(); + // Coalesce identical inputs only; a different personal policy must never join. + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify([message.input, settings]))); + const key = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); + const existing = inFlight.get(key); if (existing) return existing; + if (inFlight.size >= 3) throw new ExtensionError('ANALYSIS_BUSY', 'Three analyses are already active. Retry when one completes.'); + const operation = analyze(message.input, settings).finally(() => { inFlight.delete(key); }); inFlight.set(key, operation); return operation; + } + case 'analysis.files': { + if (!Array.isArray(message.paths) || message.paths.length > 24 || message.paths.some(path => typeof path !== 'string' || path.length > 4096)) throw new ExtensionError('FILE_LIMIT', 'Request at most 24 visible files.'); + const context = contexts.get(message.key); if (!context) throw new ExtensionError('CONTEXT_EXPIRED', 'The analysis context was evicted or the worker restarted. Refresh the report.'); + if (!scope.options && (context.packet.comparison.repository.toLowerCase() !== scope.repository || context.packet.comparison.pullRequest !== scope.pullRequest)) throw new ExtensionError('SENDER_SCOPE', 'This report belongs to a different pull request.'); + const result: Record = Object.create(null) as Record; + for (const path of message.paths) { + const file = context.report.files.find(file => file.path === path || file.oldPath === path); if (!file) continue; + let view = context.files.get(file.path); + if (!view) { + const projection = humanReport(context.report, context.policy, file.path, context.packet.view.errors); + if (!projection.ok) throw new ExtensionError('FILE_VIEW', projection.diagnostics.map(item => item.message).join('\n')); + view = decorateView(projection.value, context.settings); context.files.set(file.path, view); + // Visible files are cheap to rebuild; retain at most 150 projections. + while (context.files.size > 150) context.files.delete(context.files.keys().next().value!); + } + result[path] = view; + } + return result; + } + case 'diagnostics.get': return diagnostics(); + case 'options.open': await chrome.runtime.openOptionsPage(); return null; + case 'data.action': { + if (['data.clearRepositoryOverrides', 'data.resetAll'].includes(message.action) && message.confirmed !== true) throw new ExtensionError('CONFIRMATION_REQUIRED', 'Confirm this destructive operation in Settings.'); + switch (message.action) { + case 'data.clearAnalysisCache': await cache.clear('report'); contexts.clear(); break; + case 'data.clearPolicyCache': await cache.clear('policy'); contexts.clear(); break; + case 'data.clearRepositoryOverrides': await preferences.save({ 'policy.repositoryOverrides': '{}' }); contexts.clear(); break; + case 'data.resetAll': await cache.clear(); contexts.clear(); await preferences.reset(); break; + case 'diagnostics.copySupportSnapshot': { + const [info, settings] = await Promise.all([diagnostics(), preferences.load()]); const { last, ...safeInfo } = info; + return { kind: 'diffdevil.support/1', ...safeInfo, adapter: BROWSER_ADAPTER, sourcePresent: Boolean(last), advancedActive: advancedChanges(settings).map(item => item.id), + settings: Object.fromEntries(Object.entries(settings).map(([id, value]) => [id, id.startsWith('policy.') && !['policy.mode', 'policy.preset', 'policy.primaryMetric'].includes(id) ? { redacted: true, bytes: bytes(value) } : value])) }; + } + default: throw new ExtensionError('UNKNOWN_ACTION', 'Unknown data-management action.'); + } + return diagnostics(); + } + default: throw new ExtensionError('UNKNOWN_REQUEST', 'Unsupported extension request.'); + } +} +chrome.runtime.onMessage.addListener((input, sender, reply) => { + if (!input || typeof input !== 'object' || typeof (input as Record).type !== 'string') { reply({ ok: false, code: 'INVALID_REQUEST', message: 'Invalid extension request.' }); return; } + void (async () => { + try { + if (bytes(input) > 24 * 1024 * 1024) throw new ExtensionError('MESSAGE_LIMIT', 'The request exceeds 24 MiB.'); + reply({ ok: true, value: await handle(input as Message, sender) }); + } catch (error) { + await recordError(error).catch(() => undefined); + reply({ ok: false, code: error instanceof ExtensionError ? error.code : 'EXTENSION_OPERATION', message: error instanceof Error ? error.message : 'The extension operation failed.' }); + } + })(); return true; +}); +// Restrict direct storage access on every worker start as well as installation. +for (const area of [chrome.storage.local, chrome.storage.sync, chrome.storage.session]) void area.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' }).catch(error => { void recordError(error).catch(() => undefined); }); +chrome.action.onClicked.addListener(() => { void chrome.runtime.openOptionsPage(); }); diff --git a/apps/browser-extension/src/chrome.d.ts b/apps/browser-extension/src/chrome.d.ts new file mode 100644 index 00000000..ed2c3171 --- /dev/null +++ b/apps/browser-extension/src/chrome.d.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** The explicit subset used by this application; no broad browser authority. */ +declare namespace chrome { + namespace runtime { + const id: string; + function getURL(path: string): string; + function getManifest(): { version: string; name: string; version_name?: string }; + function openOptionsPage(): Promise; + function sendMessage(input: unknown): Promise; + interface Sender { id?: string; url?: string; frameId?: number; tab?: { id?: number; url?: string; incognito?: boolean } } + const onMessage: { addListener(listener: (input: unknown, sender: Sender, reply: (value: unknown) => void) => boolean | void): void }; + const onInstalled: { addListener(listener: () => void): void }; + } + namespace storage { + interface Area { get(keys?: string | string[] | null): Promise>; set(items: Record): Promise; remove(keys: string | string[]): Promise; clear(): Promise; getBytesInUse(keys?: string | string[] | null): Promise; setAccessLevel(options: { accessLevel: 'TRUSTED_CONTEXTS' }): Promise } + const sync: Area; const local: Area; const session: Area; + const onChanged: { addListener(listener: (changes: Record, area: string) => void): void; removeListener(listener: (changes: Record, area: string) => void): void }; + } + namespace action { const onClicked: { addListener(listener: () => void): void } } +} diff --git a/apps/browser-extension/src/content/acquire.ts b/apps/browser-extension/src/content/acquire.ts new file mode 100644 index 00000000..118728f0 --- /dev/null +++ b/apps/browser-extension/src/content/acquire.ts @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import type { BrowserComparison, BrowserInput } from '@wolfsblvt/diffdevil/browser'; +import { request, type Lookup, type Packet, type PolicySource, type PublicPull } from '../shared/protocol.js'; +import { boundedText, ExtensionError, safePath } from '../shared/errors.js'; +import { blobText, pageComparison, sameComparison, type Route } from './github.js'; +const LIMIT = 8 * 1024 * 1024; +async function html(path: string, signal: AbortSignal): Promise<{ response: Response; document: Document }> { + const response = await fetch(path, { credentials: 'same-origin', cache: 'no-store', signal: AbortSignal.any([signal, AbortSignal.timeout(20_000)]), headers: { Accept: 'text/html' } }); + const url = new URL(response.url); + if (url.origin !== 'https://github.com' || /^\/(?:login|session|sessions)(?:\/|$)/u.test(url.pathname)) throw new ExtensionError('SIGNED_IN_UNAVAILABLE', 'The signed-in GitHub page is unavailable.'); + if (!response.ok && response.status !== 404) throw new ExtensionError('GITHUB_HTTP', `GitHub returned HTTP ${response.status}.`); + return { response, document: new DOMParser().parseFromString(await boundedText(response, LIMIT), 'text/html') }; +} +async function policyFile(comparison: BrowserComparison, path: string, signal: AbortSignal, publicAccess: boolean): Promise { + try { + const result = await html(`/${comparison.repository}/blob/${comparison.base}/${safePath(path)}`, signal); + if (result.response.status === 404) { + const base = await html(`/${comparison.repository}/commit/${comparison.base}`, signal); + if (!base.response.ok) throw new ExtensionError('BASE_ACCESS', 'Access to the exact trusted base could not be confirmed.'); + return { status: 'absent', at: Date.now() }; + } + const text = blobText(result.document); + if (text === undefined || new TextEncoder().encode(text).byteLength > 256 * 1024) throw new ExtensionError('POLICY_BODY', 'GitHub did not expose complete policy text.'); + return { status: 'present', text, at: Date.now() }; + } catch (error) { + if (signal.aborted || !publicAccess) throw error; + return request({ type: 'source.policy', repository: comparison.repository, base: comparison.base, path }); + } +} +/** Reads source through the signed-in page; all analysis runs in the extension worker. */ +export async function acquire(current: Route, document: Document, signal: AbortSignal): Promise<{ packet: Packet; settings: Lookup['settings'] }> { + let comparison = pageComparison(document, current); let publicResult: PublicPull | undefined; + if (!comparison) { + try { const refreshed = await html(current.path, signal); if (refreshed.response.ok) comparison = pageComparison(refreshed.document, current); } + catch (error) { if (signal.aborted) throw error; } + } + if (!comparison) { + publicResult = await request({ type: 'source.public', repository: current.repository, pullRequest: current.pullRequest, files: true }); comparison = publicResult.comparison; + } + const lookup = await request({ type: 'cache.lookup', comparison }); if (signal.aborted) throw signal.reason; + let acquisition: BrowserInput | undefined; + if (!lookup.reportCached) { + if (publicResult?.files) acquisition = { comparison, format: 'github-files', files: publicResult.files, complete: publicResult.files.length === comparison.changedFiles }; + else { + try { + const response = await fetch(`${current.path}.diff`, { credentials: 'same-origin', cache: 'no-store', signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]), headers: { Accept: 'text/plain' } }); + if (!response.ok || !['https://github.com', 'https://patch-diff.githubusercontent.com'].includes(new URL(response.url).origin) || /text\/html/iu.test(response.headers.get('Content-Type') ?? '')) throw new ExtensionError('DIFF_UNAVAILABLE', 'The GitHub diff route did not return source material.'); + const text = await boundedText(response, LIMIT); + if (text.trim() && !text.startsWith('diff --git ')) throw new ExtensionError('DIFF_FORMAT', 'The provider response is not a unified diff.'); + acquisition = { comparison, format: 'diff', text, complete: true }; + } catch (error) { + if (signal.aborted) throw error; + publicResult = await request({ type: 'source.public', repository: current.repository, pullRequest: current.pullRequest, files: true }); + if (!sameComparison(comparison, publicResult.comparison)) throw new ExtensionError('COMPARISON_MOVED', 'The pull request changed during acquisition. Refresh the comparison.'); + comparison = publicResult.comparison; + acquisition = { comparison, format: 'github-files', files: publicResult.files ?? [], complete: publicResult.files?.length === comparison.changedFiles }; + } + } + } + let policy: PolicySource = { status: 'unavailable', at: Date.now() }; + if (lookup.selected.mode !== 'personal-only') { + try { policy = lookup.policy ?? await policyFile(comparison, '.diffdevil.yml', signal, Boolean(publicResult)); } + catch (error) { if (signal.aborted) throw error; } + } + const selected = { ...lookup.selected, ...(policy.status === 'present' ? { repository: policy.text! } : {}) }; + const templates = await request({ type: 'policy.templates', layers: selected }); const sources: Record = Object.create(null) as Record; + for (const path of templates) { + try { const value = await policyFile(comparison, path, signal, Boolean(publicResult)); if (value.status === 'present' && value.text !== undefined) sources[path] = value.text; } + catch (error) { if (signal.aborted) throw error; } // The compiler reports missing trusted template text. + } + if (!publicResult) { + const after = await html(current.path, signal); const observed = pageComparison(after.document, current); + if (!after.response.ok || !observed || !sameComparison(comparison, observed) || comparison.changedFiles !== observed.changedFiles) throw new ExtensionError('COMPARISON_MOVED', 'GitHub did not confirm the same base, head and file count after acquisition.'); + } else { + const after = await request({ type: 'source.public', repository: current.repository, pullRequest: current.pullRequest }); + if (!sameComparison(comparison, after.comparison) || comparison.changedFiles !== after.comparison.changedFiles) throw new ExtensionError('COMPARISON_MOVED', 'The comparison changed while policy was acquired.'); + } + if (signal.aborted) throw signal.reason; + const packet = await request({ type: 'analysis.run', input: { comparison, ...(acquisition ? { acquisition } : {}), policy, ...(templates.length ? { templates: sources } : {}) } }); + if (signal.aborted) throw signal.reason; return { packet, settings: lookup.settings }; +} diff --git a/apps/browser-extension/src/content/content.css b/apps/browser-extension/src/content/content.css new file mode 100644 index 00000000..5679188d --- /dev/null +++ b/apps/browser-extension/src/content/content.css @@ -0,0 +1,8 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +.ddx-root,.ddx-popover,.ddx-status{font-family:var(--body-font,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif);font-size:12px;color:var(--fgColor-default,var(--color-fg-default,#1f2328));box-sizing:border-box;line-height:1.5} +.ddx-root{display:inline-flex;align-items:center;gap:7px;margin-inline:8px;vertical-align:middle;flex-wrap:wrap;text-align:left;max-width:100%}.ddx-root[data-density=comfortable]{gap:11px;margin-block:5px}.ddx-column{display:inline-flex;flex-direction:column}.ddx-trigger{display:inline-flex;align-items:center;gap:5px;padding:2px 3px;border:1px solid transparent;border-radius:6px;background:transparent;color:inherit;font:inherit;cursor:pointer;text-align:left;white-space:nowrap}.ddx-trigger:hover,.ddx-trigger[aria-expanded=true]{background:var(--bgColor-muted,var(--color-canvas-subtle,#f6f8fa));border-color:var(--borderColor-default,var(--color-border-default,#d1d9e0))}.ddx-trigger:focus-visible,.ddx-cell:focus-visible,.ddx-close:focus-visible,.ddx-small:focus-visible,.ddx-warning:focus-visible{outline:2px solid var(--focus-outlineColor,#0969da);outline-offset:2px}.ddx-primary-label{font-weight:600}.ddx-value{font-size:14px;font-weight:650;font-variant-numeric:tabular-nums}.ddx-breakdown{display:inline-flex;gap:5px;margin-inline-start:3px;font-variant-numeric:tabular-nums}.ddx-added{color:var(--fgColor-success,var(--color-success-fg,#1a7f37))}.ddx-deleted{color:var(--fgColor-danger,var(--color-danger-fg,#cf222e))}.ddx-modified{color:inherit}.ddx-raw,.ddx-local,.ddx-evidence,.ddx-label-status{font-size:11px;color:var(--fgColor-muted,var(--color-fg-muted,#59636e))}.ddx-raw{padding-inline-start:3px;font-variant-numeric:tabular-nums}.ddx-pill{font-size:11px;border:1px solid var(--borderColor-default,var(--color-border-default,#d1d9e0));border-radius:20px;padding:0 7px;white-space:nowrap}.ddx-rail{display:inline-flex;gap:3px;align-items:center;flex-wrap:wrap;max-width:260px}.ddx-cell{--ddx-band:var(--fgColor-muted,#6e7781);display:block;width:9px;height:9px;border-radius:1px;border:1px solid color-mix(in srgb,var(--ddx-band) 50%,var(--borderColor-default,#d1d9e0));background:color-mix(in srgb,var(--ddx-band) 22%,transparent)}.ddx-cell.ddx-selected{width:11px;height:11px;background:var(--ddx-band);border:2px solid var(--fgColor-default,#1f2328)} +.ddx-native-dim,.ddx-native-dim .color-fg-success,.ddx-native-dim .color-fg-danger,.ddx-native-dim .text-green,.ddx-native-dim .text-red{color:var(--fgColor-muted,var(--color-fg-muted,#59636e))!important}.ddx-native-dim .diffstat-block-added,.ddx-native-dim .diffstat-block-deleted{background-color:color-mix(in srgb,var(--fgColor-muted,#6e7781) 35%,transparent)!important}.ddx-native-hidden{display:none!important} +.ddx-brand{display:inline-flex;flex:none;width:16px;height:16px;vertical-align:middle}.ddx-brand img{width:16px;height:16px}.ddx-brand-mono{background:currentColor;mask-repeat:no-repeat;mask-size:contain;mask-position:center;-webkit-mask-repeat:no-repeat;-webkit-mask-size:contain;-webkit-mask-position:center} +.ddx-popover{position:fixed;z-index:1000;width:540px;padding:18px 20px;overflow:auto;overscroll-behavior:contain;background:var(--bgColor-default,var(--color-canvas-default,#fff));border:1px solid var(--borderColor-default,var(--color-border-default,#d1d9e0));border-radius:10px;box-shadow:var(--shadow-floating-medium,0 8px 32px #0003);overflow-wrap:anywhere}.ddx-popover *{box-sizing:border-box}.ddx-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}.ddx-heading h2{font-size:16px;font-weight:600;line-height:1.4;margin:0}.ddx-close{border:0;background:transparent;color:inherit;cursor:pointer;border-radius:4px;padding:0 5px;font-size:24px;line-height:1}.ddx-source{margin:7px 0 0;font-size:12px}.ddx-revisions{font:11px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--fgColor-muted,#59636e);margin:2px 0 18px}.ddx-report-primary{display:flex;gap:10px;align-items:baseline;font-size:14px}.ddx-report-primary strong{font-size:30px;font-weight:650;letter-spacing:-.025em;font-variant-numeric:tabular-nums}.ddx-report-parts{margin:0 0 18px}.ddx-facts{margin:15px 0}.ddx-facts h3,.ddx-file-name,.ddx-policy-error h3{font-size:12px;font-weight:650;margin:0 0 6px}.ddx-facts dl{display:grid;grid-template-columns:minmax(95px,1fr) minmax(0,2.5fr);gap:3px 12px;margin:0}.ddx-facts dt{color:var(--fgColor-muted,#59636e);font-weight:400}.ddx-facts dd{margin:0;font-variant-numeric:tabular-nums}.ddx-notice{border-inline-start:2px solid var(--fgColor-attention,#9a6700);padding-inline-start:10px;margin:12px 0}.ddx-details{margin:12px 0}.ddx-details summary{cursor:pointer;font-weight:600}.ddx-details pre{font-size:11px;white-space:pre-wrap;max-height:240px;overflow:auto;padding:8px;background:var(--bgColor-muted,#f6f8fa);border-radius:6px}.ddx-policy-error{border:1px solid var(--borderColor-attention-emphasis,#9a6700);border-radius:6px;padding:10px;margin:14px 0}.ddx-policy-error p{margin:4px 0}.ddx-report-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:16px;border-top:1px solid var(--borderColor-default,#d1d9e0);padding-top:12px}.ddx-report-footer a{color:var(--fgColor-accent,#0969da)}.ddx-small,.ddx-warning{border:1px solid var(--borderColor-default,#d1d9e0);border-radius:5px;padding:2px 7px;background:var(--bgColor-default,#fff);color:inherit;cursor:pointer;font:inherit;font-size:11px}.ddx-warning{color:var(--fgColor-attention,#9a6700)}.ddx-status{display:inline-flex;align-items:center;gap:8px;margin:6px 8px;max-width:650px}.ddx-error{color:var(--fgColor-attention,#9a6700)} +@media(max-width:700px){.ddx-root{margin-block:4px}.ddx-trigger{flex-wrap:wrap;white-space:normal}.ddx-popover{padding:14px}.ddx-facts dl{grid-template-columns:minmax(70px,1fr) minmax(0,2fr)}} +@media(forced-colors:active){.ddx-cell{border-color:CanvasText}.ddx-cell.ddx-selected{background:Highlight;border-color:Highlight}.ddx-brand-mono{background:CanvasText}} diff --git a/apps/browser-extension/src/content/controller.ts b/apps/browser-extension/src/content/controller.ts new file mode 100644 index 00000000..d45e6420 --- /dev/null +++ b/apps/browser-extension/src/content/controller.ts @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import type { HumanReportView } from '@wolfsblvt/diffdevil/browser'; +import { DEFAULTS, type Settings } from '../shared/catalogue.js'; +import { request as defaultRequest, type Packet } from '../shared/protocol.js'; +import { SETTINGS_KEY } from '../shared/settings-key.js'; +import { node, button } from '../shared/dom.js'; +import { acquire as defaultAcquire } from './acquire.js'; +import { route, aggregateHost, FILE_HEADERS, filePath, pageComparison, sameComparison, fullFilesView } from './github.js'; +import { projection, reportPanel, type Projection } from './render.js'; +import { Popover } from './popover.js'; +/** Dependencies are explicit so lifecycle tests never need to impersonate a browser origin. */ +export interface ContentDependencies { readonly href?: () => string; readonly acquire?: typeof defaultAcquire; readonly request?: typeof defaultRequest } +export function startContent(dependencies: ContentDependencies = {}): { refresh: () => Promise; stop: () => void } { + const href = dependencies.href ?? (() => location.href); + const acquire = dependencies.acquire ?? defaultAcquire; + const request = dependencies.request ?? defaultRequest; + const lifecycle = new AbortController(); let stopped = false; +const popover = new Popover(); const mounted = new Map(); const fileViews = new Map(); const waiting = new Set(); const failed = new Set(); +let settings: Settings = { ...DEFAULTS }; let packet: Packet | undefined; let controller: AbortController | undefined; +let generation = 0; let activeRoute = ''; let identityInFlight = ''; let failedIdentity = ''; let acquiring = false; let fileBusy = false; let renderQueued = false; let opened = false; +let status: HTMLElement | undefined; let fileError = false; let observedRoot: Element | undefined; let observer: MutationObserver | undefined; +const own = (node: Node): boolean => node instanceof Element && Boolean(node.closest('.ddx-root, .ddx-popover, .ddx-status')); +function clear(): void { + for (const item of mounted.values()) item.cleanup(); mounted.clear(); popover.close(false); status?.remove(); status = undefined; + for (const node of document.querySelectorAll('.ddx-native-dim, .ddx-native-hidden')) node.classList.remove('ddx-native-dim', 'ddx-native-hidden'); +} +function showStatus(message: string, error = false): void { + const host = aggregateHost(document); if (!host) return; + status?.remove(); status = node('span', `ddx-status${error ? ' ddx-error' : ''}`, message); status.setAttribute('role', 'status'); + if (error) status.append(button('Retry', () => { void refresh(true); }, 'ddx-small')); host.insertAdjacentElement('afterend', status); +} +function nativeStat(host: HTMLElement): void { + const candidates = host.matches('.diffstat, #diffstat, [data-testid$="diff-stats"]') ? [host] : [...host.querySelectorAll('.diffstat, [data-testid$="diff-stats"]')].filter(element => !own(element)); + for (const element of candidates) { element.classList.toggle('ddx-native-hidden', Boolean(settings['display.hideNativeDiffstat'])); element.classList.toggle('ddx-native-dim', Boolean(settings['display.dimNativeDiffstat']) && !settings['display.hideNativeDiffstat']); } +} +function mount(host: HTMLElement, view: HumanReportView, aggregate: boolean): void { + if (mounted.has(host)) return; + const item = projection(view, settings, popover, aggregate); mounted.set(host, item); + const interactive = host.closest('button, a'); + if (interactive) interactive.insertAdjacentElement('afterend', item.root); else if (aggregate) host.insertAdjacentElement('afterend', item.root); else host.append(item.root); + nativeStat(host); + if (aggregate && settings['display.detailDefaultOpen'] && !opened) { opened = true; popover.toggle(item.trigger, reportPanel(view, popover)); } +} +function schedule(): void { if (stopped || renderQueued) return; renderQueued = true; queueMicrotask(() => { renderQueued = false; render(); }); } +function render(): void { + if (stopped) return; + popover.reconcile(); for (const [host, item] of mounted) if (!host.isConnected || !item.root.isConnected || !fullFilesView(href()) && item.root.dataset.ddx === 'file') { item.cleanup(); mounted.delete(host); } + if (!packet || acquiring || !settings['display.enabled']) return; + if (!fileError) { status?.remove(); status = undefined; } + const aggregate = aggregateHost(document); if (aggregate) { nativeStat(aggregate); if (settings['display.aggregateChanged']) mount(aggregate, packet.view, true); } + if (fullFilesView(href())) for (const header of document.querySelectorAll(FILE_HEADERS)) { + nativeStat(header); if (!settings['display.fileChanged']) continue; + const path = filePath(header); if (!path || mounted.has(header)) continue; const view = fileViews.get(path); + if (view) mount(header, view, false); else if (!failed.has(path) && packet.files.some(file => file.path === path || file.oldPath === path)) waiting.add(path); + } + if (waiting.size && !fileBusy) void renderFiles(); +} +async function renderFiles(): Promise { + if (!packet || fileBusy) return; fileBusy = true; const revision = generation; const paths = [...waiting].slice(0, 24); paths.forEach(path => waiting.delete(path)); + try { + const result = await request>({ type: 'analysis.files', key: packet.key, paths }); if (revision !== generation) return; + for (const [path, view] of Object.entries(result)) fileViews.set(path, view); for (const path of paths) if (!Object.hasOwn(result, path)) failed.add(path); + } catch (error) { + if (revision !== generation) return; + if ((error as { code?: string }).code === 'CONTEXT_EXPIRED') { void refresh(true); return; } + paths.forEach(path => failed.add(path)); fileError = true; showStatus(error instanceof Error ? error.message : 'File evidence is unavailable.', true); + } finally { fileBusy = false; schedule(); } +} +function observe(): void { + const root = document.querySelector('#repo-content-pjax-container, main, [role="main"]') ?? document.body; + if (root === observedRoot && observer) return; observer?.disconnect(); observedRoot = root; + observer = new MutationObserver(records => { + let providerChanged = false; let identityChanged = false; + for (const record of records) { + if (own(record.target)) continue; + if (record.type === 'attributes' || record.target instanceof Element && record.target.matches('script[type="application/json"], input#pull_request_head_sha, input#pull_request_base_sha')) { identityChanged = true; continue; } + for (const added of [...record.addedNodes, ...record.removedNodes]) { + if (!(added instanceof Element) || own(added)) continue; + if (added.matches('script[type="application/json"]') || added.querySelector('script[type="application/json"]')) identityChanged = true; + if (!added.isConnected || added.matches(`${FILE_HEADERS}, .gh-header-meta, .diffstat`) || added.querySelector(`${FILE_HEADERS}, .gh-header-meta, .diffstat`)) providerChanged = true; + } + } + if (identityChanged) void refresh(false); else if (providerChanged) schedule(); + }); + observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-current-head-oid', 'data-base-ref-oid', 'value'] }); +} +async function refresh(force: boolean): Promise { + if (stopped) return; + const current = route(href()); + if (!current) { generation++; controller?.abort(); clear(); packet = undefined; activeRoute = ''; acquiring = false; observer?.disconnect(); observedRoot = undefined; return; } + observe(); const key = `${current.repository.toLowerCase()}#${current.pullRequest}`; const identity = pageComparison(document, current); const identityKey = `${key}:${identity?.base ?? '?'}:${identity?.head ?? '?'}`; + if (!force && failedIdentity === identityKey) return; + if (!force && packet && activeRoute === key && identity && sameComparison(packet.comparison, identity)) { schedule(); return; } + if (!force && acquiring && activeRoute === key && (!identity || identityInFlight === identityKey)) return; + const revision = ++generation; controller?.abort(); const signal = (controller = new AbortController()).signal; + acquiring = true; activeRoute = key; identityInFlight = identityKey; clear(); fileViews.clear(); waiting.clear(); failed.clear(); opened = false; fileError = false; + try { + settings = await request({ type: 'settings.get' }); if (revision !== generation) return; + if (!settings['display.enabled']) { packet = undefined; return; } + showStatus('Changed · reading comparison…'); const result = await acquire(current, document, signal); + if (revision !== generation || signal.aborted || !route(href())) return; + packet = result.packet; settings = result.settings; failedIdentity = ''; + } catch (error) { + if (revision !== generation || signal.aborted) return; + packet = undefined; failedIdentity = identityKey; showStatus(`Changed ? · ${error instanceof Error ? error.message : 'Source is unavailable.'}`, true); + } finally { if (revision === generation) { acquiring = false; schedule(); } } +} +let navigationQueued = false; +const navigated = (): void => { if (navigationQueued) return; navigationQueued = true; queueMicrotask(() => { navigationQueued = false; void refresh(false); }); }; +for (const event of ['turbo:load', 'pjax:end', 'soft-nav:payload', 'soft-nav:end', 'popstate']) { document.addEventListener(event, navigated, { signal: lifecycle.signal }); window.addEventListener(event, navigated, { signal: lifecycle.signal }); } +// This outer watcher only checks whether the observed root was detached. +const rootWatcher = new MutationObserver(() => { if (observedRoot && !observedRoot.isConnected) { observe(); navigated(); } }); +rootWatcher.observe(document.body, { childList: true, subtree: true }); +const changed = (changes: Record, area: string): void => { if ((area === 'sync' || area === 'local') && Object.hasOwn(changes, SETTINGS_KEY)) void refresh(true); }; +chrome.storage.onChanged.addListener(changed); +const refreshTheme = (): void => { if (packet && !acquiring) { clear(); schedule(); } }; +const themeWatcher = new MutationObserver(refreshTheme); +themeWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['data-color-mode', 'data-theme', 'data-dark-theme', 'data-light-theme'] }); +matchMedia('(prefers-color-scheme: dark)').addEventListener('change', refreshTheme, { signal: lifecycle.signal }); +void refresh(false); +return { refresh: () => refresh(true), stop: (): void => { + stopped = true; generation++; controller?.abort(); lifecycle.abort(); observer?.disconnect(); rootWatcher.disconnect(); themeWatcher.disconnect(); chrome.storage.onChanged.removeListener(changed); clear(); +} }; +} diff --git a/apps/browser-extension/src/content/github.ts b/apps/browser-extension/src/content/github.ts new file mode 100644 index 00000000..bfb3038b --- /dev/null +++ b/apps/browser-extension/src/content/github.ts @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import type { BrowserComparison } from '@wolfsblvt/diffdevil/browser'; +export interface Route { repository: string; pullRequest: number; path: string } +export function route(url: string): Route | undefined { + const parsed = new URL(url); if (parsed.origin !== 'https://github.com') return undefined; + const match = /^\/([a-z0-9_.-]+\/[a-z0-9_.-]+)\/pull\/([1-9]\d*)(?:\/|$)/iu.exec(parsed.pathname); + if (!match || match[1]!.split('/').some(part => part === '.' || part === '..') || !Number.isSafeInteger(Number(match[2]))) return undefined; + return { repository: match[1]!, pullRequest: Number(match[2]), path: `/${match[1]}/pull/${match[2]}` }; +} +export function providerObjects(document: Document): readonly Record[] { + const result: Record[] = []; + for (const script of [...document.querySelectorAll('script[type="application/json"]')].slice(0, 20)) { + const text = script.textContent ?? ''; if (text.length > 4 * 1024 * 1024) continue; + try { + const queue = [{ value: JSON.parse(text) as unknown, depth: 0 }]; let offset = 0; + while (offset < queue.length && offset < 25_000) { + const item = queue[offset++]!; if (!item.value || typeof item.value !== 'object' || item.depth > 15) continue; + if (!Array.isArray(item.value)) result.push(item.value as Record); + for (const value of Object.values(item.value)) if (value && typeof value === 'object' && queue.length < 25_000) queue.push({ value, depth: item.depth + 1 }); + } + } catch { /* Other scripts do not establish comparison evidence. */ } + } + return result; +} +const sha = (value: unknown): string | undefined => typeof value === 'string' && /^[a-f0-9]{40}$/u.test(value) ? value : undefined; +const count = (value: unknown): number | undefined => Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : undefined; +function fileCount(document: Document, current: Route): number | undefined { + for (const link of document.querySelectorAll('nav a[href], .tabnav-tab[href]')) { + if (new URL(link.getAttribute('href')!, 'https://github.com').pathname !== `${current.path}/files`) continue; + const text = link.querySelector('.Counter, [data-testid="counter"]')?.textContent?.trim().replaceAll(',', ''); + if (text && /^\d+$/u.test(text)) return count(Number(text)); + } + return undefined; +} +export function pageComparison(document: Document, current: Route): BrowserComparison | undefined { + for (const object of providerObjects(document)) { + const base = sha(object.baseRefOid ?? object.baseOid ?? object.baseSha); const head = sha(object.headRefOid ?? object.headOid ?? object.headSha); + if (!base || !head || object.number !== undefined && object.number !== current.pullRequest) continue; + const files = count(object.changedFiles ?? object.changed_files ?? object.changedFileCount) ?? fileCount(document, current); + const additions = count(object.additions); const deletions = count(object.deletions); + return { host: 'github.com', repository: current.repository, pullRequest: current.pullRequest, base, head, + ...(files === undefined ? {} : { changedFiles: files }), ...(additions === undefined ? {} : { additions }), ...(deletions === undefined ? {} : { deletions }) }; + } + const value = (selector: string, attr = 'value'): string | undefined => document.querySelector(selector)?.getAttribute(attr) ?? undefined; + const base = sha(value('input#pull_request_base_sha')) ?? sha(value('[data-base-ref-oid]', 'data-base-ref-oid')) ?? sha(value('meta[name="pull-request-base-sha"]', 'content')); + const head = sha(value('input#pull_request_head_sha')) ?? sha(value('[data-current-head-oid]', 'data-current-head-oid')) ?? sha(value('meta[name="pull-request-head-sha"]', 'content')); + const files = fileCount(document, current); + return base && head ? { host: 'github.com', repository: current.repository, pullRequest: current.pullRequest, base, head, ...(files === undefined ? {} : { changedFiles: files }) } : undefined; +} +export function sameComparison(a: BrowserComparison, b: BrowserComparison): boolean { return a.repository.toLowerCase() === b.repository.toLowerCase() && a.pullRequest === b.pullRequest && a.base === b.base && a.head === b.head; } +export const FILE_HEADERS = '.file-header, [data-testid="file-header"], [data-test-selector="file-header"], [data-testid="diff-file-header"]'; +export function filePath(header: Element): string | undefined { + const ancestor = header.closest('[data-path], [data-file-path]'); + return header.getAttribute('data-path') ?? header.getAttribute('data-file-path') ?? ancestor?.getAttribute('data-path') ?? ancestor?.getAttribute('data-file-path') + ?? header.querySelector('[data-tagsearch-path]')?.getAttribute('data-tagsearch-path') ?? header.querySelector('a[title]')?.getAttribute('title') ?? undefined; +} +export function aggregateHost(document: Document): HTMLElement | undefined { + const selectors = '[data-testid="pull-request-diff-stats"], [data-testid="pr-diff-stats"], #diffstat, .gh-header-meta .diffstat, #partial-discussion-header .diffstat, .pr-toolbar .diffstat'; + for (const element of document.querySelectorAll(selectors)) if (!element.closest(FILE_HEADERS)) return element; + return document.querySelector('#partial-discussion-header .gh-header-meta, .gh-header-meta, [data-testid="pull-request-header"]') ?? undefined; +} +export function blobText(document: Document): string | undefined { + for (const value of providerObjects(document)) { + if (value.isTruncated === true || value.truncated === true || value.isBinary === true) continue; + if (Array.isArray(value.rawLines) && value.rawLines.every(line => typeof line === 'string')) return value.rawLines.join('\n'); + if (typeof value.rawBlob === 'string') return value.rawBlob; + } + const lines = document.querySelectorAll('.js-file-line.blob-code, .blob-code-inner.js-file-line'); + return lines.length ? [...lines].map(line => line.textContent ?? '').join('\n') : undefined; +} + +/** Aggregate is always the full PR; never put full-PR file counts on commit-only diffs. */ +export function fullFilesView(url: string): boolean { + const current = route(url); if (!current) return false; + const parsed = new URL(url); + return parsed.pathname.replace(/\/$/u, '') === `${current.path}/files` + && !['base_oid', 'head_oid', 'commit', 'sha', 'base', 'head'].some(key => parsed.searchParams.has(key)); +} diff --git a/apps/browser-extension/src/content/labels.ts b/apps/browser-extension/src/content/labels.ts new file mode 100644 index 00000000..87681f9f --- /dev/null +++ b/apps/browser-extension/src/content/labels.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-only +const ROOTS = ['.js-issue-labels', '.sidebar-labels', '[data-testid="issue-labels"]', '[data-testid="labels-section"]']; +export function observedLabels(document: Document): readonly string[] { + return [...document.querySelectorAll(ROOTS.flatMap(root => [`${root} .IssueLabel`, `${root} [data-testid="issue-label"]`]).join(','))].map(node => node.getAttribute('data-name') ?? node.textContent?.trim() ?? '').filter(Boolean); +} +function trigger(document: Document): HTMLElement | undefined { + const elements = document.querySelectorAll('#labels-select-menu summary, .js-issue-labels summary, .sidebar-labels summary, button[aria-label="Edit labels"], [data-testid="labels-section"] button'); + return [...elements].find(element => !element.matches('[disabled], [aria-disabled="true"]') && !element.closest('[hidden], [inert]') && element.getClientRects().length > 0); +} +export const pickerAvailable = (document: Document): boolean => Boolean(trigger(document)); +/** The only write is the user's own visible native GitHub selection. */ +export function labelHandoff(label: string, members: readonly string[], status: HTMLElement, document: Document): () => void { + const control = trigger(document); + if (!control) { status.textContent = 'GitHub’s writable label picker is unavailable here.'; return () => undefined; } + const before = observedLabels(document); const previous = before.filter(name => members.includes(name) && name !== label); let searched = false; + const stop = (): void => { observer.disconnect(); clearTimeout(timer); document.removeEventListener('keydown', escape); }; + const escape = (event: KeyboardEvent): void => { if (event.key === 'Escape') stop(); }; + const prepare = (): void => { + const input = document.querySelector('#labels-select-menu input, input[placeholder="Filter labels"], input[placeholder="Search labels"], [data-testid="label-picker"] input, .SelectMenu-filter input'); + if (input && !searched) { + searched = true; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, label); input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); input.select(); + status.textContent = `Choose “${label}” in GitHub.${previous.length ? ` Replace managed ${previous.join(', ')} deliberately; unrelated labels stay yours.` : ''} diffdevil has not applied it.`; + } + if (observedLabels(document).includes(label) && !before.includes(label)) { status.textContent = `Observed on GitHub: ${label}.`; stop(); } + else if (searched && !input) { status.textContent = 'Picker closed. No new label selection was observed.'; stop(); } + }; + const observer = new MutationObserver(prepare); observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(() => { if (!searched) status.textContent = 'The native picker could not be opened or inspected. No labels were changed.'; stop(); }, 15_000); + document.addEventListener('keydown', escape); + if (control.tagName === 'SUMMARY' && control.parentElement instanceof HTMLDetailsElement) { if (!control.parentElement.open) control.click(); } else control.click(); + prepare(); return stop; +} diff --git a/apps/browser-extension/src/content/main.ts b/apps/browser-extension/src/content/main.ts new file mode 100644 index 00000000..dcb8b862 --- /dev/null +++ b/apps/browser-extension/src/content/main.ts @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { startContent } from './controller.js'; +startContent(); diff --git a/apps/browser-extension/src/content/popover.ts b/apps/browser-extension/src/content/popover.ts new file mode 100644 index 00000000..9e525d72 --- /dev/null +++ b/apps/browser-extension/src/content/popover.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** One anchored, non-modal report. No backdrop, focus trap, or page-scroll lock. */ +export class Popover { + private anchor?: HTMLElement; + private panel?: HTMLElement; + private listeners?: AbortController; + private detached?: MutationObserver; + private resized?: ResizeObserver; + toggle(anchor: HTMLElement, panel: HTMLElement): void { + if (this.anchor === anchor) { this.close(); return; } + this.close(false); this.anchor = anchor; this.panel = panel; + panel.classList.add('ddx-popover'); panel.id = 'diffdevil-report-popover'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'diffdevil analysis'); panel.tabIndex = -1; + anchor.setAttribute('aria-expanded', 'true'); anchor.setAttribute('aria-controls', panel.id); document.body.append(panel); this.position(); panel.focus({ preventScroll: true }); + const controller = this.listeners = new AbortController(); + document.addEventListener('pointerdown', event => { const path = event.composedPath(); if (!path.includes(anchor) && !path.includes(panel)) this.close(false); }, { capture: true, signal: controller.signal }); + document.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); this.close(); } }, { signal: controller.signal }); + document.addEventListener('scroll', this.position, { capture: true, passive: true, signal: controller.signal }); window.addEventListener('resize', this.position, { signal: controller.signal }); + this.resized = new ResizeObserver(this.position); this.resized.observe(panel); + this.detached = new MutationObserver(() => this.reconcile()); if (anchor.parentNode) this.detached.observe(anchor.parentNode, { childList: true }); + } + reconcile(): void { if (this.anchor && !this.anchor.isConnected) this.close(false); } + close(restore = true): void { + const anchor = this.anchor; this.listeners?.abort(); this.detached?.disconnect(); this.resized?.disconnect(); this.panel?.remove(); + anchor?.setAttribute('aria-expanded', 'false'); anchor?.removeAttribute('aria-controls'); + delete this.anchor; delete this.panel; delete this.listeners; delete this.detached; delete this.resized; + if (restore && anchor?.isConnected) anchor.focus({ preventScroll: true }); + } + private position = (): void => { + if (!this.panel || !this.anchor?.isConnected) { this.reconcile(); return; } + const panel = this.panel; const anchor = this.anchor.getBoundingClientRect(); const padding = 12; + if (anchor.bottom < 0 || anchor.top > innerHeight) { this.close(false); return; } + panel.style.maxWidth = `${Math.max(1, innerWidth - padding * 2)}px`; + const belowSpace = Math.max(0, innerHeight - anchor.bottom - padding - 6); + const aboveSpace = Math.max(0, anchor.top - padding - 6); + const naturalHeight = panel.scrollHeight + 2; + const above = belowSpace < naturalHeight && aboveSpace > belowSpace; + // Constrain the chosen side instead of shifting a full-height report over its + // own trigger. The trigger must remain clickable for click-again dismissal. + panel.style.maxHeight = `${Math.max(1, above ? aboveSpace : belowSpace)}px`; + const size = panel.getBoundingClientRect(); + panel.style.top = `${Math.max(padding, above ? anchor.top - size.height - 6 : anchor.bottom + 6)}px`; + panel.style.left = `${Math.max(padding, Math.min(anchor.right - size.width, innerWidth - size.width - padding))}px`; + }; +} diff --git a/apps/browser-extension/src/content/render.ts b/apps/browser-extension/src/content/render.ts new file mode 100644 index 00000000..427f06bc --- /dev/null +++ b/apps/browser-extension/src/content/render.ts @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { measurementText as value, evidenceText } from '@wolfsblvt/diffdevil/browser/text'; +import type { HumanReportView, Rail } from '@wolfsblvt/diffdevil/browser'; +import type { Settings } from '../shared/catalogue.js'; +import { node, button, isDark } from '../shared/dom.js'; +import { productIcon } from '../shared/icons.js'; +import { Popover } from './popover.js'; +import { labelHandoff, observedLabels, pickerAvailable } from './labels.js'; +function facts(title: string, items: readonly (readonly [string, string])[]): HTMLElement { + const section = node('section', 'ddx-facts'); section.append(node('h3', '', title)); const list = node('dl'); + for (const [name, text] of items) list.append(node('dt', '', name), node('dd', '', text)); section.append(list); return section; +} +function range(cell: Rail['cells'][number]): string { return `${cell.lower === undefined ? 'Unbounded below' : `At least ${cell.lower}`}${cell.upper === undefined ? '; no upper limit' : `; below ${cell.upper}`}${cell.selected ? '; selected' : ''}`; } +export function reportPanel(view: HumanReportView, popover: Popover): HTMLElement { + const panel = node('div'); const header = node('header', 'ddx-heading'); const close = button('×', () => popover.close(), 'ddx-close'); close.setAttribute('aria-label', 'Close report'); + header.append(node('h2', '', 'diffdevil analysis'), close); panel.append(header); + const source = view.report.source; + panel.append(node('p', 'ddx-source', `${source.repository ?? source.kind}${source.pullRequest ? ` #${source.pullRequest}` : ''} · ${evidenceText(view.evidence.status)}`), + node('p', 'ddx-revisions', `${source.base?.slice(0, 12) ?? '?'} → ${source.head?.slice(0, 12) ?? '?'} · ${source.comparison ?? 'supplied'}`)); + if (view.focus) { panel.append(node('h3', 'ddx-file-name', view.focus.path)); if (!view.focus.included) panel.append(node('p', 'ddx-notice', 'Excluded from the aggregate. These are its actual file measurements; no size tier is selected.')); } + const primary = node('div', 'ddx-report-primary'); primary.append(node('span', '', 'Changed'), node('strong', '', value(view.changed)), node('span', '', 'lines')); panel.append(primary); + panel.append(node('p', 'ddx-report-parts', `+${value(view.added)} added only · −${value(view.deleted)} deleted only · ~${value(view.modified)} modified`)); + panel.append(facts('Raw', [['Additions', `+${value(view.raw.added)}`], ['Deletions', `−${value(view.raw.deleted)}`], ['Churn', value(view.raw.churn)]])); + panel.append(facts('Evidence', [['Measurement', evidenceText(view.evidence.status)], ['File set', `${view.report.fileSet.complete ? 'Complete' : 'Incomplete'} · ${value(view.report.fileSet.total)} files`], ['Source', 'Local browser analysis'], ['Semantics', view.report.semantics.replacementLines]])); + if (view.evidence.reasons.length) panel.append(node('p', 'ddx-notice', view.evidence.reasons.map(reason => reason.code).join(' · '))); + if (!view.focus) panel.append(facts('Files', Object.entries(view.report.totals.files).map(([name, count]) => [name, value(count)]))); + if (Object.keys(view.report.metrics ?? {}).length) panel.append(facts('Metrics', Object.entries(view.report.metrics ?? {}).map(([name, metric]) => [name, `${value(metric)} · ${evidenceText(metric.status)}`]))); + for (const rail of view.rails) { + panel.append(facts(`Band: ${rail.id}`, [['Expression', rail.expression], ['Result', rail.label ?? rail.selected ?? 'No single band established'], ...rail.cells.map(cell => [cell.name, range(cell)] as const)])); + if (rail.group) panel.append(facts('Managed label intent', [['Group', rail.group], ['Members', rail.members?.join(', ') ?? 'none'], ['Mapped label', rail.label ?? 'none'], ['Observed on GitHub', observedLabels(document).filter(label => rail.members?.includes(label)).join(', ') || 'none visible']])); + } + if (view.errors.length) { const errors = node('section', 'ddx-policy-error'); errors.append(node('h3', '', 'Policy not applied')); for (const error of view.errors) errors.append(node('p', '', `${error.code}: ${error.message}`)); panel.append(errors); } + if (view.policy) { + panel.append(facts('Policy', [['Mode', view.policy.mode], ['Layers', view.policy.layers.join(' < ')], ['Digest', view.policy.digest]])); + const details = node('details', 'ddx-details'); details.append(node('summary', '', 'Effective-setting provenance'), facts('Origins', view.policy.origins.map(origin => [origin.path, `${origin.layer}${origin.replaces ? ` · replaces ${origin.replaces}` : ''}`]))); panel.append(details); + } + for (const [title, data] of [['Scopes', view.report.scopes], ['Rules', view.report.rules], ['File detail', view.focus]] as const) { + if (!data || !Object.keys(data).length) continue; + const details = node('details', 'ddx-details'); details.append(node('summary', '', title), node('pre', '', JSON.stringify(data, null, 2))); panel.append(details); + } + const identity = node('details', 'ddx-details'); identity.append(node('summary', '', 'Report identity'), facts('Identifiers', [['Report', view.report.reportId ?? 'unavailable'], ['Policy', view.report.policyId ?? 'none'], ['Schema', view.report.schemaVersion], ['Presenter', view.kind], ['Comparison', source.comparisonId]])); panel.append(identity); + const footer = node('footer', 'ddx-report-footer'); const settings = node('a', '', 'Extension settings'); settings.href = chrome.runtime.getURL('options.html'); settings.target = '_blank'; settings.rel = 'noopener noreferrer'; + const copy = button('Copy report facts', () => { void navigator.clipboard.writeText(JSON.stringify(view, null, 2)).then(() => { copy.textContent = 'Copied'; }, () => { copy.textContent = 'Clipboard unavailable'; }); }, 'ddx-small'); copy.title = 'Includes paths and report facts, but not source lines.'; + footer.append(settings, copy); panel.append(footer); return panel; +} +export interface Projection { root: HTMLElement; trigger: HTMLButtonElement; cleanup: () => void } +export function projection(view: HumanReportView, settings: Settings, popover: Popover, aggregate: boolean): Projection { + const root = node('span', 'ddx-root'); root.dataset.ddx = aggregate ? 'aggregate' : 'file'; root.dataset.density = String(settings['display.density']); + const column = node('span', 'ddx-column'); const trigger = node('button', 'ddx-trigger'); trigger.type = 'button'; trigger.setAttribute('aria-expanded', 'false'); trigger.setAttribute('aria-haspopup', 'dialog'); + const mark = productIcon(String(settings['display.brandIcon']), isDark(document), chrome.runtime.getURL); if (mark) trigger.append(mark); + trigger.append(node('span', 'ddx-primary-label', 'Changed'), node('strong', 'ddx-value', value(view.changed))); + if (settings['display.decomposition']) { const breakdown = node('span', 'ddx-breakdown'); breakdown.append(node('span', 'ddx-added', `+${value(view.added)}`), node('span', 'ddx-deleted', `−${value(view.deleted)}`), node('span', 'ddx-modified', `~${value(view.modified)}`)); trigger.append(breakdown); } + if (view.evidence.status !== 'exact') trigger.append(node('span', 'ddx-evidence', evidenceText(view.evidence.status))); + trigger.title = `Open ${aggregate ? 'pull-request' : 'file'} report. ${evidenceText(view.evidence.status)}. Replacements count once.`; + trigger.addEventListener('click', event => { event.stopPropagation(); popover.toggle(trigger, reportPanel(view, popover)); }); + column.append(trigger); if (settings['display.rawChurn']) column.append(node('span', 'ddx-raw', `Raw +${value(view.raw.added)} −${value(view.raw.deleted)}`)); root.append(column); + const rail = view.rails.find(rail => rail.id === 'size') ?? view.rails[0]; + if (view.focus?.included === false) root.append(node('span', 'ddx-pill', 'excluded')); + else if (rail && settings['display.virtualBandPill']) { const pill = node('span', 'ddx-pill', rail.label ?? (rail.selected ? `${rail.id}/${rail.selected}` : `${rail.id}/?`)); pill.title = 'Virtual local classification. Not an applied GitHub label.'; root.append(pill); } + if (rail && settings['display.bandRail']) { + const track = node('span', 'ddx-rail'); track.setAttribute('role', 'list'); track.setAttribute('aria-label', `${rail.id} policy bands`); + for (const cell of rail.cells) { const square = node('span', `ddx-cell${cell.selected ? ' ddx-selected' : ''}`); square.tabIndex = 0; square.setAttribute('role', 'listitem'); square.title = `${cell.name}: ${range(cell)}`; square.setAttribute('aria-label', square.title); if (cell.selected) square.setAttribute('aria-current', 'true'); if (cell.color && /^[\da-f]{6}$/iu.test(cell.color)) square.style.setProperty('--ddx-band', `#${cell.color}`); track.append(square); } + root.append(track); + } + if (settings['display.appStanding']) { const local = node('span', 'ddx-local', 'local'); local.title = 'Computed in this browser. No App report or approval is implied.'; root.append(local); } + if (view.errors.length) { const warning = button('policy unavailable', () => popover.toggle(trigger, reportPanel(view, popover)), 'ddx-warning'); root.append(warning); } + let stopLabelObservation: (() => void) | undefined; + if (aggregate && rail?.label && settings['labels.nativeHandoff']) { + const status = node('span', 'ddx-label-status'); status.setAttribute('role', 'status'); + if (observedLabels(document).includes(rail.label)) status.textContent = `${rail.label} · observed on GitHub`; + else if (pickerAvailable(document)) root.append(button(`Find ${rail.label}`, () => { stopLabelObservation?.(); stopLabelObservation = labelHandoff(rail.label!, rail.members ?? [], status, document); }, 'ddx-small')); + root.append(status); + } + return { root, trigger, cleanup: () => { stopLabelObservation?.(); root.remove(); } }; +} diff --git a/apps/browser-extension/src/options/main.ts b/apps/browser-extension/src/options/main.ts new file mode 100644 index 00000000..a7252516 --- /dev/null +++ b/apps/browser-extension/src/options/main.ts @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { SETTINGS, DEFAULTS, advancedChanges, searchSettings, type Setting, type Settings, type SettingValue } from '../shared/catalogue.js'; +import { bands, overrides, personalYaml, repositoryKey } from '../shared/settings.js'; +import { request, type Diagnostics } from '../shared/protocol.js'; +import { productIcon, GLYPH_STATUS } from '../shared/icons.js'; +import { node, button, isDark } from '../shared/dom.js'; +const byId = (id: string): T => { const result = document.getElementById(id); if (!result) throw new Error(`Missing options control ${id}.`); return result as T; }; +let settings: Settings = { ...DEFAULTS }; +let view: 'basic' | 'advanced' = 'basic'; +let modifiedOnly = false; +const revealed = new Set(); +// Unsaved editor content survives changing views and searching. Only Save writes it. +const drafts = new Map(); +const search = byId('settings-search'); +const content = byId('settings-content'); +let announcementTimer: ReturnType | undefined; +function announce(message: string, error = false): void { + const status = byId('save-status'); status.textContent = message; status.hidden = false; status.classList.toggle('is-error', error); + clearTimeout(announcementTimer); if (!error) announcementTimer = setTimeout(() => { status.hidden = true; }, 6000); +} +const humanBytes = (value: number): string => value < 1024 ? `${value} B` : value < 1024 * 1024 ? `${(value / 1024).toFixed(1)} KiB` : `${(value / 1024 / 1024).toFixed(2)} MiB`; +function theme(): void { + const choice = String(settings['appearance.theme']); + if (choice === 'system') delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = choice; + try { localStorage.setItem('diffdevil.options.theme', choice); } catch { /* Applied without persistent early-paint state. */ } + byId('wordmark').src = chrome.runtime.getURL(`assets/diffdevil-wordmark-${isDark(document) ? 'dark' : 'light'}.svg`); + for (const option of document.querySelectorAll('#theme-control button')) option.setAttribute('aria-pressed', String(option.dataset.theme === choice)); + // Previews update in place; changing theme must not discard a policy draft. + for (const preview of document.querySelectorAll('[data-icon-preview]')) { + preview.replaceChildren(); const mark = productIcon(preview.dataset.iconPreview!, isDark(document), chrome.runtime.getURL); + if (mark) preview.append(mark); preview.append(node('span', '', 'Changed'), node('strong', '', '178')); + } +} +function indicators(): void { + const count = advancedChanges(settings).length; const badge = byId('advanced-badge'); badge.textContent = String(count); badge.hidden = count === 0; + const active = byId('show-changed'); active.hidden = count === 0 || view === 'advanced'; active.textContent = `${count} advanced ${count === 1 ? 'setting' : 'settings'} active`; + for (const option of document.querySelectorAll('#view-control button')) option.setAttribute('aria-pressed', String(option.dataset.view === view)); +} +function defaultText(item: Setting): string { + if (typeof item.default === 'boolean') return item.default ? 'On' : 'Off'; + if (item.kind === 'bands') return 'Five size bands'; + if (item.kind === 'yaml') return 'Guided policy'; + if (item.kind === 'overrides') return 'No overrides'; + return item.default === '' ? 'Empty' : String(item.default); +} +const origin = (item: Setting): string => `Default: ${defaultText(item)} · ${settings[item.id] === item.default ? 'Default' : 'Modified'} · ${item.group === 'Policy' ? 'Personal layer; effective repository provenance is shown in the report' : 'Personal preference'}`; +async function save(item: Setting, value: SettingValue, error?: HTMLElement): Promise { + try { + settings = await request({ type: 'settings.save', patch: { [item.id]: value } }); drafts.delete(item.id); + if (error) error.textContent = ''; indicators(); theme(); + const card = document.getElementById(item.id); card?.classList.toggle('is-modified', settings[item.id] !== item.default); + const status = card?.querySelector('.setting-origin'); if (status) status.textContent = origin(item); + announce('Saved. Open GitHub pull requests refresh their local projection.'); return true; + } catch (exception) { + const message = exception instanceof Error ? exception.message : 'The setting could not be saved.'; + if (error) error.textContent = message; announce(message, true); return false; + } +} +function label(text: string, field: HTMLElement): HTMLElement { const result = node('label', 'field-label', text); result.append(field); return result; } +function bandEditor(item: Setting, error: HTMLElement): HTMLElement { + const host = node('div', 'band-editor'); + // In-progress invalid rows need to remain editable without passing validation. + let values = JSON.parse(drafts.get(item.id) ?? String(settings[item.id])) as ReturnType; + const retain = (): void => { drafts.set(item.id, JSON.stringify(values)); }; + const render = (): void => { + host.replaceChildren(); const table = node('table'); const caption = node('caption', 'visually-hidden', 'Personal band thresholds and existing-label mappings'); table.append(caption); + const head = node('thead'); const headings = node('tr'); for (const text of ['ID', 'Name', 'Below', 'Label mapping', 'Colour', '']) { const th = node('th', '', text); th.scope = 'col'; headings.append(th); } head.append(headings); table.append(head); + const body = node('tbody'); + values.forEach((band, index) => { + const row = node('tr'); + for (const key of ['id', 'name', 'lt', 'label', 'color'] as const) { + const cell = node('td'); const input = node('input'); input.setAttribute('aria-label', `${band.name || `Band ${index + 1}`} ${key}`); input.value = band[key] === null ? '' : String(band[key]); + if (key === 'lt') { input.type = 'number'; input.min = '1'; input.step = '1'; input.disabled = index === values.length - 1; input.placeholder = 'Otherwise'; } + if (key === 'color') { input.maxLength = 6; input.placeholder = 'Auto'; input.className = 'colour-field'; } + input.addEventListener('input', () => { values = values.map((value, at) => at === index ? { ...value, [key]: key === 'lt' ? Number(input.value) : input.value } : value); retain(); }); + cell.append(input); row.append(cell); + } + const controls = node('td'); const remove = button('×', () => { if (values.length < 2) return; values.splice(index, 1); values[values.length - 1]!.lt = null; retain(); render(); }, 'icon-button'); remove.disabled = values.length < 2; remove.setAttribute('aria-label', `Remove ${band.name}`); controls.append(remove); row.append(controls); body.append(row); + }); + table.append(body); const overflow = node('div', 'table-overflow'); overflow.append(table); host.append(overflow); + const actions = node('div', 'button-row'); + actions.append(button('Save bands', () => { void save(item, JSON.stringify(values), error); }, 'primary-button'), button('Add band', () => { + if (values.length >= 20) return; const lower = values.at(-2)?.lt ?? 10; values[values.length - 1]!.lt = Math.max(lower + 1, lower * 2); + let id = `band_${values.length + 1}`; while (values.some(value => value.id === id)) id += '_new'; values.push({ id, name: `Band ${values.length + 1}`, lt: null, label: '', color: '' }); retain(); render(); + }), button('Discard edits', () => { drafts.delete(item.id); values = bands(String(settings[item.id])); error.textContent = ''; render(); })); + host.append(actions, node('p', 'control-note', 'One cell per range. No upper limit on the final range. Empty label mappings remain virtual; only declared existing-label mappings can open the native picker.')); + }; render(); return host; +} +function overrideEditor(item: Setting, error: HTMLElement): HTMLElement { + const host = node('div', 'override-editor'); const existing = node('div', 'repository-list'); + const repository = node('input'); repository.placeholder = 'owner/repository'; repository.setAttribute('aria-label', 'Repository identifier'); + const mode = node('select'); mode.setAttribute('aria-label', 'Repository policy mode'); + for (const [value, title] of [['inherit', 'Use global mode'], ['composed', 'Composed'], ['repository', 'Repository'], ['personal-only', 'Personal only']]) { const option = node('option', '', title!); option.value = value!; mode.append(option); } + const yaml = node('textarea', 'code-editor'); yaml.rows = 9; yaml.spellcheck = false; yaml.setAttribute('aria-label', 'Repository override YAML'); yaml.placeholder = 'version: 1\n# Explicit personal overrides for this repository.'; + const draftKey = `${item.id}:editor`; const draft = drafts.get(draftKey); + if (draft) { const parsed = JSON.parse(draft) as { repository: string; mode: string; yaml: string }; repository.value = parsed.repository; mode.value = parsed.mode; yaml.value = parsed.yaml; } + const retain = (): void => { drafts.set(draftKey, JSON.stringify({ repository: repository.value, mode: mode.value, yaml: yaml.value })); }; + for (const field of [repository, mode, yaml]) field.addEventListener('input', retain); + const list = (): void => { existing.replaceChildren(); for (const name of Object.keys(overrides(String(settings[item.id]))).sort()) existing.append(button(name, () => { const entry = overrides(String(settings[item.id]))[name]!; repository.value = name; mode.value = entry.mode ?? 'inherit'; yaml.value = entry.yaml ?? ''; retain(); }, 'repository-chip')); }; + const update = async (remove: boolean): Promise => { + try { + const name = repositoryKey(repository.value.trim()); const values = overrides(String(settings[item.id])); + if (remove) delete values[name]; else values[name] = { ...(mode.value !== 'inherit' ? { mode: mode.value as 'composed' | 'repository' | 'personal-only' } : {}), ...(yaml.value.trim() ? { yaml: yaml.value } : {}) }; + if (await save(item, JSON.stringify(values), error)) { if (remove) { mode.value = 'inherit'; yaml.value = ''; } drafts.delete(draftKey); list(); } + } catch (exception) { error.textContent = exception instanceof Error ? exception.message : 'The repository override is invalid.'; } + }; + const actions = node('div', 'button-row'); actions.append(button('Save repository override', () => { void update(false); }, 'primary-button'), button('Remove override', () => { void update(true); }, 'danger-button')); + host.append(existing, label('Repository', repository), label('Mode', mode), label('Explicit YAML', yaml), actions); list(); return host; +} +function control(item: Setting, error: HTMLElement): HTMLElement { + const host = node('div', 'setting-control'); const value = settings[item.id] ?? item.default; const id = `control-${item.id}`; + if (item.kind === 'boolean') { + const wrapper = node('label', 'switch'); const input = node('input'); input.type = 'checkbox'; input.id = id; input.checked = Boolean(value); input.setAttribute('aria-labelledby', `name-${item.id}`); input.setAttribute('aria-describedby', `description-${item.id}`); + input.addEventListener('change', () => { void save(item, input.checked, error).then(ok => { if (!ok) input.checked = Boolean(settings[item.id]); }); }); + wrapper.append(input, node('span', 'switch-track'), node('span', 'switch-label', 'On')); host.append(wrapper); return host; + } + if (item.id === 'display.brandIcon') { + const group = node('fieldset', 'icon-choices'); group.id = id; group.append(node('legend', 'visually-hidden', item.name)); + for (const [value, title] of item.choices ?? []) { + const wrapper = node('label', 'icon-choice'); const input = node('input'); input.type = 'radio'; input.name = item.id; input.value = value; input.checked = settings[item.id] === value; + input.addEventListener('change', () => { void save(item, value, error).then(ok => { if (!ok) for (const radio of group.querySelectorAll('input')) radio.checked = radio.value === settings[item.id]; }); }); + const preview = node('span', 'icon-preview'); preview.dataset.iconPreview = value; const mark = productIcon(value, isDark(document), chrome.runtime.getURL); if (mark) preview.append(mark); preview.append(node('span', '', 'Changed'), node('strong', '', '178')); + wrapper.append(input, node('span', 'choice-title', title), preview); group.append(wrapper); + } + host.append(group, node('p', 'control-note', 'The monochrome default is the accepted centre-seam diffdevil/brand glyph from the shared Wolfsblvt icon system. Full colour uses the accepted micro identity symbol.')); return host; + } + if (item.kind === 'choice') { + const select = node('select'); select.id = id; select.setAttribute('aria-labelledby', `name-${item.id}`); + for (const [value, title] of item.choices ?? []) { const option = node('option', '', title); option.value = value; select.append(option); } select.value = String(value); + select.addEventListener('change', () => { void save(item, select.value, error).then(ok => { if (!ok) select.value = String(settings[item.id]); }); }); host.append(select); return host; + } + if (item.kind === 'number') { + const input = node('input'); input.type = 'number'; input.id = id; input.min = '4'; input.max = '128'; input.step = '1'; input.value = String(value); input.setAttribute('aria-labelledby', `name-${item.id}`); + input.addEventListener('change', () => { void save(item, Number(input.value), error).then(ok => { if (!ok) input.value = String(settings[item.id]); }); }); host.append(input, node('span', 'input-unit', 'MiB')); return host; + } + if (item.kind === 'bands') return bandEditor(item, error); + if (item.kind === 'overrides') return overrideEditor(item, error); + if (item.kind === 'action') { host.append(button(item.name, () => { void manage(item, error); }, item.destructive ? 'danger-button' : 'secondary-button')); return host; } + const text = node('textarea', item.kind === 'yaml' ? 'code-editor' : 'path-editor'); text.id = id; text.rows = item.kind === 'yaml' ? 14 : 3; text.spellcheck = false; text.value = drafts.get(item.id) ?? String(value); text.setAttribute('aria-labelledby', `name-${item.id}`); text.setAttribute('aria-describedby', `description-${item.id}`); + text.placeholder = item.kind === 'yaml' ? 'version: 1\npresets: [size@1]\n# Leave empty to use guided controls.' : '**/generated/**'; text.addEventListener('input', () => drafts.set(item.id, text.value)); + const actions = node('div', 'button-row'); actions.append(button(item.kind === 'yaml' ? 'Validate and save' : 'Save paths', () => { void save(item, text.value, error); }, 'primary-button'), button('Discard edits', () => { text.value = String(settings[item.id]); drafts.delete(item.id); error.textContent = ''; })); + if (item.kind === 'yaml') { + const file = node('input'); file.type = 'file'; file.accept = '.yaml,.yml'; file.hidden = true; + file.addEventListener('change', () => { void (async () => { try { const entry = file.files?.[0]; if (!entry) return; if (entry.size > 256 * 1024) throw new Error('A personal policy must be at most 256 KiB.'); text.value = await entry.text(); drafts.set(item.id, text.value); announce('Imported into the editor. Validate and save to apply.'); } catch (exception) { error.textContent = exception instanceof Error ? exception.message : 'Import failed.'; } finally { file.value = ''; } })(); }); + actions.append(button('Import YAML', () => file.click()), button('Export YAML', () => download('diffdevil.yml', text.value.trim() ? text.value : personalYaml(settings), 'application/yaml')), file); + } + host.append(text, actions); return host; +} +function card(item: Setting): HTMLElement { + const section = node('section', `setting${item.kind !== 'action' && settings[item.id] !== item.default ? ' is-modified' : ''}`); section.id = item.id; section.setAttribute('aria-labelledby', `name-${item.id}`); + const anchorRow = node('div', 'setting-anchor'); const anchor = node('a', '', `#${item.id}`); anchor.href = `#${item.id}`; + anchorRow.append(anchor, button('Copy link', () => { void navigator.clipboard.writeText(`${location.href.split('#')[0]}#${item.id}`).then(() => announce(`Copied #${item.id}`), () => announce('Clipboard access was declined.', true)); }, 'copy-link')); if (item.advanced) anchorRow.append(node('span', 'advanced-tag', 'Advanced')); + const title = node('div', 'setting-title'); const name = node('h3', '', item.name); name.id = `name-${item.id}`; title.append(name); + if (item.kind !== 'action') title.append(button('Reset', () => { void save(item, item.default).then(ok => { if (ok) { drafts.delete(`${item.id}:editor`); render(); document.getElementById(item.id)?.querySelector('.reset-setting')?.focus({ preventScroll: true }); } }); }, 'reset-setting')); + const description = node('p', 'setting-description', item.description); description.id = `description-${item.id}`; + const error = node('p', 'validation-message'); error.setAttribute('role', 'alert'); section.append(anchorRow, title, description); + if (item.kind !== 'action') section.append(node('p', 'setting-origin', origin(item))); + section.append(control(item, error), error); + if (String(settings['policy.advancedYaml']).trim() && ['policy.preset', 'policy.primaryMetric', 'policy.bands', 'policy.includeOnly', 'policy.exclude', 'policy.forceInclude'].includes(item.id)) section.append(node('p', 'control-note', 'Advanced personal YAML is active. This guided value is preserved but not applied.')); + return section; +} +function render(): void { + content.replaceChildren(); const categories = byId('categories'); categories.replaceChildren(); const query = search.value.trim(); + let visible = query ? searchSettings(query, settings) : SETTINGS.filter(item => view === 'advanced' || !item.advanced || revealed.has(item.id)); + if (modifiedOnly && !query) visible = advancedChanges(settings); + for (const group of ['Display', 'Policy', 'Integration', 'Appearance', 'Data']) { + const items = visible.filter(item => item.group === group); if (!items.length) continue; + const section = node('section', 'settings-group'); section.id = `category-${group.toLowerCase()}`; section.append(node('h2', 'category-heading', group)); + for (const item of items) section.append(card(item)); content.append(section); + const link = node('a', '', group); link.href = `#${section.id}`; link.append(node('span', '', String(items.length))); categories.append(link); + } + if (!visible.length) content.append(node('p', 'empty-search', 'No matching settings. Try a name, description or #identifier.')); + byId('search-summary').textContent = query || modifiedOnly ? `${visible.length} ${visible.length === 1 ? 'result' : 'results'}${query && view === 'basic' && visible.some(item => item.advanced) ? ' · includes Advanced' : ''}` : ''; + indicators(); theme(); +} +function revealFragment(): void { + let id: string; try { id = decodeURIComponent(location.hash.slice(1)); } catch { return; } + if (SETTINGS.some(item => item.id === id)) { revealed.add(id); search.value = ''; modifiedOnly = false; render(); } + const target = document.getElementById(id); if (!target) return; + requestAnimationFrame(() => { target.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' }); target.classList.add('is-targeted'); target.querySelector('.setting-control input, .setting-control select, textarea, .band-editor input, .override-editor input, .setting-control button')?.focus({ preventScroll: true }); }); +} +async function showDiagnostics(): Promise { + const host = byId('diagnostics'); + try { + const info = await request({ type: 'diagnostics.get' }); const list = node('dl', 'metadata-grid'); + for (const [key, value] of [['Extension', info.version], ['Engine', info.engine], ['Report schema', info.schema], ['Presenter', info.presenter], ['Measurement', info.measurement], ['Icon resolver', GLYPH_STATUS], ['Report cache', `${info.cache.reportEntries} entries · ${humanBytes(info.cache.reportBytes)}`], ['Policy cache', `${info.cache.policyEntries} entries · ${humanBytes(info.cache.policyBytes)}`], ['Cache capacity', `${humanBytes(info.cache.bytes)} / ${humanBytes(info.cache.maximumBytes)}`], ['Settings storage', `${humanBytes(info.syncBytes)} synchronized · ${humanBytes(info.localBytes)} local`], ['Advanced settings', `${advancedChanges(settings).length} modified`]]) list.append(node('dt', '', key!), node('dd', '', value!)); + host.replaceChildren(list); + if (info.last) host.append(node('p', 'last-source', `Last analysis: ${info.last.repository} #${info.last.pullRequest} · ${info.last.base.slice(0, 12)} → ${info.last.head.slice(0, 12)} · ${new Date(info.last.at).toLocaleString()}`)); + host.append(node('p', 'control-note', 'Raw diffs are processed in memory and discarded. Cached reports can contain private paths and numeric facts; cached repository policy contains configuration text. These caches are not browser-synchronized.')); + if (info.errors.length) { const details = node('details', 'recent-errors'); details.append(node('summary', '', `${info.errors.length} recent diagnostic codes`)); for (const item of info.errors) details.append(node('p', '', `${new Date(item.at).toLocaleString()} · ${item.code}`)); host.append(details); } + } catch (exception) { host.textContent = exception instanceof Error ? exception.message : 'Extension details are unavailable.'; } +} +async function manage(item: Setting, error: HTMLElement): Promise { + if (item.destructive && !confirm(`${item.name}? This deletes the selected extension data. Export your configuration first to keep a copy.`)) return; + try { + const result = await request({ type: 'data.action', action: item.id, confirmed: true }); + if (item.id === 'diagnostics.copySupportSnapshot') { await navigator.clipboard.writeText(JSON.stringify(result, null, 2)); announce('Copied a support snapshot without source, private paths or policy text.'); } + else { settings = await request({ type: 'settings.get' }); if (item.id === 'data.resetAll') drafts.clear(); if (item.id === 'data.clearRepositoryOverrides') drafts.delete('policy.repositoryOverrides:editor'); render(); announce(`${item.name}: complete.`); } + await showDiagnostics(); + } catch (exception) { error.textContent = exception instanceof Error ? exception.message : 'The operation failed.'; announce(error.textContent, true); } +} +function download(name: string, content: string, type: string): void { + const url = URL.createObjectURL(new Blob([content], { type })); const link = node('a'); link.href = url; link.download = name; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); +} +for (const [value, title] of [['dark', 'Dark'], ['system', 'Auto'], ['light', 'Light']]) { const option = button(title!, () => { void save(SETTINGS.find(item => item.id === 'appearance.theme')!, value!); }); option.dataset.theme = value; option.setAttribute('aria-pressed', 'false'); byId('theme-control').append(option); } +for (const option of document.querySelectorAll('#view-control button')) option.addEventListener('click', () => { view = option.dataset.view as 'basic' | 'advanced'; revealed.clear(); modifiedOnly = false; render(); }); +search.addEventListener('input', () => { modifiedOnly = false; render(); window.scrollTo({ top: 0, behavior: 'instant' }); }); +byId('show-changed').addEventListener('click', () => { modifiedOnly = true; search.value = ''; render(); }); +byId('refresh-details').addEventListener('click', () => { void showDiagnostics(); }); +byId('export-settings').addEventListener('click', () => download('diffdevil-settings.json', JSON.stringify({ kind: 'diffdevil.settings', version: 1, settings }, null, 2), 'application/json')); +const importFile = byId('import-file'); byId('import-settings').addEventListener('click', () => importFile.click()); +importFile.addEventListener('change', () => { void (async () => { + try { + const file = importFile.files?.[0]; if (!file) return; if (file.size > 2 * 1024 * 1024) throw new Error('A settings export must be at most 2 MiB.'); + const parsed = JSON.parse(await file.text()) as Record; if (parsed.kind !== 'diffdevil.settings' || parsed.version !== 1) throw new Error('This is not a supported diffdevil settings export.'); + if (!confirm('Replace your extension settings with this export? Rebuildable reports are not imported.')) return; + settings = await request({ type: 'settings.save', patch: parsed.settings, replace: true }); drafts.clear(); render(); await showDiagnostics(); announce('Imported and validated settings.'); + } catch (exception) { announce(exception instanceof Error ? exception.message : 'Import failed.', true); } finally { importFile.value = ''; } +})(); }); +window.addEventListener('hashchange', revealFragment); matchMedia('(prefers-color-scheme: dark)').addEventListener('change', theme); +void (async () => { try { settings = await request({ type: 'settings.get' }); render(); revealFragment(); await showDiagnostics(); } catch (exception) { content.textContent = exception instanceof Error ? exception.message : 'Settings could not be loaded.'; announce('Stored data has not been overwritten. Reload or inspect the browser console before resetting.', true); } })(); diff --git a/apps/browser-extension/src/options/options.css b/apps/browser-extension/src/options/options.css new file mode 100644 index 00000000..d561ddbb --- /dev/null +++ b/apps/browser-extension/src/options/options.css @@ -0,0 +1,18 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +@import '../../../../design/tokens.css'; +*{box-sizing:border-box}body{margin:0;font:var(--t-body) var(--font-ui);color:var(--fg);background:var(--bg);line-height:1.5}button,input,textarea,select{font:inherit}button,a,input,textarea,select{touch-action:manipulation}button{cursor:pointer}a{color:inherit;text-underline-offset:3px}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid var(--focus);outline-offset:3px}button:disabled{cursor:not-allowed;opacity:.55}.visually-hidden,.search-label{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.skip-link{position:fixed;top:-100px;left:16px;z-index:10;background:var(--bg-raised);padding:12px}.skip-link:focus{top:8px} +.app-header{border-bottom:1px solid var(--hair);background:var(--bg)}.header-inner{max-width:1180px;margin:auto;min-height:76px;padding:16px 32px;display:flex;align-items:center;gap:20px}.wordmark{display:inline-flex;align-items:center}.wordmark img{object-fit:contain}.location-label{font-size:13px;color:var(--fg-3);border-left:1px solid var(--hair);padding-left:20px}.header-end{margin-left:auto;display:flex;align-items:center;gap:20px}.header-link{font-size:12px;color:var(--fg-2);text-decoration:none}.app-main{max-width:1180px;margin:auto;padding:36px 32px 48px}.page-heading{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin-bottom:24px}.eyebrow{font:11px/1.5 var(--font-mono);letter-spacing:.025em;color:var(--fg-3);margin:0 0 8px}h1{font-size:28px;line-height:1.2;letter-spacing:var(--tracking-display);font-weight:600;margin:0 0 10px}.lede{font-size:14px;color:var(--fg-2);margin:0}.local-badge{font:11px/1.5 var(--font-mono);color:var(--fg-3);white-space:nowrap;border:1px solid var(--hair);border-radius:var(--r-pill);padding:5px 10px} +.settings-toolbar{position:sticky;top:0;z-index:2;background:var(--bg);padding:16px 0 18px;border-bottom:1px solid var(--hair);margin-bottom:24px}input[type=search]{width:100%;padding:13px 16px;border-radius:var(--r-block);border:1px solid var(--hair-strong);background:var(--bg-raised);color:var(--fg);font-size:14px}.view-row{display:flex;gap:16px;align-items:center;margin-top:14px;min-height:32px}#search-summary{font-size:12px;color:var(--fg-3);margin-left:auto}.segmented{display:inline-flex;background:var(--bg-raised);border:1px solid var(--hair);border-radius:var(--r-control);padding:3px;gap:3px}.segmented button{border:0;border-radius:4px;background:transparent;color:var(--fg-3);padding:6px 13px;font-size:12px}.segmented button[aria-pressed=true]{color:var(--fg);background:var(--bg-raised-2);box-shadow:0 1px 2px #0002}.segmented button:hover{color:var(--fg)}.count-badge{display:inline-block;font:10px/1.6 var(--font-mono);background:var(--accent);color:var(--on-accent);min-width:16px;padding:0 3px;border-radius:4px;margin-left:4px}[hidden]{display:none!important} +.settings-layout{display:grid;grid-template-columns:150px minmax(0,1fr);gap:30px}.category-nav{align-self:start;position:sticky;top:145px;display:flex;flex-direction:column;gap:4px;padding-top:6px}.category-nav a{display:flex;justify-content:space-between;gap:8px;padding:8px 10px;border-radius:var(--r-control);text-decoration:none;color:var(--fg-2);font-size:12px}.category-nav a:hover{background:var(--bg-raised);color:var(--fg)}.category-nav span{font:11px/1.5 var(--font-mono);color:var(--fg-3)}.settings-group{scroll-margin-top:145px;margin-bottom:32px}.category-heading{font-size:16px;font-weight:600;margin:0 0 14px}.setting{padding:20px 22px;background:var(--bg-raised);border:1px solid var(--hair);border-radius:var(--r-card);margin-bottom:10px;scroll-margin-top:150px;position:relative;min-width:0}.setting.is-modified{border-left:3px solid var(--accent);padding-left:20px}.setting-anchor{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:8px;font:11px/1.4 var(--font-mono)}.setting-anchor>a{color:var(--fg-3);text-decoration:none;overflow-wrap:anywhere}.setting-anchor>a:hover{color:var(--focus);text-decoration:underline}.copy-link{padding:0;border:0;background:none;color:var(--fg-3);font-size:10px;opacity:0}.setting:hover .copy-link,.copy-link:focus-visible{opacity:1}.advanced-tag{font:10px/1.5 var(--font-ui);border:1px solid var(--hair-strong);border-radius:var(--r-chip);color:var(--fg-3);padding:0 4px}.setting-title{display:flex;align-items:center;gap:14px;justify-content:space-between}.setting-title h3{font-size:15px;line-height:1.4;font-weight:600;margin:0}.reset-setting{border:0;background:transparent;color:var(--fg-3);font-size:11px;padding:1px 0}.reset-setting:hover{color:var(--focus)}.setting-description{font-size:13px;color:var(--fg-2);max-width:740px;margin:6px 0}.setting-origin{font-size:10.5px;color:var(--fg-3);margin:7px 0 14px;line-height:1.6}.setting-control{display:flex;align-items:flex-start;flex-wrap:wrap;gap:8px}.setting-control:has(textarea),.setting-control:has(fieldset){display:block}.setting-control select{min-width:220px;max-width:100%}.setting-control input[type=number]{width:95px}.input-unit{font-size:12px;color:var(--fg-3);padding:8px 0}.control-note{font-size:11px;line-height:1.65;color:var(--fg-3);margin:10px 0 0} +input:not([type=checkbox]):not([type=radio]):not([type=search]),select,textarea{border:1px solid var(--hair-strong);border-radius:var(--r-control);background:var(--bg);color:var(--fg);padding:8px 10px;font-size:12px}textarea{width:100%;resize:vertical}.code-editor,.path-editor{font:12px/1.65 var(--font-mono);tab-size:2;min-height:75px}.code-editor{color:var(--term-cmd)!important;background:var(--bg-machine)!important}.code-editor::placeholder{color:var(--term-prompt)}input::placeholder,textarea::placeholder{color:var(--fg-3)}select{padding-right:28px}.switch{display:inline-flex;align-items:center;gap:9px;position:relative;cursor:pointer}.switch input{position:absolute;opacity:0;width:36px;height:20px;margin:0;z-index:1;cursor:pointer}.switch-track{display:block;width:34px;height:19px;border:1px solid var(--hair-strong);border-radius:12px;background:var(--bg);position:relative}.switch-track:after{content:'';position:absolute;top:3px;left:3px;width:11px;height:11px;border-radius:50%;background:var(--fg-3);transition:transform var(--dur)}.switch input:checked+.switch-track{background:var(--accent);border-color:var(--accent)}.switch input:checked+.switch-track:after{transform:translateX(15px);background:var(--on-accent)}.switch input:focus-visible+.switch-track{outline:2px solid var(--focus);outline-offset:3px}.switch-label{font-size:11px;color:var(--fg-3)}.switch input:not(:checked)~.switch-label{font-size:0}.switch input:not(:checked)~.switch-label:after{content:'Off';font-size:11px} +button:not(.segmented button,.copy-link,.reset-setting){border:1px solid var(--hair-strong);border-radius:var(--r-control);background:var(--bg-raised-2);color:var(--fg);padding:7px 12px;font-size:12px}button:hover:not(.segmented button,.copy-link,.reset-setting){border-color:var(--fg-3)}button.primary-button{background:var(--accent);border-color:var(--accent);color:var(--on-accent);font-weight:600}button.subtle-button{background:transparent;font-size:11px;color:var(--fg-2)}button.danger-button{color:var(--fg);text-decoration:underline;text-decoration-color:var(--status-error);text-underline-offset:4px;background:transparent}button.icon-button{padding:4px 7px;font-size:18px;line-height:1}.button-row{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:10px}.validation-message{font-size:12px;color:var(--fg);border-inline-start:2px solid var(--status-error);padding-inline-start:10px;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.6;margin:10px 0 0}.validation-message:empty{display:none} +.icon-choices{padding:0;border:0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0}.icon-choice{display:grid;grid-template-columns:14px 1fr;align-items:center;gap:8px;border:1px solid var(--hair-strong);border-radius:var(--r-block);padding:12px;cursor:pointer;min-width:0}.icon-choice:has(input:checked){border-color:var(--focus);background:color-mix(in srgb,var(--accent) 5%,var(--bg))}.icon-choice input{margin:0;accent-color:var(--accent)}.choice-title{font-size:12px}.icon-preview{grid-column:1/-1;display:flex;align-items:center;gap:6px;font-size:11px;color:var(--fg-2);padding-top:6px}.icon-preview strong{font-size:13px;color:var(--fg)}.ddx-brand{display:inline-flex;width:16px;height:16px;flex:none}.ddx-brand img{width:16px;height:16px}.ddx-brand-mono{background:currentColor;mask-position:center;mask-size:contain;mask-repeat:no-repeat;-webkit-mask-position:center;-webkit-mask-size:contain;-webkit-mask-repeat:no-repeat} +.table-overflow{overflow:auto}.band-editor table{width:100%;border-collapse:collapse;table-layout:fixed}.band-editor th{font-size:10px;font-weight:400;color:var(--fg-3);text-align:left;padding:0 3px 7px}.band-editor td{padding:3px}.band-editor th:first-child{width:70px}.band-editor th:nth-child(2){width:80px}.band-editor th:nth-child(3){width:86px}.band-editor th:nth-child(5){width:85px}.band-editor th:last-child{width:34px}.band-editor input{width:100%;min-width:0;font-size:11px!important;padding:7px!important}.band-editor .colour-field{font-family:var(--font-mono)}.field-label{display:block;font-size:11px;color:var(--fg-3);margin-top:12px}.field-label>input,.field-label>select,.field-label>textarea{display:block;width:100%;margin-top:6px}.repository-list{display:flex;flex-wrap:wrap;gap:6px}.repository-chip{font:11px/1.5 var(--font-mono)!important;overflow-wrap:anywhere}.empty-search{padding:24px;background:var(--bg-raised);border:1px solid var(--hair);border-radius:var(--r-card);color:var(--fg-2)} +.details-panel{margin:12px 0 0 180px;border-top:1px solid var(--hair);padding:26px 0}.section-title{display:flex;align-items:center;justify-content:space-between}.section-title h2{font-size:18px;margin:0 0 16px;font-weight:600}.metadata-grid{display:grid;grid-template-columns:155px minmax(0,1fr);gap:6px 18px;font-size:12px;margin:0}.metadata-grid dt{color:var(--fg-3)}.metadata-grid dd{margin:0;font-family:var(--font-mono);font-size:11px;overflow-wrap:anywhere}.last-source{font-size:11px;color:var(--fg-2);padding:12px 0;border-top:1px solid var(--hair);margin-top:16px;overflow-wrap:anywhere}.recent-errors{font-size:12px;color:var(--fg-3);margin-top:12px}.portability{margin-left:180px;border-top:1px solid var(--hair);padding:24px 0 0}.portability h2{font-size:16px;font-weight:600;margin:0 0 8px}.portability p{font-size:12px;color:var(--fg-3);max-width:680px;margin:0}.app-footer{max-width:1116px;margin:auto;padding:24px 0 32px;border-top:1px solid var(--hair);font-size:11px;color:var(--fg-3)}.app-footer>div{display:flex;gap:16px;align-items:center;margin-bottom:14px}.app-footer strong{color:var(--fg-2);font-weight:500}.app-footer nav{display:flex;flex-wrap:wrap;gap:18px}.app-footer a{text-decoration:none}.app-footer a:hover{color:var(--focus)}.publication-note{font-size:10px;margin:14px 0 0}.save-status{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);max-width:min(700px,calc(100vw - 32px));border:1px solid var(--hair-strong);background:var(--bg-raised);box-shadow:0 8px 32px #0004;color:var(--fg);padding:10px 16px;border-radius:var(--r-block);font-size:12px;z-index:5}.save-status.is-error{border-color:var(--status-error)}.setting.is-targeted{animation:target-pulse 2s ease-out;border-color:var(--focus)}@keyframes target-pulse{0%{box-shadow:0 0 0 4px color-mix(in srgb,var(--focus) 30%,transparent)}100%{box-shadow:0 0 0 0 transparent}} +@media(max-width:850px){.settings-layout{grid-template-columns:minmax(0,1fr);gap:8px}.category-nav{position:static;flex-direction:row;flex-wrap:wrap;padding:0 0 12px}.category-nav a{gap:8px}.details-panel,.portability{margin-left:0}.icon-choices{gap:6px}.choice-title{font-size:11px}.app-footer{margin:0 24px}.local-badge{display:none}.page-heading{margin-bottom:14px}}@media(max-width:600px){.app-main{padding:24px 16px 36px}.header-inner{padding:12px 16px;gap:12px}.location-label{padding-left:12px}.header-end{gap:8px}.header-link{display:none}.header-end .segmented button{padding:5px 8px;font-size:10px}.wordmark img{width:104px}.setting{padding:18px 14px}.setting.is-modified{padding-left:12px}.view-row{flex-wrap:wrap;gap:8px}#search-summary{font-size:10px}.settings-toolbar{padding-top:10px}.icon-choices{grid-template-columns:1fr}.icon-choice{grid-template-columns:14px 1fr auto;padding:10px}.icon-preview{grid-column:auto;padding:0}.band-editor table{min-width:570px}.metadata-grid{grid-template-columns:120px 1fr}.app-footer>div{align-items:flex-start;flex-direction:column;gap:6px}.copy-link{opacity:1}.setting-anchor{font-size:10px}h1{font-size:26px}} +@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;animation:none!important;transition:none!important}}@media(forced-colors:active){.switch-track,.icon-choice{border-color:CanvasText}.switch input:checked+.switch-track{background:Highlight}.ddx-brand-mono{background:CanvasText}} + +/* Keep focused native controls reachable below the sticky search toolbar. */ +input,select,textarea,button{scroll-margin-top:170px}.switch-track{pointer-events:none} + +#settings-content,.band-editor,.table-overflow{min-width:0;max-width:100%} diff --git a/apps/browser-extension/src/options/theme.ts b/apps/browser-extension/src/options/theme.ts new file mode 100644 index 00000000..1cb99764 --- /dev/null +++ b/apps/browser-extension/src/options/theme.ts @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Apply before paint; the authoritative preference is re-read from Chrome storage. +try { const theme = localStorage.getItem('diffdevil.options.theme'); if (theme === 'dark' || theme === 'light') document.documentElement.dataset.theme = theme; } catch { /* Storage denial never prevents Settings from opening. */ } diff --git a/apps/browser-extension/src/shared/catalogue.ts b/apps/browser-extension/src/shared/catalogue.ts new file mode 100644 index 00000000..122cccb4 --- /dev/null +++ b/apps/browser-extension/src/shared/catalogue.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-only +export type SettingValue = boolean | string | number; +export type Settings = Record; +export interface Setting { + readonly id: string; readonly name: string; readonly description: string; + readonly group: 'Display' | 'Policy' | 'Integration' | 'Data' | 'Appearance'; + readonly kind: 'boolean' | 'choice' | 'bands' | 'paths' | 'yaml' | 'overrides' | 'number' | 'action'; + readonly default: SettingValue; readonly advanced?: boolean; readonly choices?: readonly (readonly [string, string])[]; readonly keywords?: string; readonly destructive?: boolean; +} +const flag = (id: string, name: string, description: string, value = true, advanced = false): Setting => ({ id, name, description, default: value, advanced, kind: 'boolean', group: id.startsWith('app.') || id.startsWith('labels.') ? 'Integration' : 'Display' }); +export const DEFAULT_BANDS = JSON.stringify([ + { id: 'xs', name: 'XS', lt: 20, label: 'size/XS', color: 'C2E0C6' }, + { id: 's', name: 'S', lt: 100, label: 'size/S', color: 'BFDADC' }, + { id: 'm', name: 'M', lt: 500, label: 'size/M', color: 'C5DEF5' }, + { id: 'l', name: 'L', lt: 1000, label: 'size/L', color: 'D4C5F9' }, + { id: 'xl', name: 'XL', lt: null, label: 'size/XL', color: 'DCC6E0' }, +]); +export const SETTINGS: readonly Setting[] = [ + flag('display.enabled', 'Enable diffdevil on GitHub', 'Add replacement-aware measurements to pull-request pages. Disabling removes injected controls and restores native counters.'), + flag('display.aggregateChanged', 'Show pull-request Changed', 'Show the complete pull-request measurement across Conversation, Commits, Checks and Files changed.'), + flag('display.fileChanged', 'Show per-file Changed', 'Measure each file independently as its header appears. A file does not inherit the pull request’s size tier.'), + flag('display.decomposition', 'Show added, deleted and modified lines', 'Keep +added-only, −deleted-only and ~modified beside Changed. Replacements count once.'), + flag('display.rawChurn', 'Show raw churn', 'Show raw additions and deletions below Changed. This is separate from GitHub’s own counters.'), + flag('display.dimNativeDiffstat', 'Dim GitHub’s native diffstat', 'Make native green/red numbers and the ratio bar secondary without dimming keyboard focus outlines.'), + flag('display.bandRail', 'Show the policy band rail', 'One cell per configured band. A cell is selected only when the evidence proves that classification.'), + flag('display.virtualBandPill', 'Show the virtual size pill', 'Display your policy classification even in repositories you do not own. This does not apply a label.'), + flag('display.appStanding', 'Show result provenance', 'Identify local analysis. A matching App checkmark requires authenticated report delivery, which is not configured in this build.'), + { id: 'display.brandIcon', name: 'Diffdevil icon', description: 'Quiet monochrome provenance, the full-colour micro symbol, or no icon. Changed remains visible text.', group: 'Display', kind: 'choice', default: 'monochrome', choices: [['monochrome', 'Monochrome glyph'], ['full-color', 'Full-colour symbol'], ['none', 'None']], keywords: 'brand hide icon full color colour symbol' }, + { id: 'policy.mode', name: 'Policy mode', description: 'Composed layers personal defaults, trusted base-revision repository policy, then explicit personal repository overrides. Personal only deliberately ignores the repository policy.', group: 'Policy', kind: 'choice', default: 'composed', choices: [['composed', 'Composed'], ['repository', 'Repository'], ['personal-only', 'Personal only']] }, + { id: 'policy.preset', name: 'Personal preset', description: 'Use a configurable size policy or no classification. Advanced YAML can express the full policy directly.', group: 'Policy', kind: 'choice', default: 'size@1', choices: [['size@1', 'size@1'], ['none', 'No size policy']] }, + { id: 'policy.primaryMetric', name: 'Classification metric', description: 'Choose the fact that determines the size band. Changed remains the primary displayed measurement.', group: 'Policy', kind: 'choice', default: 'lines.changed', choices: [['lines.changed', 'Replacement-aware Changed'], ['raw.churn', 'Raw churn'], ['raw.added', 'Raw additions'], ['raw.deleted', 'Raw deletions'], ['lines.modified', 'Modified positions']] }, + { id: 'policy.bands', name: 'Bands and label mappings', description: 'Upper limits are exclusive; the last band is unbounded. Optional existing-label mappings and colours remain inspectable.', group: 'Policy', kind: 'bands', default: DEFAULT_BANDS, keywords: 'tier threshold size label colour color range' }, + flag('app.preferExactReport', 'Prefer a matching App report', 'Retained preference for future immutable App report delivery. This build analyzes locally and never calls a hosted analysis endpoint.'), + flag('display.hideNativeDiffstat', 'Hide GitHub’s native diffstat', 'Hide the native raw counters and ratio bar. This explicit choice takes precedence over dimming.', false, true), + flag('display.detailDefaultOpen', 'Open aggregate details by default', 'Open a non-modal report once for each newly acquired comparison. File reports remain on demand.', false, true), + { id: 'display.density', name: 'Display density', description: 'Compact rows or more comfortable spacing, with the same measurements.', group: 'Display', kind: 'choice', default: 'compact', advanced: true, choices: [['compact', 'Compact'], ['comfortable', 'Comfortable']] }, + ...(['includeOnly', 'exclude', 'forceInclude'] as const).map((key): Setting => ({ id: `policy.${key}`, name: { includeOnly: 'Include only paths', exclude: 'Exclude paths', forceInclude: 'Force-include paths' }[key], description: 'One diffdevil glob per line. Force-included paths win. Repository layers may replace personal path defaults; excluded file facts remain inspectable.', group: 'Policy', kind: 'paths', default: '', advanced: true, keywords: 'glob filter scope path' })), + { id: 'policy.repositoryOverrides', name: 'Repository overrides', description: 'Local-only policy modes and explicit YAML keyed by owner/repository. These do not change repository configuration.', group: 'Policy', kind: 'overrides', default: '{}', advanced: true }, + { id: 'policy.advancedYaml', name: 'Advanced personal policy', description: 'Use the real diffdevil schema, scopes, metrics, bands, queries and rules. Nonempty YAML replaces guided personal controls; those controls remain preserved.', group: 'Policy', kind: 'yaml', default: '', advanced: true, keywords: 'configuration schema compiler validation import export' }, + flag('app.showPolicyMismatch', 'Show App policy mismatches', 'Retained for future report delivery. A personal policy result must stay separate from a different App policy.', true, true), + flag('app.showStaleResult', 'Show stale App standing', 'Retained for future report delivery. An older-head report must never validate the current comparison.', true, true), + flag('labels.nativeHandoff', 'Offer the native label picker', 'Open and search GitHub’s visible picker for the mapped existing label. You confirm the native selection. No hidden POST, label creation or automatic mutation.', true, true), + { id: 'cache.maximumSize', name: 'Rebuildable cache limit', description: 'MiB for normalized reports and trusted-base policy documents. Least-recently-used entries are evicted. Raw patches are never stored.', group: 'Data', kind: 'number', default: 16, advanced: true }, + { id: 'appearance.theme', name: 'Settings theme', description: 'Follow the system, or select the authored dark or light settings theme. GitHub controls follow GitHub’s theme independently.', group: 'Appearance', kind: 'choice', default: 'system', choices: [['dark', 'Dark'], ['system', 'Automatic'], ['light', 'Light']] }, + ...[ + ['data.clearAnalysisCache', 'Clear analysis cache', 'Delete normalized reports without changing preferences or policy documents.', false], + ['data.clearPolicyCache', 'Clear repository-policy cache', 'Delete trusted-base policy documents and exact-base missing-file results.', false], + ['data.clearRepositoryOverrides', 'Clear repository overrides', 'Remove all explicit per-repository preferences. Global personal settings remain.', true], + ['data.resetAll', 'Reset all extension data', 'Delete preferences, overrides, caches and diagnostics after confirmation.', true], + ['diagnostics.copySupportSnapshot', 'Copy support snapshot', 'Copy versions, safe display preferences, sanitized error codes and storage totals. No source, private paths, repository identities or policy text.', false], + ].map(([id, name, description, destructive]): Setting => ({ id: id as string, name: name as string, description: description as string, destructive: destructive as boolean, group: 'Data', kind: 'action', default: '', advanced: true })), +]; +export const DEFAULTS: Readonly = Object.freeze(Object.fromEntries(SETTINGS.filter(item => item.kind !== 'action').map(item => [item.id, item.default]))); +export const findSetting = (id: string): Setting | undefined => SETTINGS.find(item => item.id === id); +export const advancedChanges = (settings: Settings): readonly Setting[] => SETTINGS.filter(item => item.advanced && item.kind !== 'action' && settings[item.id] !== item.default); +export function searchSettings(query: string, values?: Settings): readonly Setting[] { + const terms = query.toLocaleLowerCase('en').trim().split(/\s+/u).filter(Boolean); + return SETTINGS.filter(item => terms.every(term => term === '@modified' ? values && values[item.id] !== item.default && item.kind !== 'action' : term === '@advanced' ? item.advanced : `${item.id} ${item.name} ${item.description} ${item.group} ${item.keywords ?? ''}`.toLocaleLowerCase('en').includes(term.replace(/^#/u, '')))); +} diff --git a/apps/browser-extension/src/shared/dom.ts b/apps/browser-extension/src/shared/dom.ts new file mode 100644 index 00000000..2a49dbea --- /dev/null +++ b/apps/browser-extension/src/shared/dom.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: AGPL-3.0-only +export function node(tag: K, className = '', text = ''): HTMLElementTagNameMap[K] { + const result = document.createElement(tag); if (className) result.className = className; if (text) result.textContent = text; return result; +} +export function button(text: string, action: () => void, className = ''): HTMLButtonElement { const result = node('button', className, text); result.type = 'button'; result.addEventListener('click', action); return result; } +export function isDark(document: Document): boolean { + const mode = document.documentElement.dataset.colorMode ?? document.documentElement.dataset.theme; + return mode === 'dark' || mode !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches; +} diff --git a/apps/browser-extension/src/shared/errors.ts b/apps/browser-extension/src/shared/errors.ts new file mode 100644 index 00000000..240f31f3 --- /dev/null +++ b/apps/browser-extension/src/shared/errors.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-only +export class ExtensionError extends Error { constructor(readonly code: string, message: string) { super(message); } } +export async function boundedText(response: Response, maximum: number): Promise { + if (!response.body) return ''; + const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8', { fatal: true }); let size = 0; let text = ''; + try { for (;;) { const chunk = await reader.read(); if (chunk.done) return text + decoder.decode(); size += chunk.value.byteLength; if (size > maximum) throw new ExtensionError('SOURCE_LIMIT', 'The source exceeds the browser acquisition limit.'); text += decoder.decode(chunk.value, { stream: true }); } } + finally { await reader.cancel().catch(() => undefined); reader.releaseLock(); } +} +export const object = (value: unknown): Record => { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ExtensionError('PROVIDER_RESPONSE', 'The provider returned an unexpected response.'); return value as Record; }; +export function safePath(value: string): string { + if (typeof value !== 'string' || !value || value.length > 1024 || value.startsWith('/') || value.includes('\\') || /[\x00-\x1f]/u.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) throw new ExtensionError('POLICY_PATH', 'Use a repository-relative policy path without parent traversal.'); + return value.split('/').map(encodeURIComponent).join('/'); +} diff --git a/apps/browser-extension/src/shared/icons.ts b/apps/browser-extension/src/shared/icons.ts new file mode 100644 index 00000000..892fc72d --- /dev/null +++ b/apps/browser-extension/src/shared/icons.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { node } from './dom.js'; +/** + * Canonical compact product identity copied unchanged from + * Wolfsblvt/wolfsblvt-icons@8596b6bc2ba7c4b950963b7ec26cee1a26974439. + * `diffdevil/brand` is MIT geometry with SHA-256 + * d33cf3b1a7a8ec52706da3311ac82e0500ec6c2eee8e35baeaaa2d5ef2b9b34e. + */ +export const GLYPH_STATUS = 'accepted-diffdevil-brand-centre-seam'; +export function productIcon(mode: string, dark: boolean, url: (path: string) => string): HTMLElement | undefined { + if (mode === 'none') return undefined; + const mark = node('span', 'ddx-brand'); mark.setAttribute('aria-hidden', 'true'); + if (mode === 'full-color') { + const image = node('img'); image.alt = ''; image.width = 16; image.height = 16; image.src = url(`assets/diffdevil-symbol-micro-${dark ? 'dark' : 'light'}.svg`); mark.append(image); + } else { + const source = url('assets/diffdevil-brand.svg'); mark.classList.add('ddx-brand-mono'); + mark.style.maskImage = `url("${source}")`; mark.style.setProperty('-webkit-mask-image', `url("${source}")`); + } + return mark; +} diff --git a/apps/browser-extension/src/shared/protocol.ts b/apps/browser-extension/src/shared/protocol.ts new file mode 100644 index 00000000..a027fcea --- /dev/null +++ b/apps/browser-extension/src/shared/protocol.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import type { BrowserComparison, BrowserInput, HumanReportView, PolicyLayers } from '@wolfsblvt/diffdevil/browser'; +import type { Settings } from './catalogue.js'; +export interface PolicySource { status: 'present' | 'absent' | 'unavailable'; text?: string; blob?: string; at: number } +export interface AnalysisInput { comparison: BrowserComparison; acquisition?: BrowserInput; policy: PolicySource; templates?: Record } +export interface Packet { key: string; comparison: BrowserComparison; view: HumanReportView; files: readonly { path: string; oldPath?: string }[]; refreshedAt: number; cached: boolean } +export interface Lookup { settings: Settings; selected: PolicyLayers; reportCached: boolean; policy?: PolicySource } +export interface PublicPull { comparison: BrowserComparison; files?: readonly unknown[] } +export interface CacheInfo { entries: number; bytes: number; reportEntries: number; reportBytes: number; policyEntries: number; policyBytes: number; maximumBytes: number } +export interface Diagnostics { version: string; engine: string; schema: string; measurement: string; presenter: string; cache: CacheInfo; localBytes: number; syncBytes: number; errors: readonly { code: string; at: number }[]; last?: { repository: string; pullRequest: number; base: string; head: string; at: number } } +export type Message = + | { type: 'settings.get' } | { type: 'settings.save'; patch: unknown; replace?: boolean } + | { type: 'policy.templates'; layers: PolicyLayers } + | { type: 'source.public'; repository: string; pullRequest: number; files?: boolean } + | { type: 'source.policy'; repository: string; base: string; path: string } + | { type: 'cache.lookup'; comparison: BrowserComparison } + | { type: 'analysis.run'; input: AnalysisInput } + | { type: 'analysis.files'; key: string; paths: string[] } + | { type: 'diagnostics.get' } | { type: 'data.action'; action: string; confirmed?: boolean } + | { type: 'options.open' }; +export async function request(input: Message): Promise { + const response = await chrome.runtime.sendMessage(input) as { ok: true; value: T } | { ok: false; code: string; message: string } | undefined; + if (!response) throw new Error('The extension worker did not respond. Reload the extension and this page.'); + if (!response.ok) throw Object.assign(new Error(response.message), { code: response.code }); return response.value; +} diff --git a/apps/browser-extension/src/shared/settings-key.ts b/apps/browser-extension/src/shared/settings-key.ts new file mode 100644 index 00000000..d2b62475 --- /dev/null +++ b/apps/browser-extension/src/shared/settings-key.ts @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Isolated from the compiler-bearing settings module to keep content scripts small. */ +export const SETTINGS_KEY = 'diffdevil.settings.v1'; diff --git a/apps/browser-extension/src/shared/settings.ts b/apps/browser-extension/src/shared/settings.ts new file mode 100644 index 00000000..5c4f20f0 --- /dev/null +++ b/apps/browser-extension/src/shared/settings.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { compileBrowserPolicy, readPolicyText, stringifyPolicy, type PolicyLayers, type HumanReportView } from '@wolfsblvt/diffdevil/browser'; +import { SETTINGS, DEFAULTS, type Settings, type SettingValue } from './catalogue.js'; +export { SETTINGS_KEY } from './settings-key.js'; +export const bytes = (value: unknown): number => new TextEncoder().encode(typeof value === 'string' ? value : JSON.stringify(value)).byteLength; +export interface Band { id: string; name: string; lt: number | null; label: string; color: string } +export interface Override { mode?: PolicyLayers['mode']; yaml?: string } +export function repositoryKey(value: string): string { + const key = value.toLowerCase(); + if (!/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/u.test(key) || key.split('/').some(part => part === '.' || part === '..')) throw new Error('Use owner/repository, without a URL.'); + return key; +} + +export function bands(text: string): Band[] { + const value: unknown = JSON.parse(text); + if (!Array.isArray(value) || !value.length || value.length > 20) throw new Error('Configure between 1 and 20 bands.'); + const ids = new Set(); const labels = new Set(); let previous = 0; + return value.map((entry: unknown, index): Band => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw new Error('Each band must be an object.'); + const item = entry as Record; + if (Object.keys(item).some(key => !['id', 'name', 'lt', 'label', 'color'].includes(key))) throw new Error('Unknown band field.'); + if (typeof item.id !== 'string' || !/^[a-z][a-z0-9_]*$/u.test(item.id) || ids.has(item.id)) throw new Error('Band IDs must be unique lowercase identifiers.'); ids.add(item.id); + if (typeof item.name !== 'string' || !item.name.trim() || item.name.length > 50) throw new Error('Each band needs a short human-readable name.'); + if (index === value.length - 1 ? item.lt !== null : typeof item.lt !== 'number' || !Number.isSafeInteger(item.lt) || item.lt <= previous) throw new Error('Upper limits must increase. Only the final band has no limit.'); + if (typeof item.lt === 'number') previous = item.lt; + if (typeof item.label !== 'string' || item.label.length > 50 || /[\x00-\x1f]/u.test(item.label) || item.label.trim() && labels.has(item.label.trim())) throw new Error('Mapped labels must be unique, short, single-line names, or empty.'); if (item.label.trim()) labels.add(item.label.trim()); + if (typeof item.color !== 'string' || item.color && !/^[a-f\d]{6}$/iu.test(item.color)) throw new Error('Colour must be six hexadecimal digits, or empty.'); + return { id: item.id, name: item.name.trim(), lt: item.lt as number | null, label: item.label.trim(), color: item.color.toUpperCase() }; + }); +} +export function overrides(text: string): Record { + if (bytes(text) > 1024 * 1024) throw new Error('Repository overrides exceed 1 MiB.'); + const input: unknown = JSON.parse(text); if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Overrides must be keyed by repository.'); + const result: Record = Object.create(null) as Record; + for (const [key, entry] of Object.entries(input)) { + const repo = repositoryKey(key); if (Object.hasOwn(result, repo)) throw new Error('Duplicate repository identity.'); + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw new Error('Each override must be an object.'); + const item = entry as Record; if (Object.keys(item).some(key => !['mode', 'yaml'].includes(key))) throw new Error('Unknown repository override field.'); + if (item.mode !== undefined && !['composed', 'repository', 'personal-only'].includes(String(item.mode))) throw new Error('Unknown policy mode.'); + if (item.yaml !== undefined && (typeof item.yaml !== 'string' || bytes(item.yaml) > 256 * 1024)) throw new Error('Override YAML must be text of at most 256 KiB.'); + if (typeof item.yaml === 'string' && item.yaml.trim()) { const checked = readPolicyText(item.yaml); if (!checked.ok) throw new Error(checked.diagnostics.map(error => error.message).join('\n')); } + result[repo] = { ...(item.mode ? { mode: item.mode as PolicyLayers['mode'] } : {}), ...(typeof item.yaml === 'string' ? { yaml: item.yaml } : {}) }; + } + return result; +} +export function personalYaml(settings: Settings): string { + const yaml = String(settings['policy.advancedYaml']); if (yaml.trim()) return yaml; + const pathLines = (key: string): string[] => String(settings[`policy.${key}`]).split(/\r?\n/u).map(line => line.trim()).filter(Boolean); + const includeOnly = pathLines('includeOnly'); + // Explicit includeOnly: [] means no files in the engine. Blank guided input + // means no restriction, so omit the member instead of changing core semantics. + const paths = { ...(includeOnly.length ? { includeOnly } : {}), exclude: pathLines('exclude'), forceInclude: pathLines('forceInclude') }; + if (settings['policy.preset'] === 'none') return stringifyPolicy({ version: 1, presets: [], defaults: { paths } }); + const ranges = bands(String(settings['policy.bands'])); const mapped = ranges.every(band => band.label); + return stringifyPolicy({ version: 1, presets: [], defaults: { paths }, metrics: { review: { measure: settings['policy.primaryMetric'] } }, + bands: { size: { value: 'metrics.review', minimum: 0, ranges: ranges.map(band => band.lt === null ? { id: band.id, otherwise: true } : { id: band.id, lt: band.lt }) } }, + ...(mapped ? { labelGroups: { size: ranges.map(band => band.label) }, labelDefinitions: Object.fromEntries(ranges.map(band => [band.label, { color: band.color || 'D1D5DB', description: 'Configured diffdevil size band' }])), rules: { size: { band: 'size', onUnknown: 'hold', effects: { labels: { group: 'size', byBand: Object.fromEntries(ranges.map(band => [band.id, band.label])) } } } } } : {}) }); +} +export function selectedPolicy(settings: Settings, repository: string): PolicyLayers { + const local = overrides(String(settings['policy.repositoryOverrides']))[repositoryKey(repository)]; + return { mode: local?.mode ?? settings['policy.mode'] as PolicyLayers['mode'], personal: personalYaml(settings), ...(local?.yaml?.trim() ? { override: local.yaml } : {}) }; +} +export function validateSettings(input: unknown, previous: Settings = { ...DEFAULTS }): Settings { + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Settings must be an object.'); + const result = { ...previous }; + for (const [id, value] of Object.entries(input)) { + const item = SETTINGS.find(item => item.id === id && item.kind !== 'action'); if (!item) throw new Error(`Unknown setting: ${id}`); + if (typeof value !== typeof item.default) throw new Error(`${item.name} has the wrong value type.`); + if (item.choices && !item.choices.some(([choice]) => choice === value)) throw new Error(`${item.name} has an unsupported choice.`); + if (item.kind === 'number' && (!Number.isInteger(value) || Number(value) < 4 || Number(value) > 128)) throw new Error('Cache limit must be 4–128 MiB.'); + if (typeof value === 'string' && bytes(value) > (item.kind === 'overrides' ? 1024 * 1024 : 256 * 1024)) throw new Error(`${item.name} is too large.`); + result[id] = value as SettingValue; + } + bands(String(result['policy.bands'])); overrides(String(result['policy.repositoryOverrides'])); + const compiled = compileBrowserPolicy(JSON.stringify({ mode: 'personal-only', personal: personalYaml(result) })); + if (!compiled.ok && !compiled.diagnostics.every(error => error.code === 'E_TEMPLATE_SOURCE')) throw new Error(compiled.diagnostics.map(error => error.message).join('\n')); + return result; +} +/** Guided names/colours are presentation preferences, not a second policy dialect. */ +export function decorateView(view: HumanReportView, settings: Settings): HumanReportView { + if (String(settings['policy.advancedYaml']).trim() || settings['policy.preset'] === 'none' || !view.policy) return view; + const origin = view.policy.origins.find(item => item.path === '/bands/size'); + if (origin?.layer !== 'personal') return view; + const configured = bands(String(settings['policy.bands'])); + const result = view.rails.map(rail => rail.id !== 'size' ? rail : { ...rail, + ...(!rail.label && rail.selected && configured.find(band => band.id === rail.selected)?.label ? { label: configured.find(band => band.id === rail.selected)!.label, group: 'personal size mapping', members: configured.map(band => band.label).filter(Boolean) } : {}), cells: rail.cells.map(cell => { + const band = configured.find(band => band.id === cell.id); + return band ? { ...cell, name: band.name, ...(cell.color ? {} : band.color ? { color: band.color } : {}), ...(cell.label ? {} : band.label ? { label: band.label } : {}) } : cell; + }) }); + return { ...view, rails: result }; +} diff --git a/apps/browser-extension/store/LISTING.md b/apps/browser-extension/store/LISTING.md new file mode 100644 index 00000000..62de2741 --- /dev/null +++ b/apps/browser-extension/store/LISTING.md @@ -0,0 +1,39 @@ +# Store listing copy + +## Meaning + +Authored copy for the browser application. Publish only after the exact candidate passes the release gate. This document does not assert that a Store listing exists. + +## Name + +diffdevil for GitHub + +## Short description + +See replacement-aware Changed lines, personal size bands and inspectable diff reports directly in GitHub pull requests. + +## Single purpose + +Help users understand the size and evidence of the GitHub pull requests they are viewing by measuring available diffs locally and presenting the results in GitHub's interface. + +## Detailed description + +GitHub shows added and removed lines. diffdevil also shows what changed. + +A replaced line is one changed position, not two units of churn. diffdevil displays **Changed** with added-only, deleted-only and modified components, while keeping raw additions and deletions available as a separate view. Pull-request totals and each acquired file use the same reusable diffdevil engine. + +Choose your own size policy. Use personal defaults, the repository's trusted base-revision configuration, or a deliberate composition with your own repository override. Edit thresholds, optional existing-label mappings and path rules, or use the full diffdevil YAML policy language. Local size classifications work without owning the repository and do not apply GitHub labels. + +Open the anchored report for revisions, measurement evidence, files, metrics, scopes, bands, rules and policy provenance. Incomplete or unmeasurable material stays explicit. Missing source never becomes a reassuring exact zero. + +The full settings application includes search, Basic and Advanced views, shareable stable-setting links, authored light/dark themes, monochrome/full-colour/no-icon preferences, import/export, cache controls and redacted support diagnostics. Native GitHub counters can be left alone, dimmed or deliberately hidden. + +No account or local daemon is required. The extension does not collect a personal access token, trigger hosted App analysis or send your source to a diffdevil service. It reads the current GitHub comparison, its trusted configuration and explicitly referenced templates from GitHub, processes them locally, and stores bounded rebuildable numeric reports and policy data in your browser. Small preferences may synchronize through Chrome; advanced policy and repository overrides remain local. Cached reports can include private paths and repository identifiers. You can inspect and clear that data in Settings. + +An optional native-label handoff opens and searches GitHub's visible picker. You make the actual GitHub selection. There are no automatic label mutations, new-label creation or hidden provider writes. + +This candidate supports github.com in normal Chrome profiles. It does not support Incognito or GitHub Enterprise hosts. Very large or incomplete provider responses are qualified or rejected explicitly. The interface currently presents local results; it does not claim authenticated hosted-App report delivery. This is an independent tool, not a GitHub or Google product. + +## Links and fields + +The source, issues and product links point to the existing `Wolfsblvt/diffdevil` repository. The owner must supply an actually reachable public privacy-policy URL for the accepted source and the real developer/contact identity in the Store dashboard. Do not enter the unpublished local options URL as the public privacy-policy URL. No new website inclusion or deployment is part of this Work. diff --git a/apps/browser-extension/store/PRIVACY-ANSWERS.md b/apps/browser-extension/store/PRIVACY-ANSWERS.md new file mode 100644 index 00000000..4fc2887f --- /dev/null +++ b/apps/browser-extension/store/PRIVACY-ANSWERS.md @@ -0,0 +1,37 @@ +# Store privacy declarations + +## Meaning + +These are source-grounded draft answers for the Store review of the exact release, not a claim of legal or platform certification. Local-only processing still counts as handling user data. Do not select “no user data handled” merely because the extension has no developer-controlled backend. + +## Purpose and permissions + +The single purpose is local measurement and inspection of the GitHub pull request the user is viewing. `storage` preserves preferences, recoverable local policy and disposable reports. The github.com content-script scope supports GitHub's client-side navigation into PR views; the controller exits unrelated routes without reading their content for analysis. The api.github.com host permission supports a narrowly constructed, unauthenticated, read-only public fallback. No cookies, history API, tabs enumeration, unlimited storage, all-sites host access or clipboard permission is requested. + +## Data categories to disclose + +Website content: the available diff, file identities, revision metadata, trusted repository-policy text, explicitly referenced templates and relevant native label controls are processed to supply the visible feature. Numeric reports, paths and trusted policy may be cached. Raw patches and template contents are not persisted by the analysis cache. + +Web browsing activity: processing is confined to the active supported GitHub PR route, but comparison cache keys and the last-analysis identity can reveal which PRs were used. Disclose that limited activity rather than claiming that absence of the browser-history permission means no URL-associated user data exists. The extension does not build or transmit an unrelated browsing history. + +User-provided configuration: small display/guided settings can synchronize through Chrome's own sync service; advanced YAML and per-repository overrides remain local. Exports deliberately contain their configuration and repository identifiers. The redacted support snapshot is a separate, user-initiated operation. + +No payment, health, authentication secret, advertising identifier or general personal-communications collection is implemented. Private source or policy may itself contain sensitive material; do not claim that the extension can guarantee its absence merely because the feature does not seek that category. + +## Use, sharing and safeguards + +Data is used to provide the visible PR-analysis feature and its local settings, cache and diagnostics. There is no sale, advertising use, creditworthiness use, telemetry endpoint or unrelated transfer. GitHub receives ordinary requests for its own resources. Chrome may synchronize the small synchronized preferences. The extension does not transmit source to Wolfsblvt Works or a hosted diffdevil analyzer. A user-requested export/copy remains the user's deliberate action. + +Support diagnostics persist codes and timestamps, not raw response bodies or policy expressions. Full settings exports are not support snapshots and must not be described as redacted. Rebuildable storage has a visible limit, LRU eviction and independent clear controls; reset and destructive override removal require confirmation. Incognito is disabled. There is no independent cache encryption, and no universal encrypted-profile assertion is made. The owner must assess the exact release's storage/security posture against Store requirements before publication. + +## Remote code + +No remotely hosted executable code is used. JavaScript, compiled validators, CSS, images and optional fonts are packaged. Diffs and policy documents are data handled by the bundled, bounded language implementation. They cannot introduce new JavaScript or change the extension's host permissions. + +## Public notice and owner attestations + +The bundled privacy notice is `apps/browser-extension/PRIVACY.md`. A public, reachable version is required for the dashboard. The owner must attest to the current Limited Use requirements only after reviewing the exact package and public notice; this source session does not fabricate an owner's legal certification. Reviewer access and any required test account must be arranged by the owner without putting credentials in the repository or source archive. + +## Primary references checked 2026-09-19 + +[Privacy policy requirement](https://developer.chrome.com/docs/webstore/program-policies/privacy/), [local processing and user-data FAQ](https://developer.chrome.com/docs/webstore/program-policies/user-data-faq), [Limited Use](https://developer.chrome.com/docs/webstore/program-policies/limited-use), and [privacy troubleshooting](https://developer.chrome.com/docs/webstore/troubleshooting). Dashboard category wording and requirements must be checked at actual submission; the generated asset and source manifest are not a Store decision. diff --git a/apps/browser-extension/store/README.md b/apps/browser-extension/store/README.md new file mode 100644 index 00000000..769a7ad9 --- /dev/null +++ b/apps/browser-extension/store/README.md @@ -0,0 +1,11 @@ +# Store preparation + +## Meaning + +This directory contains authored submission material, not a Store listing. The build and asset generator do not publish anything, create a developer account or change the website. No extension ID or Store URL has been invented. + +Run `npm run extension:store` after the candidate build and successful rendered-browser QA. The output is `artifacts/browser-extension/store`: a 128px icon, five 1280×800 RGB screenshots, 440×280 and 1400×560 RGB promotional rasters, editable SVG compositions and a SHA-256 manifest. The Store icon uses the accepted master mark; the small monochrome interface slot uses the accepted centre-seam shared product glyph. No font files are part of this source-session handoff. + +[Listing](LISTING.md) contains the single purpose and ready-to-edit listing copy. [Privacy answers](PRIVACY-ANSWERS.md) maps actual behavior to declarations rather than equating local processing with no data handling. [Submission](SUBMISSION.md) contains the release gate and reviewer notes. [The application privacy notice](../PRIVACY.md) is also rendered into the unpacked extension. + +Assets remain reserved product artwork. Screenshots are actual renders of this source candidate against explicitly authored fixtures; they are not captures of a live private repository or an accepted installed extension. Their disclosure remains visible. Promotion compositions use the same measured fixture: 178 Changed = 60 added-only + 18 deleted-only + 100 modified; raw +160/−118 = 278 churn. diff --git a/apps/browser-extension/store/SUBMISSION.md b/apps/browser-extension/store/SUBMISSION.md new file mode 100644 index 00000000..3975470f --- /dev/null +++ b/apps/browser-extension/store/SUBMISSION.md @@ -0,0 +1,33 @@ +# Submission and reviewer handoff + +## Meaning + +A finite release checklist for an already authored application and asset set. Publication, public website changes, developer verification and the final Store submission are intentionally outside the source contribution. Do not treat an unverified candidate as a released extension. + +## Source and reproducibility + +Record the accepted commit and base, Node/TypeScript/browser versions, package-lock integrity, build receipt and unpacked file hashes. Use the corresponding source, not a detached binary. Run the normal repository gates, application/core tests, browser DOM/acquisition tests and installed suite. Confirm no unrelated website, deployment or npm publication step is part of the build. Review generated Action-runtime changes and check their freshness. + +## Browser release gate + +Accept installed MV3 behavior in an unrestricted allowed Chrome profile, including CSP, real settings storage, IndexedDB, worker and browser restart, quotas, disabling/reset and no raw-source persistence. Accept real public and authorized private GitHub PRs and current modern/legacy placement where applicable. Check the full PR and narrowed/commit comparisons, head changes and native label handoff with and without permission. Execute Windows and real Chrome zoom/accessibility acceptance. Managed-policy-blocked evidence and authored fixtures do not satisfy these gates. + +Confirm the full-colour/none choices and the pinned accepted centre-seam monochrome resolver. The build must verify the shared glyph hash and must not redraw or substitute its geometry. + +## Package and Store material + +Build the exact version without development harnesses, source maps, tokens, credentials, test browser profiles or dependency caches in the unpacked package. The manifest must remain MV3 with the declared minimal permissions and no remote-code allowance. The package must contain its applicable code/font notices and corresponding-source location. A font-free package is supported; do not advertise bundled typography in that package. + +Prepare the 128×128 icon with the existing master symbol, five supported 1280×800 screenshots, 440×280 small promotional tile and optional 1400×560 marquee. Generated promotional rasters are PNG24 RGB; the icon deliberately has transparent outer padding. Preserve true proportions and verify small-size readability. Review all visible source-candidate/fixture disclosures before deciding whether to replace them with accepted release captures. Do not relabel fixture images as real installed screenshots. + +Use Listing for the single purpose, short and detailed descriptions. Use Privacy Answers to disclose website content, limited PR-route activity and local/synchronized configuration. Set a real public privacy-policy URL and real owner contact identity. Do not invent a Store URL or hardcode a made-up extension ID into the footer. The source/options/product links can remain the existing repository links. + +## Reviewer instructions + +Load the corresponding unpacked package in an allowed Chrome profile, open an accessible GitHub PR, and wait for the comparison to qualify. Click Changed to inspect the report, including raw counts and revision identity. Open Settings with the toolbar action; search `#policy.advancedYaml`, change theme, inspect cache/diagnostics and try an invalid policy without overwriting the saved value. The README explains policy modes and the deliberate absence of hosted-App delivery. + +A writable repository is not required for local counts or virtual bands. Native-label handoff is visible only where the relevant native picker is available. An API rate limit or an inaccessible private base may require signed-in acquisition or a deliberate Personal only policy mode; it must not be reported as a zero-sized PR. + +## Primary image specification + +[Chrome Web Store images](https://developer.chrome.com/docs/webstore/images), checked 2026-09-19. Image dimensions and file hashes are independently recorded in `artifacts/browser-extension/store/asset-manifest.json`. This checklist does not grant publication authority or claim Store approval. diff --git a/apps/browser-extension/tests/application.test.mjs b/apps/browser-extension/tests/application.test.mjs new file mode 100644 index 00000000..649c6c6a --- /dev/null +++ b/apps/browser-extension/tests/application.test.mjs @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import test, { after } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { productionModules, memoryAreas, comparison, patch, unwrap, metadata, response } from './support.mjs'; +const loaded = await productionModules(); after(loaded.cleanup); +const m = loaded.module; +const defaults = () => ({ ...m.DEFAULTS }); +const report = () => unwrap(m.analyzeBrowserInput(JSON.stringify({ comparison, format: 'diff', text: patch, complete: true }))); +const personal = settings => unwrap(m.compileBrowserPolicy(JSON.stringify({ mode: 'personal-only', personal: m.personalYaml(settings) }))); + +test('every public setting has one unique dotted ID and a default', () => { + assert.equal(new Set(m.SETTINGS.map(item => item.id)).size, m.SETTINGS.length); + for (const item of m.SETTINGS) { assert.match(item.id, /^[a-z]+\.[A-Za-z]+$/u); assert.ok(item.name); assert.ok(item.description); if (item.kind !== 'action') assert.equal(typeof item.default, typeof m.DEFAULTS[item.id]); } +}); +test('default settings compile the real Changed policy, not raw churn', () => { const config = m.validateSettings({}); const view = unwrap(m.humanReport(report(), personal(config))); assert.equal(view.changed.value, 2); assert.equal(view.raw.churn.value, 3); assert.equal(view.report.metrics.review.value, 2); assert.equal(view.rails[0].cells.length, 5); }); +test('icon is default-on monochrome and none is a valid explicit choice', () => { assert.equal(m.DEFAULTS['display.brandIcon'], 'monochrome'); assert.equal(m.validateSettings({ 'display.brandIcon': 'none' })['display.brandIcon'], 'none'); assert.throws(() => m.validateSettings({ 'display.brandIcon': 'anything' })); }); +test('monochrome icon bytes are the accepted centre-seam shared glyph', async () => { const bytes = await readFile('apps/browser-extension/assets/diffdevil-brand.svg'); assert.equal(createHash('sha256').update(bytes).digest('hex'), 'd33cf3b1a7a8ec52706da3311ac82e0500ec6c2eee8e35baeaaa2d5ef2b9b34e'); const manifest = JSON.parse(await readFile('apps/browser-extension/manifest.json', 'utf8')); assert.ok(manifest.web_accessible_resources[0].resources.includes('assets/diffdevil-brand.svg')); }); +test('search includes stable IDs and hidden Advanced descriptions', () => { assert.equal(m.searchSettings('#display.hideNativeDiffstat')[0].id, 'display.hideNativeDiffstat'); assert.ok(m.searchSettings('@advanced policy').every(item => item.advanced)); assert.ok(m.searchSettings('native picker').some(item => item.id === 'labels.nativeHandoff')); }); +test('advanced modification count is not a second settings state', () => { const settings = m.validateSettings({ 'display.hideNativeDiffstat': true, 'display.enabled': false }); assert.deepEqual(m.advancedChanges(settings).map(item => item.id), ['display.hideNativeDiffstat']); assert.deepEqual(m.searchSettings('@modified', settings).map(item => item.id).sort(), ['display.enabled', 'display.hideNativeDiffstat']); }); +test('unknown keys, wrong types and cache limits are rejected', () => { for (const value of [{ typo: true }, { 'display.enabled': 'false' }, { 'cache.maximumSize': 3 }, { 'cache.maximumSize': 129 }, { 'cache.maximumSize': 4.5 }]) assert.throws(() => m.validateSettings(value)); }); +test('repository keys support .github and underscores but not traversal', () => { assert.equal(m.repositoryKey('Wolf/.GitHub'), 'wolf/.github'); assert.equal(m.repositoryKey('wolf/_internal'), 'wolf/_internal'); for (const value of ['../x', 'a/..', 'https://github.com/a/b', 'a/b/c', 'a\\b']) assert.throws(() => m.repositoryKey(value)); }); +test('band shape enforces strict thresholds and unique normalized labels', () => { + const values = m.bands(m.DEFAULT_BANDS); values[1].lt = values[0].lt; assert.throws(() => m.bands(JSON.stringify(values))); + const names = m.bands(m.DEFAULT_BANDS); names[0].label = 'same'; names[1].label = ' same '; assert.throws(() => m.bands(JSON.stringify(names))); + const final = m.bands(m.DEFAULT_BANDS); final.at(-1).lt = 10000; assert.throws(() => m.bands(JSON.stringify(final))); +}); +test('partial native label mappings stay visible without manufacturing a provider plan', () => { + const values = m.bands(m.DEFAULT_BANDS); values[2].label = ''; values[0].name = 'Tiny'; + const settings = m.validateSettings({ 'policy.bands': JSON.stringify(values) }); + const view = m.decorateView(unwrap(m.humanReport(report(), personal(settings))), settings); + assert.equal(view.rails[0].cells[0].name, 'Tiny'); assert.equal(view.rails[0].label, 'size/XS'); assert.ok(view.rails[0].members.includes('size/XS')); assert.equal(Object.keys(view.report.rules ?? {}).length, 0); +}); +test('classification metric changes do not rename or replace Changed', () => { const settings = m.validateSettings({ 'policy.primaryMetric': 'raw.churn' }); const view = unwrap(m.humanReport(report(), personal(settings))); assert.equal(view.changed.value, 2); assert.equal(view.report.metrics.review.value, 3); }); +test('Advanced YAML replaces the guided personal document without erasing it', () => { const yaml = 'version: 1\nsize:\n metric: raw.churn\n'; const settings = m.validateSettings({ 'policy.advancedYaml': yaml }); assert.equal(m.personalYaml(settings), yaml); assert.equal(settings['policy.bands'], m.DEFAULT_BANDS); assert.equal(unwrap(m.humanReport(report(), personal(settings))).report.metrics.review.value, 3); }); +test('invalid Advanced YAML cannot be saved', () => { assert.throws(() => m.validateSettings({ 'policy.advancedYaml': 'version: 1\nnonexistent: true\n' })); }); +test('template files may be unresolved while editing but retain real compiler diagnostics', () => { const yaml = 'version: 1\nrules:\n note:\n when: "true"\n effects:\n comment:\n mode: upsert\n templateFile: .github/note.md\n'; assert.equal(m.validateSettings({ 'policy.advancedYaml': yaml })['policy.advancedYaml'], yaml); assert.equal(m.compileBrowserPolicy(JSON.stringify({ mode: 'personal-only', personal: yaml })).ok, false); }); +test('repository overrides remain explicit and case-normalized', () => { const settings = m.validateSettings({ 'policy.repositoryOverrides': JSON.stringify({ 'Fixture/Example': { mode: 'personal-only', yaml: 'version: 1\n' } }) }); assert.equal(m.selectedPolicy(settings, 'fixture/example').mode, 'personal-only'); assert.equal(m.selectedPolicy(settings, 'different/example').mode, 'composed'); }); +test('duplicate repository identities and unsupported fields are rejected', () => { assert.throws(() => m.overrides('{"a/b":{},"A/B":{}}')); assert.throws(() => m.overrides('{"a/b":{"token":"secret"}}')); }); +test('upper repository convenience policy wins over guided lower declarations', () => { const policy = unwrap(m.compileBrowserPolicy(JSON.stringify({ mode: 'composed', personal: m.personalYaml(defaults()), repository: 'version: 1\nsize:\n metric: raw.churn\n' }))); const view = m.decorateView(unwrap(m.humanReport(report(), policy)), defaults()); assert.equal(view.report.metrics.review.value, 3); assert.equal(view.policy.origins.find(origin => origin.path === '/metrics/review').layer, 'repository'); }); + +test('preferences split private policy into local storage and small preferences into sync', async () => { + const areas = memoryAreas(); const preferences = new m.Preferences(areas); await preferences.save({ 'policy.advancedYaml': 'version: 1\n', 'policy.repositoryOverrides': '{"private/repository":{"mode":"personal-only"}}' }); + assert.equal(areas.sync.data[m.SETTINGS_KEY]['policy.advancedYaml'], undefined); assert.equal(areas.sync.data[m.SETTINGS_KEY]['policy.repositoryOverrides'], undefined); assert.equal(areas.local.data[m.SETTINGS_KEY]['policy.advancedYaml'], 'version: 1\n'); +}); +test('concurrent writes are serialized without losing a field', async () => { const preferences = new m.Preferences(memoryAreas()); await Promise.all([preferences.save({ 'display.enabled': false }), preferences.save({ 'display.rawChurn': false })]); const settings = await preferences.load(); assert.equal(settings['display.enabled'], false); assert.equal(settings['display.rawChurn'], false); }); +test('sync quota failure rolls both stores back', async () => { const areas = memoryAreas(); const preferences = new m.Preferences(areas); await preferences.save({ 'display.enabled': false }); const previous = structuredClone(areas.local.data); areas.sync.failures = 1; await assert.rejects(preferences.save({ 'display.enabled': true, 'policy.advancedYaml': 'version: 1\n' })); assert.equal((await preferences.load())['display.enabled'], false); assert.deepEqual(areas.local.data, previous); }); +test('failed rollback preserves a recovery journal until a later restart', async () => { const areas = memoryAreas(); const preferences = new m.Preferences(areas); await preferences.save({ 'display.enabled': false }); areas.sync.failures = 2; await assert.rejects(preferences.save({ 'display.enabled': true }), /recovery remains pending/); assert.ok(areas.local.data['diffdevil.settings.pending.v1']); const restarted = new m.Preferences(areas); assert.equal((await restarted.load())['display.enabled'], false); assert.equal(areas.local.data['diffdevil.settings.pending.v1'], undefined); }); +test('interrupted settings commit restores the last committed pair', async () => { const areas = memoryAreas(); const preferences = new m.Preferences(areas); await preferences.save({ 'display.enabled': false }); const sync = structuredClone(areas.sync.data[m.SETTINGS_KEY]); const local = structuredClone(areas.local.data[m.SETTINGS_KEY]); areas.local.data['diffdevil.settings.pending.v1'] = { version: 1, sync, local }; areas.local.data[m.SETTINGS_KEY]['policy.advancedYaml'] = 'broken: true'; const restarted = new m.Preferences(areas); assert.equal((await restarted.load())['policy.advancedYaml'], ''); }); +test('damaged persisted settings and journals fail without silent reset', async () => { const areas = memoryAreas(); areas.sync.data[m.SETTINGS_KEY] = []; await assert.rejects(new m.Preferences(areas).load(), /damaged/); assert.deepEqual(areas.sync.data[m.SETTINGS_KEY], []); areas.sync.data = {}; areas.local.data['diffdevil.settings.pending.v1'] = { version: 99 }; await assert.rejects(new m.Preferences(areas).load(), /journal/); }); +test('settings reset clears local, synced, and session state', async () => { const areas = memoryAreas(); const preferences = new m.Preferences(areas); await preferences.save({ 'display.enabled': false }); areas.session.data.last = 'private'; await preferences.reset(); assert.deepEqual(areas.sync.data, {}); assert.deepEqual(areas.local.data, {}); assert.deepEqual(areas.session.data, {}); assert.equal((await preferences.load())['display.enabled'], true); }); + +const entry = (key, size, touched) => ({ key, size, touched, kind: 'report', value: {} }); +test('LRU eviction preserves newest entries and handles replacement accounting', () => { const entries = [entry('a', 4, 1), entry('b', 4, 2), entry('c', 4, 3)]; assert.deepEqual(m.evictions(entries, 8), ['a']); assert.deepEqual(m.evictions(entries, 8, entry('b', 5, 4)), ['a', 'c']); }); +test('LRU eviction is deterministic and never mutates the existing catalogue', () => { const values = [entry('z', 5, 1), entry('a', 5, 1)]; const before = structuredClone(values); assert.deepEqual(m.evictions(values, 5), ['a']); assert.deepEqual(values, before); }); +test('budget decisions preserve the maximum over a sweep of entry sizes', () => { for (let count = 0; count < 100; count++) { const entries = Array.from({ length: count }, (_, i) => entry(String(i), (i * 997) % 1000 + 1, i)); const removed = new Set(m.evictions(entries, 5000)); assert.ok(entries.filter(item => !removed.has(item.key)).reduce((sum, item) => sum + item.size, 0) <= 5000); } }); +test('invalid cache capacity is rejected before opening IndexedDB', async () => { const cache = new m.AnalysisCache({ open() { throw new Error('must not open'); } }); await assert.rejects(cache.configure(3), /4–128/); await assert.rejects(cache.configure(NaN), /4–128/); }); + +const sender = { id: 'owned', frameId: 0, url: 'https://github.com/fixture/example/pull/42/files' }; +test('only the owned extension options page can mutate settings', () => { assert.throws(() => m.authorize(sender, { type: 'settings.save', patch: {} }, 'owned'), /Settings/); assert.equal(m.authorize({ id: 'owned', url: 'chrome-extension://owned/options.html' }, { type: 'settings.save', patch: {} }, 'owned').options, true); }); +test('messages from websites, other extensions and subframes are rejected', () => { for (const candidate of [{ ...sender, id: 'other' }, { ...sender, url: 'https://evil.test' }, { ...sender, frameId: 1 }, { ...sender, url: 'chrome-extension://other/options.html' }]) assert.throws(() => m.authorize(candidate, { type: 'settings.get' }, 'owned')); }); +test('a PR page cannot become an arbitrary repository source proxy', () => { assert.throws(() => m.authorize(sender, { type: 'source.public', repository: 'private/other', pullRequest: 42 }, 'owned')); assert.throws(() => m.authorize(sender, { type: 'source.public', repository: 'fixture/example', pullRequest: 43 }, 'owned')); assert.equal(m.authorize(sender, { type: 'cache.lookup', comparison }, 'owned').pullRequest, 42); }); +test('policy paths are data with no absolute, parent or backslash escape', () => { for (const path of ['../secret', '/etc/passwd', 'a/../b', 'a\\b', 'a//b', '.']) assert.throws(() => m.safePath(path)); assert.equal(m.safePath('.github/a b.md'), '.github/a%20b.md'); }); +test('route parsing covers tabs, .github repositories, and rejects foreign hosts', () => { for (const tab of ['', '/files', '/commits', '/checks']) assert.equal(m.route(`https://github.com/fixture/example/pull/42${tab}`).pullRequest, 42); assert.equal(m.route('https://github.com/fixture/.github/pull/1').repository, 'fixture/.github'); for (const url of ['https://evil.test/fixture/example/pull/42', 'https://github.com/fixture/example/issues/42', 'https://github.com/fixture/example/pull/0']) assert.equal(m.route(url), undefined); }); + +test('public acquisition uses read-only anonymous requests and verifies revisions twice', async () => { + const calls = []; const source = new m.PublicSource(async (url, init) => { calls.push({ url, init }); return response(url.includes('/files?') ? [{ filename: 'a.ts', status: 'modified', additions: 2, deletions: 1, patch: '@@ -1,2 +1,3 @@\n-old\n+new\n+extra\n keep' }] : metadata()); }); + const result = await source.pull('fixture/example', 42, true); assert.equal(result.files.length, 1); assert.equal(calls.length, 3); + for (const { url, init } of calls) { assert.ok(url.startsWith('https://api.github.com/repos/fixture/example/')); assert.equal(init.credentials, 'omit'); assert.equal(init.method, undefined); assert.equal(init.redirect, 'error'); assert.equal(init.headers.Authorization, undefined); } +}); +test('public file pagination is capped at GitHub’s 3,000-file ceiling', async () => { + let pages = 0; const source = new m.PublicSource(async url => url.includes('/files?') ? (pages++, response(Array.from({ length: 100 }, (_, i) => ({ filename: `${pages}-${i}` })))) : response(metadata({ changed_files: 3001 }))); + const result = await source.pull('fixture/example', 42, true); assert.equal(pages, 30); assert.equal(result.files.length, 3000); assert.equal(result.comparison.changedFiles, 3001); +}); +test('head movement during API pagination cannot become a current report', async () => { let reads = 0; const source = new m.PublicSource(async url => response(url.includes('/files?') ? [{ filename: 'a' }] : metadata({ head: { sha: reads++ ? 'c'.repeat(40) : comparison.head } }))); await assert.rejects(source.pull('fixture/example', 42, true), error => error.code === 'COMPARISON_MOVED'); }); +test('foreign provider PR identity and invalid counters are rejected', async () => { for (const value of [metadata({ number: 43 }), metadata({ changed_files: -1 }), metadata({ base: { sha: comparison.base, repo: { full_name: 'other/repo' } } })]) await assert.rejects(new m.PublicSource(async () => response(value)).pull('fixture/example', 42)); }); +test('private and rate-limited API responses have explicit distinct failures', async () => { for (const [status, code] of [[404, 'PUBLIC_UNAVAILABLE'], [403, 'GITHUB_RATE_LIMIT'], [429, 'GITHUB_RATE_LIMIT']]) await assert.rejects(new m.PublicSource(async () => response({}, status)).pull('fixture/example', 42), error => error.code === code); }); +test('404 policy is absence only after independently confirmed exact-base access', async () => { const paths = []; const source = new m.PublicSource(async url => { paths.push(url); return url.includes('/git/commits/') ? response({ sha: comparison.base }) : response({}, 404); }); assert.equal((await source.policy('fixture/example', comparison.base, '.diffdevil.yml')).status, 'absent'); assert.equal(paths.length, 2); await assert.rejects(new m.PublicSource(async () => response({}, 404)).policy('fixture/example', comparison.base, '.diffdevil.yml'), error => error.code === 'POLICY_ACCESS'); }); +test('trusted base-policy UTF-8 is decoded without remote executable content', async () => { const text = 'version: 1\n# Grüße\n'; const source = new m.PublicSource(async () => response({ type: 'file', encoding: 'base64', sha: 'c'.repeat(40), content: Buffer.from(text).toString('base64') })); const result = await source.policy('fixture/example', comparison.base, '.diffdevil.yml'); assert.equal(result.text, text); assert.equal(result.blob, 'c'.repeat(40)); }); +test('symlinks, invalid blob identity and over-limit policy bodies are rejected', async () => { for (const value of [{ type: 'symlink', encoding: 'base64', sha: 'c'.repeat(40), content: '' }, { type: 'file', encoding: 'base64', sha: 'not-a-sha', content: '' }, { type: 'file', encoding: 'base64', sha: 'c'.repeat(40), content: Buffer.alloc(256 * 1024 + 1).toString('base64') }]) await assert.rejects(new m.PublicSource(async () => response(value)).policy('fixture/example', comparison.base, '.diffdevil.yml')); }); +test('bounded UTF-8 reader accounts for bytes and cancels oversized responses', async () => { assert.equal(await m.boundedText(new Response('éé'), 4), 'éé'); await assert.rejects(m.boundedText(new Response('éé'), 3), error => error.code === 'SOURCE_LIMIT'); await assert.rejects(m.boundedText(new Response(Uint8Array.from([0xff])), 10)); }); + +test('portable browser export is truly closed and CSP-safe', async () => { const source = await readFile('dist/browser/index.js', 'utf8'); assert.doesNotMatch(source, /\bnew Function\s*\(|\beval\s*\(/u); assert.doesNotMatch(source, /(?:from|import)\s*['"]node:/u); const metadata = JSON.parse(await readFile('dist/browser/metafile.json', 'utf8')); assert.ok(Object.values(metadata.outputs).every(output => output.imports.every(item => !item.external))); }); +test('content bundle contains no compiler, parser or heavy policy runtime', async () => { const meta = JSON.parse(await readFile('artifacts/browser-extension/bundle-metafile.json', 'utf8')); const entries = Object.keys(meta.content.inputs); assert.ok(entries.every(name => !/chevrotain|\/policy\/compile|dist\/browser\/index/u.test(name)), entries.join('\n')); const code = await readFile('artifacts/browser-extension/unpacked/content.js'); assert.ok(code.length < 100 * 1024, `Content bundle: ${code.length} bytes`); }); +test('manifest confines permissions, executable resources and incognito behavior', async () => { const manifest = JSON.parse(await readFile('apps/browser-extension/manifest.json', 'utf8')); assert.equal(manifest.manifest_version, 3); assert.deepEqual(manifest.permissions, ['storage']); assert.deepEqual(manifest.host_permissions, ['https://api.github.com/*']); assert.equal(manifest.incognito, 'not_allowed'); assert.ok(manifest.web_accessible_resources.flatMap(item => item.resources).every(name => name.endsWith('.svg'))); assert.doesNotMatch(manifest.content_security_policy.extension_pages, /unsafe-eval/u); assert.equal(manifest.externally_connectable, undefined); }); +test('settings document has no invented Store link or unrelated remote assets', async () => { const html = await readFile('apps/browser-extension/options.html', 'utf8'); assert.doesNotMatch(html, /chromewebstore\.google\.com|fonts\.google|googletagmanager/u); assert.match(html, /No Chrome Web Store listing/u); assert.match(html, /privacy\.html/u); }); diff --git a/apps/browser-extension/tests/support.mjs b/apps/browser-extension/tests/support.mjs new file mode 100644 index 00000000..37bff040 --- /dev/null +++ b/apps/browser-extension/tests/support.mjs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { build } from 'esbuild'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +export async function productionModules() { + const directory = await mkdtemp(join(tmpdir(), 'diffdevil-extension-tests-')); + const file = join(directory, 'modules.mjs'); + await build({ stdin: { contents: [ + `export * from './apps/browser-extension/src/shared/catalogue.ts';`, + `export * from './apps/browser-extension/src/shared/settings.ts';`, + `export * from './apps/browser-extension/src/shared/errors.ts';`, + `export * from './apps/browser-extension/src/background/preferences.ts';`, + `export * from './apps/browser-extension/src/background/cache.ts';`, + `export * from './apps/browser-extension/src/background/authorization.ts';`, + `export * from './apps/browser-extension/src/background/public-source.ts';`, + `export * from './apps/browser-extension/src/content/github.ts';`, + `export * from './dist/browser/index.js';`, + ].join('\n'), resolveDir: resolve('.') }, outfile: file, bundle: true, platform: 'node', format: 'esm', target: 'node22', alias: { '@wolfsblvt/diffdevil/browser': resolve('dist/browser/index.js') } }); + return { module: await import(pathToFileURL(file).href), cleanup: () => rm(directory, { recursive: true, force: true }) }; +} +export class MemoryArea { + data = {}; + failures = 0; + async get(keys) { const result = keys === undefined || keys === null ? this.data : Object.fromEntries((Array.isArray(keys) ? keys : [keys]).filter(key => Object.hasOwn(this.data, key)).map(key => [key, this.data[key]])); return structuredClone(result); } + async set(values) { if (this.failures > 0) { this.failures--; throw new Error('simulated quota failure'); } this.data = { ...this.data, ...structuredClone(values) }; } + async remove(keys) { for (const key of Array.isArray(keys) ? keys : [keys]) delete this.data[key]; } + async clear() { this.data = {}; } + async getBytesInUse(keys) { return new TextEncoder().encode(JSON.stringify(await this.get(keys))).byteLength; } + async setAccessLevel() {} +} +export function memoryAreas() { return { sync: new MemoryArea(), local: new MemoryArea(), session: new MemoryArea() }; } +export const comparison = { host: 'github.com', repository: 'fixture/example', pullRequest: 42, base: 'a'.repeat(40), head: 'b'.repeat(40), changedFiles: 1, additions: 2, deletions: 1 }; +export const patch = 'diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1,2 +1,3 @@\n-old\n+new\n+extra\n keep\n'; +export const unwrap = result => { if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); return result.value; }; +export function metadata(overrides = {}) { return { number: 42, base: { sha: comparison.base, repo: { full_name: 'fixture/example' } }, head: { sha: comparison.head }, changed_files: 1, additions: 2, deletions: 1, ...overrides }; } +export const response = (value, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } }); diff --git a/apps/browser-extension/tsconfig.json b/apps/browser-extension/tsconfig.json new file mode 100644 index 00000000..6aa83168 --- /dev/null +++ b/apps/browser-extension/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable" + ], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "skipLibCheck": true, + "noEmit": true, + "types": [], + "baseUrl": "../..", + "paths": { + "@wolfsblvt/diffdevil/browser": [ + "dist/lib/browser/index.d.ts" + ], + "@wolfsblvt/diffdevil/browser/text": [ + "dist/lib/browser/text.d.ts" + ] + } + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/docs/README.md b/docs/README.md index f337689f..7f58f6dc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,6 +56,7 @@ when a custom formula, scope, or policy needs it. | Presets and shortcut lowering | [`integration/presets-and-shortcuts.md`](integration/presets-and-shortcuts.md) | | Shared frozen examples and capture maintenance | [`integration/example-catalogue.md`](integration/example-catalogue.md) | | Templates and comment lifecycle | [`integration/templates.md`](integration/templates.md) | +| GitHub browser extension and portable browser API | [`integration/browser-extension.md`](integration/browser-extension.md) | | TypeScript embedding | [`integration/typescript-api.md`](integration/typescript-api.md) | | Playground response schema | [`../apps/playground/contracts/response-v1.schema.json`](../apps/playground/contracts/response-v1.schema.json) | diff --git a/docs/integration/browser-extension.md b/docs/integration/browser-extension.md new file mode 100644 index 00000000..9c0180d3 --- /dev/null +++ b/docs/integration/browser-extension.md @@ -0,0 +1,44 @@ +# Browser integration + +## Meaning + +The browser host reuses the portable diffdevil engine and real policy dialect. It does not launch the CLI, require a Node daemon, request a PAT, parse terminal output or turn DOM churn counters into replacement-aware facts. The application and provider limitations are documented in [`apps/browser-extension`](../../apps/browser-extension/README.md). + +## Public entry point + +After a normal source build, `@wolfsblvt/diffdevil/browser` resolves to a self-contained MIT ESM bundle in `dist/browser/index.js`. Consumers do not need a private recipe of Node shims or dynamic schema compilation. The lightweight `@wolfsblvt/diffdevil/browser/text` export supplies numeric/evidence text without the parser and compiler. + +```ts +import { analyzeBrowserInput, compileBrowserPolicy, humanReport } from '@wolfsblvt/diffdevil/browser'; + +const comparison = { + host: 'github.com' as const, + repository: 'example/project', + pullRequest: 42, + base: 'a'.repeat(40), + head: 'b'.repeat(40), + changedFiles: 1, +}; +const diff = 'diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n'; +const facts = analyzeBrowserInput(JSON.stringify({ comparison, format: 'diff', text: diff, complete: true })); +const policy = compileBrowserPolicy(JSON.stringify({ mode: 'personal-only', personal: 'version: 1\n' })); +if (!facts.ok) throw new Error(facts.diagnostics.map(item => item.message).join('\n')); +if (!policy.ok) throw new Error(policy.diagnostics.map(item => item.message).join('\n')); +const view = humanReport(facts.value, policy.value); +if (!view.ok) throw new Error(view.diagnostics.map(item => item.message).join('\n')); +// view.value.changed is exact 1; raw churn is exact 2. +``` + +The repeated SHA strings are explicit example identities, not real provider proof. A production host must establish full immutable identities and revalidate them around acquisition. `complete: true` describes transport, not proof that all files were supplied. Without independent cardinality, aggregate uncertainty survives. Optional additions/deletions cross-check provider evidence. A browser view of one narrowed commit cannot claim a whole-PR comparison merely because the PR number matches. + +## Policy and projection + +`compileBrowserPolicy` supports `repository`, `composed` and `personal-only`. The serialized request contains personal YAML and, when applicable, repository YAML at the trusted base, an explicit repository override and a map of already acquired trusted template text. The compiler never fetches templates or authorizes writes. `requiredTemplates` identifies only explicit selected-policy template paths; the host must validate and acquire them safely. + +`humanReport` returns the typed aggregate projection, or a file projection when given its exact path. It retains real evidence, components, raw measurements, source/report identities, configured bands and policy provenance. Excluded files remain measurable facts but do not receive a virtual policy size. `readPolicyText` validates the real document schema; it is not a new browser-specific configuration dialect. + +`appStanding` compares fully qualified identities for a future authenticated report-delivery source. Matching identity is not approval, safety or a provider effect. An untrusted DOM comment cannot establish that source. The current extension presents local results and does not guess a hosted delivery route. + +## Build, licence and verification + +Reusable source and the browser bundle are MIT. The concrete Chrome application is AGPL-3.0-only; identity assets remain reserved. The closed-bundle smoke checks and browser/core tests are separate from installed and live-provider acceptance. The [qualification manual](../../apps/browser-extension/QA.md) identifies the commands, evidence files and unverified environments. diff --git a/package.json b/package.json index e642ad7b..f6c74dc1 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "check:prep": "node ./tools/check-prep.mjs", "build": "node tools/build.mjs", "build:offline": "npm run build", - "test": "node tools/test.mjs", - "verify": "npm run check:prep && npm run build && npm test && npm run test:release && npm run check:actions && npm run check:workers && npm run check:github-app && npm run check:website", + "test": "node tools/browser-extension-build.mjs --without-fonts && node tools/test.mjs", + "verify": "npm run check:prep && npm run build && npm test && npm run test:release && npm run check:actions && npm run check:workers && npm run check:github-app && npm run check:website && npm run extension:check && node tools/browser-extension-build.mjs", "verify:offline": "npm run verify", "test:package": "node tools/package-smoke.mjs", "prepack": "npm run build", @@ -31,7 +31,13 @@ "website:check": "node tools/website-docs.mjs && astro check --root apps/website", "check:website": "node tools/website-assets.mjs && node tools/website-docs.mjs && astro build --root apps/website", "test:website": "node --test apps/website/website.test.mjs", - "qa:website": "node apps/website/qa/run.mjs" + "qa:website": "node apps/website/qa/run.mjs", + "extension:build": "npm run build && npm run extension:check && node tools/browser-extension-build.mjs", + "extension:check": "node node_modules/typescript/bin/tsc -p apps/browser-extension/tsconfig.json --pretty false", + "extension:test": "node tools/browser-extension-build.mjs --without-fonts && node --test apps/browser-extension/tests/*.test.mjs", + "extension:qa": "node apps/browser-extension/qa/run.mjs && node apps/browser-extension/qa/acquisition.mjs", + "extension:qa:installed": "node apps/browser-extension/qa/installed.mjs", + "extension:store": "node tools/browser-extension-store.mjs" }, "keywords": [ "git", @@ -74,10 +80,19 @@ "./github": { "types": "./dist/lib/github/index.d.ts", "import": "./dist/lib/github/index.js" + }, + "./browser": { + "types": "./dist/lib/browser/index.d.ts", + "import": "./dist/browser/index.js" + }, + "./browser/text": { + "types": "./dist/lib/browser/text.d.ts", + "import": "./dist/lib/browser/text.js" } }, "files": [ "dist/lib", + "dist/browser", "src/diffdevil/**/*.ts", "src/diffdevil/contracts", "src/diffdevil/presets/*.yml", diff --git a/src/diffdevil/browser/acquisition.ts b/src/diffdevil/browser/acquisition.ts new file mode 100644 index 00000000..877e0cd4 --- /dev/null +++ b/src/diffdevil/browser/acquisition.ts @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +import { capture, fail, unwrap } from '../errors.js'; +import { contentId } from '../inert.js'; +import { analyzeChanges } from '../report.js'; +import { parseUnifiedDiff, type ChangeInput } from '../sources/patch.js'; +import { changeFromGitHubFile } from './github-change.js'; +import { githubInteger, githubRecord, githubSha, githubString, repositoryPath } from '../github/values.js'; +import type { FileSet, Report, Result } from '../model.js'; + +export const BROWSER_ADAPTER = 'diffdevil.browser/1'; +export const MAX_ACQUISITION_BYTES = 24 * 1024 * 1024; +export interface BrowserComparison { + readonly host: 'github.com'; readonly repository: string; readonly pullRequest: number; + readonly base: string; readonly head: string; readonly changedFiles?: number; + readonly additions?: number; readonly deletions?: number; +} +export type BrowserInput = { readonly comparison: BrowserComparison; readonly complete: boolean } & ( + { readonly format: 'diff'; readonly text: string } | + { readonly format: 'github-files'; readonly files: readonly unknown[]; readonly diff?: string } +); +export function readComparison(input: unknown): BrowserComparison { + const value = githubRecord(input, 'Browser comparison'); + if (value.host !== 'github.com') fail('E_BROWSER_HOST', 'The browser adapter supports github.com only.', 'source'); + const repository = githubString(value.repository, 'repository'); repositoryPath(repository); + if (repository.split('/').some(part => part === '.' || part === '..')) fail('E_BROWSER_IDENTITY', 'Invalid repository identity.', 'source'); + return { host: 'github.com', repository, pullRequest: githubInteger(value.pullRequest, 'pull request', 1), + base: githubSha(value.base, 'base SHA'), head: githubSha(value.head, 'head SHA'), + ...(value.changedFiles === undefined ? {} : { changedFiles: githubInteger(value.changedFiles, 'file count') }), + ...(value.additions === undefined ? {} : { additions: githubInteger(value.additions, 'additions') }), + ...(value.deletions === undefined ? {} : { deletions: githubInteger(value.deletions, 'deletions') }) }; +} +export function comparisonKey(input: BrowserComparison): string { + const c = readComparison(input); return `${c.host}/${c.repository.toLowerCase()}#${c.pullRequest}@${c.base}...${c.head}`; +} +/** A damaged block supplies no proof. Independently complete blocks still matter. */ +function recovered(text: string): ChangeInput[] { + const result: ChangeInput[] = []; const paths = new Set(); + for (const block of text.split(/(?=^diff --git )/mu)) { + if (!block.startsWith('diff --git ')) continue; + let parsed: readonly ChangeInput[]; + try { parsed = parseUnifiedDiff(block); } catch { continue; } + if (parsed.length !== 1) continue; + const file = parsed[0]!; + if (paths.has(file.path)) fail('E_BROWSER_DUPLICATE', 'Duplicate file identity in the acquired diff.', 'source'); + paths.add(file.path); result.push(file); + } + return result; +} +function unknownFile(file: ChangeInput, reason: string): ChangeInput { + return { path: file.path, ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }), + kind: file.kind, changeType: file.changeType, incompleteReason: reason }; +} +/** Serialized provider evidence is the public boundary; no alternate diff arithmetic. */ +export function analyzeBrowserInput(json: string): Result { + return capture(() => { + if (typeof json !== 'string' || new TextEncoder().encode(json).byteLength > MAX_ACQUISITION_BYTES) fail('E_BROWSER_LIMIT', 'Browser acquisition exceeds 24 MiB.', 'source'); + const input = githubRecord(JSON.parse(json), 'Browser acquisition'); const comparison = readComparison(input.comparison); + if (typeof input.complete !== 'boolean') fail('E_BROWSER_EVIDENCE', 'Transport completeness must be explicit.', 'source'); + let complete = input.complete; let changes: ChangeInput[]; + if (input.format === 'github-files') { + if (!Array.isArray(input.files)) fail('E_BROWSER_INPUT', 'Expected the provider file array.', 'source'); + changes = input.files.map(changeFromGitHubFile); + if (typeof input.diff === 'string') { + const proofs = new Map(recovered(input.diff).map(file => [file.path, file])); + changes = changes.map(file => { + const proof = proofs.get(file.path); + if (!proof || proof.oldPath !== file.oldPath || proof.changeType !== file.changeType) return file; + if (proof.kind === 'binary' || proof.kind === 'submodule') return { ...file, kind: proof.kind }; + return proof.patch && file.additions === proof.additions && file.deletions === proof.deletions ? { ...file, kind: 'text', patch: proof.patch } : file; + }); + } + } else if (input.format === 'diff') { + if (typeof input.text !== 'string') fail('E_BROWSER_INPUT', 'Expected unified-diff text.', 'source'); + if (input.text.trim() && !input.text.startsWith('diff --git ') && !input.text.startsWith('--- ')) fail('E_BROWSER_INPUT', 'The response is not a unified diff.', 'source'); + try { changes = [...parseUnifiedDiff(input.text)]; } + catch { changes = recovered(input.text); complete = false; } + } else return fail('E_BROWSER_INPUT', 'Unsupported browser acquisition format.', 'source'); + if (new Set(changes.map(file => file.path)).size !== changes.length) fail('E_BROWSER_DUPLICATE', 'Duplicate file identity.', 'source'); + if (comparison.changedFiles !== undefined && changes.length > comparison.changedFiles) fail('E_BROWSER_EVIDENCE', 'Acquired files exceed the observed PR total.', 'source'); + const all = comparison.changedFiles !== undefined && changes.length === comparison.changedFiles; + if (!complete && input.format === 'github-files' && all && changes.length) fail('E_BROWSER_EVIDENCE', 'Pagination contradicts the observed complete file count.', 'source'); + if (input.complete === false && input.format === 'diff' && changes.length) changes[changes.length - 1] = unknownFile(changes.at(-1)!, 'BROWSER_TRUNCATED_DIFF'); + if (all) { + const added = changes.reduce((n, file) => n + (file.additions ?? file.patch?.additions ?? 0), 0); + const deleted = changes.reduce((n, file) => n + (file.deletions ?? file.patch?.deletions ?? 0), 0); + if (comparison.additions !== undefined && comparison.additions !== added || comparison.deletions !== undefined && comparison.deletions !== deleted) changes = changes.map(file => unknownFile(file, 'BROWSER_COUNTER_MISMATCH')); + } + const fileSet: FileSet = all ? { complete: true, total: { status: 'exact', value: changes.length } } + : { complete: false, total: comparison.changedFiles === undefined ? { status: 'unknown', lower: changes.length, reasons: [{ code: 'BROWSER_FILE_SET_UNPROVEN' }] } : { status: 'exact', value: comparison.changedFiles } }; + return unwrap(analyzeChanges(changes, { fileSet, source: { kind: input.format === 'diff' ? 'unified-diff' : 'github-api', comparison: 'three-dot', + repository: comparison.repository, pullRequest: comparison.pullRequest, base: comparison.base, head: comparison.head, + comparisonId: contentId('comparison', [BROWSER_ADAPTER, comparisonKey(comparison)]) } })); + }); +} diff --git a/src/diffdevil/browser/app.ts b/src/diffdevil/browser/app.ts new file mode 100644 index 00000000..29496330 --- /dev/null +++ b/src/diffdevil/browser/app.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +import { comparisonKey, readComparison, type BrowserComparison } from './acquisition.js'; +export interface AppReportIdentity { + readonly kind: 'diffdevil.browser-report'; readonly version: 1; readonly comparison: BrowserComparison; + readonly reportId: string; readonly policyDigest: string; readonly engine: string; readonly schema: string; readonly measurement: string; readonly presenter: string; +} +export type AppStanding = 'local' | 'matching' | 'policy-mismatch' | 'stale' | 'incompatible'; +/** Agreement is not approval. Call only for an authenticated immutable report delivery. */ +export function appStanding(local: AppReportIdentity, app?: AppReportIdentity): AppStanding { + if (!app) return 'local'; + if (app.kind !== 'diffdevil.browser-report' || app.version !== 1) return 'incompatible'; + try { + const a = readComparison(local.comparison); const b = readComparison(app.comparison); + if (a.repository.toLowerCase() !== b.repository.toLowerCase() || a.pullRequest !== b.pullRequest) return 'incompatible'; + if (comparisonKey(a) !== comparisonKey(b)) return 'stale'; + if (['engine', 'schema', 'measurement', 'presenter'].some(key => local[key as keyof AppReportIdentity] !== app[key as keyof AppReportIdentity])) return 'incompatible'; + if (local.policyDigest !== app.policyDigest) return 'policy-mismatch'; + return local.reportId === app.reportId ? 'matching' : 'incompatible'; + } catch { return 'incompatible'; } +} diff --git a/src/diffdevil/browser/github-change.ts b/src/diffdevil/browser/github-change.ts new file mode 100644 index 00000000..08fa49d7 --- /dev/null +++ b/src/diffdevil/browser/github-change.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +/** Pure provider normalization, shared by Node and the browser without network I/O. */ +import { fail } from '../errors.js'; +import { parsePatch, type ChangeInput } from '../sources/patch.js'; +import { validatePath } from '../paths.js'; +import { githubInteger, githubRecord, githubString } from '../github/values.js'; + +export function changeFromGitHubFile(input: unknown): ChangeInput { + const file = githubRecord(input, 'changed file'); + const path = validatePath(githubString(file.filename, 'filename')); + const statuses: Record = { added: 'added', removed: 'deleted', modified: 'modified', renamed: 'renamed', copied: 'copied', changed: 'type-changed', unchanged: 'modified' }; + const status = githubString(file.status, 'file status'), changeType = statuses[status]; + if (!changeType) fail('E_GITHUB_RESPONSE', `Unsupported GitHub file status: ${status}.`, 'source'); + const additions = githubInteger(file.additions, 'raw additions'), deletions = githubInteger(file.deletions, 'raw deletions'); + if (!Number.isSafeInteger(additions + deletions) || file.changes !== undefined && file.changes !== additions + deletions) fail('E_GITHUB_RESPONSE', 'GitHub raw file counters disagree.', 'source'); + const oldPath = changeType === 'renamed' || changeType === 'copied' ? validatePath(githubString(file.previous_filename, 'previous filename')) : undefined; + const common = { path, changeType, additions, deletions, ...(oldPath === undefined ? {} : { oldPath }) }; + if (file.patch !== undefined && typeof file.patch !== 'string') fail('E_GITHUB_RESPONSE', 'GitHub patch must be text when present.', 'source'); + // Submodule fragments have no mode header; obtain the raw diff before treating them as text. + const ambiguousGitlink = typeof file.patch === 'string' && /^[+-]Subproject commit [0-9a-f]+(?:-dirty)?$/mu.test(file.patch); + if (typeof file.patch === 'string' && file.patch !== '' && !ambiguousGitlink) { + try { + const patch = parsePatch(file.patch); + if (patch.additions === additions && patch.deletions === deletions) return { ...common, kind: 'text', patch }; + } catch { /* Raw source counters still bound a text patch whose edit blocks are incomplete. */ } + } + if (ambiguousGitlink || additions + deletions === 0) return { ...common, kind: 'unknown', incompleteReason: 'MATERIAL_KIND_UNKNOWN' }; + return { ...common, kind: 'text', incompleteReason: file.patch === undefined ? 'PATCH_OMITTED' : 'PATCH_INCOMPLETE' }; +} diff --git a/src/diffdevil/browser/index.ts b/src/diffdevil/browser/index.ts new file mode 100644 index 00000000..a002f336 --- /dev/null +++ b/src/diffdevil/browser/index.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +/** Self-contained browser bundle; executable dependencies and validators ship locally. */ +export { analyzeBrowserInput, readComparison, comparisonKey, BROWSER_ADAPTER, MAX_ACQUISITION_BYTES } from './acquisition.js'; +export type { BrowserComparison, BrowserInput } from './acquisition.js'; +export { compileBrowserPolicy, readPolicyText, requiredTemplates } from './policy.js'; +export type { BrowserPolicy, PolicyLayers, PolicyMode } from './policy.js'; +export { humanReport, HUMAN_VIEW_VERSION } from './view.js'; +export type { HumanReportView, Rail, RailCell } from './view.js'; +export { appStanding } from './app.js'; +export type { AppReportIdentity, AppStanding } from './app.js'; +export { SEMANTICS } from '../model.js'; +export type { Report, NumericMeasurement, Result, Diagnostic } from '../model.js'; +export { stringify as stringifyPolicy } from 'yaml'; +export { measurementText, evidenceText } from './text.js'; diff --git a/src/diffdevil/browser/policy.ts b/src/diffdevil/browser/policy.ts new file mode 100644 index 00000000..db086815 --- /dev/null +++ b/src/diffdevil/browser/policy.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +import { capture, fail, unwrap } from '../errors.js'; +import { contentId, deepFreeze } from '../inert.js'; +import { compilePolicy, type CompiledPolicy } from '../policy/compile.js'; +import { normalizePolicy } from '../policy/normalize.js'; +import { readPolicyYaml } from '../policy/yaml.js'; +import { readPolicyDocument } from '../policy/validation.js'; +import type { PolicyDocument, PolicyOrigin } from '../policy/types.js'; +import type { Result } from '../model.js'; +export type PolicyMode = 'composed' | 'repository' | 'personal-only'; +export interface PolicyLayers { readonly mode: PolicyMode; readonly personal: string; readonly repository?: string; readonly override?: string; readonly templateFiles?: Readonly> } +export interface BrowserPolicy { readonly program: CompiledPolicy; readonly document: PolicyDocument; readonly origins: readonly PolicyOrigin[]; readonly mode: PolicyMode; readonly digest: string; readonly layers: readonly string[] } +export function readPolicyText(text: string, name = 'personal.yml'): Result { + return capture(() => readPolicyDocument(unwrap(readPolicyYaml(text, { name })).document)); +} +const parts = (path: string): string[] => path.slice(1).split('/').map(key => key.replaceAll('~1', '/').replaceAll('~0', '~')); +function lookup(value: unknown, path: string): unknown { return parts(path).reduce((parent, key) => parent && typeof parent === 'object' ? (parent as Record)[key] : undefined, value); } +function assign(root: Record, path: string, value: unknown): void { + const keys = parts(path); const leaf = keys.pop()!; let parent = root; + for (const key of keys) { if (!parent[key] || typeof parent[key] !== 'object') parent[key] = Object.create(null) as object; parent = parent[key] as Record; } + if (value === undefined) delete parent[leaf]; else parent[leaf] = value; +} +/** Compose normalized declarations; never let implicit upper presets reset personal policy. */ +export function compileBrowserPolicy(json: string): Result { + return capture(() => { + if (typeof json !== 'string' || new TextEncoder().encode(json).byteLength > 3 * 1024 * 1024) fail('E_BROWSER_POLICY_LIMIT', 'Policy input exceeds 3 MiB.', 'config'); + const input = JSON.parse(json) as PolicyLayers; + if (!input || !['composed', 'repository', 'personal-only'].includes(input.mode)) fail('E_CONFIG', 'Unknown browser policy mode.', 'config'); + const selected: { name: string; text: string }[] = []; + const repository = input.mode !== 'personal-only' && input.repository !== undefined; + if (input.mode !== 'repository' || !repository) selected.push({ name: 'personal', text: input.personal }); + if (repository) selected.push({ name: 'repository', text: input.repository! }); + if (input.mode !== 'repository' && input.override !== undefined) selected.push({ name: 'repository-personal-override', text: input.override }); + const document: Record = { version: 1, presets: [] }; const origins = new Map(); let program: CompiledPolicy | undefined; + for (const [index, layer] of selected.entries()) { + const authored = unwrap(readPolicyText(layer.text, layer.name)); const normalized = unwrap(normalizePolicy(authored)); + const lastOrigins = new Map(normalized.origins.map(origin => [origin.path, origin])); + if (authored.presets !== undefined) for (const [path, origin] of origins) if (origin.layer.includes(':preset:')) { assign(document, path, undefined); origins.delete(path); } + for (const [path, origin] of lastOrigins) { + const preset = origin.layer.startsWith('preset:'); + if (preset && index > 0 && authored.presets === undefined) continue; + const previous = origins.get(path)?.layer; assign(document, path, lookup(normalized.document, path)); + origins.set(path, { path, layer: preset ? `${layer.name}:${origin.layer}` : layer.name, ...(previous === undefined ? {} : { replaces: previous }) }); + } + program = unwrap(compilePolicy(document, { ...(input.templateFiles ? { templateFiles: input.templateFiles } : {}) })); + } + if (!program) program = unwrap(compilePolicy(document)); + const effectiveOrigins = [...origins.values()]; + return deepFreeze({ program, document: readPolicyDocument(document), origins: effectiveOrigins, mode: input.mode, + digest: contentId('browser-policy', [program.id, input.mode, effectiveOrigins]), layers: selected.map(layer => layer.name) }); + }); +} +/** Host-provided, trusted-base files only. This discovery function does no I/O. */ +export function requiredTemplates(json: string): Result { + return capture(() => { + const input = JSON.parse(json) as PolicyLayers; const result = new Set(); + const texts = input.mode === 'repository' && input.repository !== undefined ? [input.repository] : [input.personal, ...(input.mode === 'personal-only' ? [] : [input.repository]), ...(input.mode === 'repository' ? [] : [input.override])]; + for (const text of texts) if (text !== undefined) { + const document = unwrap(readPolicyText(text)); + for (const rule of Object.values(document.rules ?? {})) { const path = rule.effects?.comment?.templateFile; if (path) result.add(path); } + } + return [...result]; + }); +} diff --git a/src/diffdevil/browser/runtime/crypto.ts b/src/diffdevil/browser/runtime/crypto.ts new file mode 100644 index 00000000..27a52116 --- /dev/null +++ b/src/diffdevil/browser/runtime/crypto.ts @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +/** + * Browser stand-in for the one `createHash('sha256')` call the engine makes when it + * derives content identities. SubtleCrypto is asynchronous, and the engine's identity + * derivation is synchronous, so this is a small plain SHA-256 (FIPS 180-4) over UTF-8. + */ +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); +const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n)); + +function sha256(bytes: Uint8Array): string { + const length = bytes.length; + const bitLength = length * 8; + const padded = new Uint8Array(((length + 9 + 63) >> 6) << 6); + padded.set(bytes); + padded[length] = 0x80; + const view = new DataView(padded.buffer); + view.setUint32(padded.length - 4, bitLength >>> 0, false); + view.setUint32(padded.length - 8, Math.floor(bitLength / 0x100000000), false); + const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + const w = new Uint32Array(64); + for (let offset = 0; offset < padded.length; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false); + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15]!, 7) ^ rotr(w[i - 15]!, 18) ^ (w[i - 15]! >>> 3); + const s1 = rotr(w[i - 2]!, 17) ^ rotr(w[i - 2]!, 19) ^ (w[i - 2]! >>> 10); + w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0; + } + let a = h[0]!, b = h[1]!, c = h[2]!, d = h[3]!, e = h[4]!, f = h[5]!, g = h[6]!, hh = h[7]!; + for (let i = 0; i < 64; i++) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K[i]! + w[i]!) >>> 0; + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) >>> 0; + hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0; + } + h[0] = (h[0]! + a) >>> 0; h[1] = (h[1]! + b) >>> 0; h[2] = (h[2]! + c) >>> 0; h[3] = (h[3]! + d) >>> 0; + h[4] = (h[4]! + e) >>> 0; h[5] = (h[5]! + f) >>> 0; h[6] = (h[6]! + g) >>> 0; h[7] = (h[7]! + hh) >>> 0; + } + return Array.from(h, word => word.toString(16).padStart(8, '0')).join(''); +} + +export function createHash(algorithm: string) { + if (algorithm !== 'sha256') throw new Error(`Unsupported browser hash algorithm ${algorithm}.`); + const chunks: Uint8Array[] = []; + const hash = { + update(data: string | Uint8Array) { chunks.push(typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data)); return hash; }, + digest(encoding: string) { + if (encoding !== 'hex') throw new Error('The browser hash shim digests to hex only.'); + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + return sha256(bytes); + }, + }; + return hash; +} +export default { createHash }; diff --git a/src/diffdevil/browser/runtime/util.ts b/src/diffdevil/browser/runtime/util.ts new file mode 100644 index 00000000..43e345eb --- /dev/null +++ b/src/diffdevil/browser/runtime/util.ts @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MIT +/** Fresh JSON crosses the browser facade, never caller-owned proxies/getters. */ +export const types = { isProxy: (_value: unknown): boolean => false }; +export default { types }; diff --git a/src/diffdevil/browser/text.ts b/src/diffdevil/browser/text.ts new file mode 100644 index 00000000..398a7789 --- /dev/null +++ b/src/diffdevil/browser/text.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +import type { NumericMeasurement } from '../model.js'; +/** Formatting only; hosts do not recalculate evidence or classify bands. */ +export function measurementText(value: NumericMeasurement): string { + const n = (value: number): string => new Intl.NumberFormat('en').format(value); + switch (value.status) { case 'exact': return n(value.value); case 'bounded': return `${n(value.lower)}–${n(value.upper)}`; case 'unknown': return value.lower === undefined ? '?' : `≥${n(value.lower)}`; case 'unmeasurable': return '∅'; } +} +export function evidenceText(status: NumericMeasurement['status']): string { return `${{ exact: '=', bounded: '≈', unknown: '?', unmeasurable: '∅' }[status]} ${status}`; } diff --git a/src/diffdevil/browser/view.ts b/src/diffdevil/browser/view.ts new file mode 100644 index 00000000..d4c81c57 --- /dev/null +++ b/src/diffdevil/browser/view.ts @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +import { capture, unwrap } from '../errors.js'; +import { contentId, deepFreeze } from '../inert.js'; +import { aggregateFiles, readReport, summarizeMeasurements } from '../report.js'; +import { evaluatePolicy } from '../policy/evaluate.js'; +import type { BandLabels } from '../policy/types.js'; +import type { Report, FileRecord, NumericMeasurement, Result, Diagnostic } from '../model.js'; +import type { BrowserPolicy } from './policy.js'; +export const HUMAN_VIEW_VERSION = 'diffdevil.human-view/1'; +export interface RailCell { readonly id: string; readonly name: string; readonly lower?: number; readonly upper?: number; readonly color?: string; readonly label?: string; readonly selected: boolean } +export interface Rail { readonly id: string; readonly expression: string; readonly cells: readonly RailCell[]; readonly selected?: string; readonly label?: string; readonly group?: string; readonly members?: readonly string[] } +export interface HumanReportView { + readonly kind: typeof HUMAN_VIEW_VERSION; readonly report: Report; readonly focus?: FileRecord; + readonly changed: NumericMeasurement; readonly added: NumericMeasurement; readonly deleted: NumericMeasurement; readonly modified: NumericMeasurement; + readonly raw: Report['totals']['raw']; readonly evidence: Report['measurement']; readonly rails: readonly Rail[]; + readonly policy?: { readonly digest: string; readonly mode: string; readonly origins: BrowserPolicy['origins']; readonly layers: readonly string[] }; + readonly errors: readonly Diagnostic[]; +} +/** A file projection preserves its evidence, then evaluates the same policy on its own facts. */ +function fileReport(report: Report, file: FileRecord): Report { + const fileSet = { complete: true, total: { status: 'exact' as const, value: 1 } }; const totals = aggregateFiles([file], fileSet); + const body = { kind: report.kind, schemaVersion: report.schemaVersion, semantics: report.semantics, source: report.source, files: [file], fileSet, totals, + measurement: summarizeMeasurements([...Object.values(totals.raw), ...Object.values(totals.lines), ...Object.values(totals.files)], file.included ? file.measurement.reasons : []) }; + return unwrap(readReport({ ...body, reportId: contentId('report', body) })); +} +function rails(report: Report, policy: BrowserPolicy): Rail[] { + return Object.entries(policy.document.bands ?? {}).map(([id, definition]) => { + const decision = report.bands?.[id]; const selected = decision?.status === 'resolved' ? decision.id : undefined; + const rule = Object.values(policy.document.rules ?? {}).find(rule => 'band' in rule && rule.band === id && rule.effects?.labels && 'byBand' in rule.effects.labels); + const mapping = rule?.effects?.labels as BandLabels | undefined; let lower = definition.minimum; + const cells: RailCell[] = definition.ranges.map(range => { + const label = mapping?.byBand[range.id]; const color = label ? policy.document.labelDefinitions?.[label]?.color : undefined; + const result = { id: range.id, name: label ?? range.id, selected: selected === range.id, + ...(lower === undefined ? {} : { lower }), ...(range.lt === undefined ? {} : { upper: range.lt }), + ...(label === undefined ? {} : { label }), ...(color === undefined ? {} : { color }) }; + lower = range.lt; return result; + }); + const label = selected === undefined ? undefined : mapping?.byBand[selected]; + return { id, expression: definition.value, cells, ...(selected === undefined ? {} : { selected }), ...(label === undefined ? {} : { label }), + ...(mapping ? { group: mapping.group, members: policy.document.labelGroups?.[mapping.group] ?? [] } : {}) }; + }); +} +/** Shared, typed human projection. DOM and CLI hosts never recompute Changed. */ +export function humanReport(input: Report, policy?: BrowserPolicy, path?: string, errors: readonly Diagnostic[] = []): Result { + return capture(() => { + const parent = unwrap(readReport(input)); const file = path === undefined ? undefined : parent.files.find(item => item.path === path); + if (path !== undefined && !file) throw new Error('The requested file is absent from this comparison.'); + let report = file ? fileReport(parent, file) : parent; + if (policy) report = unwrap(evaluatePolicy(policy.program, report)).report; + const focus = file ? report.files[0] : undefined; const lines = focus?.lines ?? report.totals.lines; + return deepFreeze({ kind: HUMAN_VIEW_VERSION, report, ...(focus ? { focus } : {}), changed: lines.changed, added: lines.added, deleted: lines.deleted, modified: lines.modified, + raw: focus?.raw ?? report.totals.raw, evidence: focus?.measurement ?? report.measurement, rails: policy && focus?.included !== false ? rails(report, policy) : [], + ...(policy ? { policy: { digest: policy.digest, mode: policy.mode, origins: policy.origins, layers: policy.layers } } : {}), errors }); + }); +} diff --git a/src/diffdevil/tests/browser.test.mjs b/src/diffdevil/tests/browser.test.mjs new file mode 100644 index 00000000..bce0c075 --- /dev/null +++ b/src/diffdevil/tests/browser.test.mjs @@ -0,0 +1,98 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { analyzeBrowserInput, compileBrowserPolicy, humanReport, comparisonKey, appStanding, measurementText, requiredTemplates } from '../../../dist/lib/browser/index.js'; +import { createHash as portableHash } from '../../../dist/lib/browser/runtime/crypto.js'; +const identity = { host: 'github.com', repository: 'test/project', pullRequest: 7, base: 'a'.repeat(40), head: 'b'.repeat(40), changedFiles: 1 }; +const patch = 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1,2 +1,3 @@\n-old\n+new\n+extra\n same\n'; +const unwrap = result => { assert.equal(result.ok, true, JSON.stringify(result.diagnostics)); return result.value; }; +const report = (text = patch, comparison = identity, complete = true) => unwrap(analyzeBrowserInput(JSON.stringify({ comparison, format: 'diff', text, complete }))); +const policy = input => unwrap(compileBrowserPolicy(JSON.stringify({ mode: 'composed', personal: 'version: 1\n', ...input }))); +const view = (input = report(), config = policy(), path) => unwrap(humanReport(input, config, path)); +test('browser facts use the existing replacement-lines engine', () => { + const result = view(); assert.equal(result.changed.value, 2); assert.equal(result.added.value, 1); assert.equal(result.modified.value, 1); assert.equal(result.deleted.value, 0); assert.equal(result.raw.churn.value, 3); + assert.equal(result.rails[0].selected, 'xs'); assert.equal(result.rails[0].label, 'size/XS'); +}); +test('file-count evidence is required before an aggregate becomes exact', () => { + const { changedFiles, ...unknown } = identity; const result = report(patch, unknown); + assert.equal(result.fileSet.complete, false); assert.equal(result.totals.lines.changed.status, 'unknown'); assert.equal(result.totals.lines.changed.lower, 2); +}); +test('cutting cleanly at a file boundary preserves missing-file uncertainty', () => { + const result = report(patch, { ...identity, changedFiles: 2 }); assert.equal(result.fileSet.complete, false); assert.equal(result.totals.lines.changed.status, 'unknown'); +}); +test('mismatching independent raw counters do not bless partial hunks', () => { + const result = report(patch, { ...identity, additions: 200, deletions: 1 }); assert.notEqual(result.files[0].lines.changed.status, 'exact'); assert.equal(result.files[0].measurement.reasons[0].code, 'BROWSER_COUNTER_MISMATCH'); +}); +test('independently confirmed empty comparison is exactly zero', () => { + const result = report('', { ...identity, changedFiles: 0 }); assert.deepEqual(result.totals.lines.changed, { status: 'exact', value: 0 }); +}); +test('truncation never manufactures exact zero', () => { + const result = report(patch.replace('+extra\n same\n', ''), identity); assert.equal(result.totals.lines.changed.status, 'unknown'); assert.equal(result.fileSet.complete, false); +}); +test('a known transport cut downgrades the final observed file', () => { assert.notEqual(report(patch, identity, false).files[0].lines.changed.status, 'exact'); }); +test('invalid repository, SHA, duplicate file, and HTML source fail closed', () => { + assert.throws(() => comparisonKey({ ...identity, repository: '../x' })); assert.throws(() => comparisonKey({ ...identity, head: 'main' })); + for (const text of [patch + patch, 'sign in']) assert.equal(analyzeBrowserInput(JSON.stringify({ comparison: identity, format: 'diff', complete: true, text })).ok, false); +}); +test('provider omitted patches use shared bounds rather than guessed modified counts', () => { + const result = unwrap(analyzeBrowserInput(JSON.stringify({ comparison: identity, complete: true, format: 'github-files', files: [{ filename: 'a.ts', status: 'modified', additions: 7, deletions: 5, changes: 12 }] }))); + assert.equal(result.files[0].lines.changed.status, 'bounded'); assert.equal(result.files[0].lines.changed.lower, 7); assert.equal(result.files[0].lines.changed.upper, 12); +}); +test('binary material stays unmeasurable and selects no band', () => { + const result = view(report('diff --git a/a.png b/a.png\nindex 1111111..2222222 100644\nBinary files a/a.png and b/a.png differ\n')); + assert.equal(result.changed.status, 'unmeasurable'); assert.equal(measurementText(result.changed), '∅'); assert.equal(result.rails[0].selected, undefined); +}); +test('file projection classifies its own facts, not the aggregate tier', () => { + const input = report(patch + patch.replaceAll('src/a.ts', 'src/b.ts'), { ...identity, changedFiles: 2 }); + const config = policy({ personal: 'version: 1\nsize:\n thresholds: {xs: 2, s: 4, m: 10, l: 20}\n' }); + assert.equal(view(input, config).rails[0].selected, 'm'); assert.equal(view(input, config, 'src/a.ts').rails[0].selected, 's'); +}); +test('excluded file keeps real measurements but no classification', () => { + const result = view(report(), policy({ personal: 'version: 1\ndefaults:\n paths:\n exclude: ["src/**"]\n' }), 'src/a.ts'); + assert.equal(result.focus.included, false); assert.equal(result.changed.value, 2); assert.deepEqual(result.rails, []); +}); +test('custom band count is not fixed to five cells', () => { + const config = policy({ personal: 'version: 1\npresets: []\nmetrics:\n review: {measure: lines.changed}\nbands:\n effort:\n value: metrics.review\n ranges: [{id: tiny, lt: 2}, {id: other, otherwise: true}]\n' }); + assert.equal(view(report(), config).rails[0].cells.length, 2); assert.equal(view(report(), config).rails[0].selected, 'other'); +}); +test('higher convenience metric replaces lower explicit declaration without dialect conflict', () => { + const config = policy({ personal: 'version: 1\nmetrics:\n review: {measure: lines.changed}\n', repository: 'version: 1\nsize:\n metric: raw.churn\n' }); + assert.equal(view(report(), config).report.metrics.review.value, 3); assert.equal(config.origins.find(item => item.path === '/metrics/review').layer, 'repository'); +}); +test('implicit repository preset does not reset personal thresholds', () => { + const config = policy({ personal: 'version: 1\nsize:\n thresholds: {xs: 2, s: 4, m: 10, l: 20}\n', repository: 'version: 1\nqueries:\n review: {expression: "totals.lines.changed"}\n' }); + assert.equal(view(report(), config).rails[0].selected, 's'); +}); +test('explicit no-preset removes inherited preset declarations', () => { + assert.deepEqual(view(report(), policy({ repository: 'version: 1\npresets: []\n' })).rails, []); +}); +test('an override cannot conceal malformed lower repository policy', () => { + const result = compileBrowserPolicy(JSON.stringify({ mode: 'composed', personal: 'version: 1\n', repository: 'version: 1\nmetrics:\n review: {measure: wrong}\n', override: 'version: 1\nmetrics:\n review: {measure: lines.changed}\n' })); assert.equal(result.ok, false); +}); +test('personal-only deliberately bypasses invalid repository policy and permits a local override', () => { + const result = policy({ mode: 'personal-only', repository: 'broken: [', override: 'version: 1\nsize:\n metric: raw.churn\n' }); assert.equal(view(report(), result).report.metrics.review.value, 3); +}); +test('repository mode ignores personal and per-repository overrides when repository policy exists', () => { + const result = policy({ mode: 'repository', personal: 'broken: [', repository: 'version: 1\n', override: 'broken: [' }); assert.deepEqual(result.layers, ['repository']); +}); +test('named declarations replace rather than merging measure with formula', () => { + const result = policy({ personal: 'version: 1\nmetrics:\n review: {formula: "totals.raw.churn"}\n', repository: 'version: 1\nmetrics:\n review: {measure: lines.changed}\n' }); assert.deepEqual({ ...result.document.metrics.review }, { measure: 'lines.changed' }); +}); +test('invalid policy leaves an inspectable factual report without rails', () => { + const result = unwrap(humanReport(report(), undefined, undefined, [{ code: 'E_CONFIG', severity: 'error', phase: 'config', message: 'Invalid policy' }])); assert.equal(result.changed.value, 2); assert.deepEqual(result.rails, []); assert.equal(result.errors.length, 1); +}); +test('trusted templates are explicit inputs and discovered without network calls', () => { + const input = { mode: 'personal-only', personal: 'version: 1\nrules:\n note:\n when: "true"\n effects:\n comment: {mode: upsert, templateFile: .github/note.md}\n' }; + assert.deepEqual(unwrap(requiredTemplates(JSON.stringify(input))), ['.github/note.md']); assert.equal(compileBrowserPolicy(JSON.stringify(input)).ok, false); + assert.equal(compileBrowserPolicy(JSON.stringify({ ...input, templateFiles: { '.github/note.md': 'Changed: {{ totals.lines.changed }}' } })).ok, true); +}); +test('portable SHA-256 identities agree with Node across block boundaries and Unicode', () => { + for (const text of ['', 'abc', '🐺 tree house', 'a'.repeat(55), 'a'.repeat(56), 'a'.repeat(64), 'z'.repeat(10000)]) assert.equal(portableHash('sha256').update(text).digest('hex'), createHash('sha256').update(text).digest('hex')); + assert.equal(portableHash('sha256').update('ab').update('c').digest('hex'), createHash('sha256').update('abc').digest('hex')); assert.throws(() => portableHash('md5')); +}); +test('App matching requires exact report, policy and version identities', () => { + const local = { kind: 'diffdevil.browser-report', version: 1, comparison: identity, reportId: 'r', policyDigest: 'p', engine: '1', schema: '1', measurement: '1', presenter: '1' }; + assert.equal(appStanding(local), 'local'); assert.equal(appStanding(local, local), 'matching'); + assert.equal(appStanding(local, { ...local, policyDigest: 'other' }), 'policy-mismatch'); assert.equal(appStanding(local, { ...local, comparison: { ...identity, head: 'c'.repeat(40) } }), 'stale'); + for (const key of ['engine', 'schema', 'measurement', 'presenter', 'reportId']) assert.equal(appStanding(local, { ...local, [key]: 'other' }), 'incompatible'); +}); diff --git a/tools/browser-extension-build.mjs b/tools/browser-extension-build.mjs new file mode 100644 index 00000000..89074fa1 --- /dev/null +++ b/tools/browser-extension-build.mjs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +import { build } from 'esbuild'; +import { Resvg } from '@resvg/resvg-js'; +import { readFile, writeFile, mkdir, rm, copyFile, readdir } from 'node:fs/promises'; +import { resolve, join } from 'node:path'; +import { createHash } from 'node:crypto'; +const root = resolve('apps/browser-extension'); +const out = resolve('artifacts/browser-extension/unpacked'); +const fontFree = process.argv.includes('--without-fonts'); +const packageJson = JSON.parse(await readFile('package.json', 'utf8')); +await rm(out, { recursive: true, force: true }); await mkdir(join(out, 'assets'), { recursive: true }); +const common = { bundle: true, platform: 'browser', target: 'chrome120', legalComments: 'eof', metafile: true, define: { __ENGINE_VERSION__: JSON.stringify(packageJson.version) }, + alias: { '@wolfsblvt/diffdevil/browser/text': resolve('dist/lib/browser/text.js'), '@wolfsblvt/diffdevil/browser': resolve('dist/browser/index.js') } }; +const modules = await build({ ...common, entryPoints: { background: join(root, 'src/background/worker.ts'), options: join(root, 'src/options/main.ts') }, outdir: out, format: 'esm', splitting: true, chunkNames: 'chunks/[name]-[hash]' }); +const content = await build({ ...common, entryPoints: [join(root, 'src/content/main.ts')], outfile: join(out, 'content.js'), format: 'iife' }); +await build({ ...common, entryPoints: [join(root, 'src/options/theme.ts')], outfile: join(out, 'theme.js'), format: 'iife' }); +await build({ entryPoints: [join(root, 'src/options/options.css')], outfile: join(out, 'options.css'), bundle: true, target: 'chrome120', loader: { '.svg': 'file' }, assetNames: 'assets/[name]-[hash]' }); +await copyFile(join(root, 'src/content/content.css'), join(out, 'content.css')); +await copyFile(join(root, 'options.html'), join(out, 'options.html')); +const manifest = JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8')); manifest.version = packageJson.version; +await writeFile(join(out, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); +const identity = resolve('design/assets/identity'); const identities = []; +const acceptedBrand = await readFile(join(root, 'assets/diffdevil-brand.svg')); +const acceptedBrandHash = createHash('sha256').update(acceptedBrand).digest('hex'); +if (acceptedBrandHash !== 'd33cf3b1a7a8ec52706da3311ac82e0500ec6c2eee8e35baeaaa2d5ef2b9b34e') throw new Error('The vendored diffdevil/brand glyph disagrees with its accepted canonical source.'); +await writeFile(join(out, 'assets/diffdevil-brand.svg'), acceptedBrand); +identities.push({ path: 'Wolfsblvt/wolfsblvt-icons@8596b6bc2ba7c4b950963b7ec26cee1a26974439:src/icons/products/diffdevil/brand.svg', sha256: acceptedBrandHash, license: 'MIT' }); +for (const file of ['diffdevil-symbol-micro-dark.svg', 'diffdevil-symbol-micro-light.svg', 'diffdevil-wordmark-dark.svg', 'diffdevil-wordmark-light.svg']) { + const bytes = await readFile(join(identity, file)); await copyFile(join(identity, file), join(out, 'assets', file)); identities.push({ path: `design/assets/identity/${file}`, sha256: createHash('sha256').update(bytes).digest('hex') }); +} +for (const size of [16, 32, 48, 128]) { + const source = (await readFile(join(identity, `diffdevil-symbol-${size <= 32 ? 'micro' : 'master'}-dark.svg`), 'utf8')).replace(/^<\?xml[^>]*>/u, ''); + const pad = size === 128 ? 16 : 0; const extent = size - pad * 2; + // Only a consumer frame and scaling. Accepted symbol path geometry is untouched. + const nested = source.replace(/]*)>/u, (_match, attributes) => ``); + const frame = `${nested}`; + await writeFile(join(out, 'assets', `icon-${size}.png`), new Resvg(frame).render().asPng()); +} +let fonts = ''; +if (!fontFree) for (const [family, cssName, weights] of [['ibm-plex-sans', 'IBM Plex Sans', [400, 600]], ['ibm-plex-mono', 'IBM Plex Mono', [400]]]) { + for (const weight of weights) { const file = `${family}-latin-${weight}-normal.woff2`; await copyFile(`node_modules/@fontsource/${family}/files/${file}`, join(out, 'assets', file)); fonts += `@font-face{font-family:"${cssName}";font-style:normal;font-weight:${weight};font-display:swap;src:url("assets/${file}") format("woff2")}\n`; } +} +await writeFile(join(out, 'options.css'), fonts + await readFile(join(out, 'options.css'), 'utf8')); +const escape = value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); +function prose(title, text) { + const paragraphs = text.trim().split(/\n\s*\n/u).map(block => { const header = /^(#{1,3}) (.+)$/u.exec(block.trim()); return header ? `${escape(header[2])}` : `

${escape(block.replaceAll('\n', ' '))}

`; }).join('\n'); + return `${escape(title)} · diffdevil
← Settings${paragraphs}
`; +} +for (const [file, page, title] of [['PRIVACY.md', 'privacy.html', 'Privacy'], ['LICENSES.md', 'licenses.html', 'Licences']]) await writeFile(join(out, page), prose(title, await readFile(join(root, file), 'utf8'))); +await mkdir(join(out, 'licenses'), { recursive: true }); +for (const [name, path] of Object.entries({ 'AGPL-3.0-only.txt': 'LICENSES/AGPL-3.0-only.txt', 'MIT.txt': 'LICENSES/MIT.txt', 'chevrotain.txt': 'node_modules/chevrotain/LICENSE.txt', 'yaml.txt': 'node_modules/yaml/LICENSE', 'ajv.txt': 'node_modules/ajv/LICENSE', 'fast-deep-equal.txt': 'node_modules/fast-deep-equal/LICENSE', 'fast-uri.txt': 'node_modules/fast-uri/LICENSE', 'json-schema-traverse.txt': 'node_modules/json-schema-traverse/LICENSE', ...(!fontFree ? { 'IBM-Plex-Sans-OFL.txt': 'node_modules/@fontsource/ibm-plex-sans/LICENSE', 'IBM-Plex-Mono-OFL.txt': 'node_modules/@fontsource/ibm-plex-mono/LICENSE' } : {}) })) await copyFile(path, join(out, 'licenses', name)); +const metadata = { modules: modules.metafile, content: content.metafile }; +const external = Object.values(metadata).flatMap(meta => Object.values(meta.outputs)).flatMap(output => output.imports).filter(item => item.external); +if (external.length) throw new Error(`Extension contains external executable imports: ${external.map(item => item.path).join(', ')}`); +const inventory = []; +async function walk(directory, relative = '') { for (const file of await readdir(directory, { withFileTypes: true })) { const path = join(directory, file.name); const name = relative + file.name; if (file.isDirectory()) await walk(path, `${name}/`); else { const bytes = await readFile(path); inventory.push({ path: name, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }); } } } +await walk(out); +const receipt = { version: packageJson.version, fontFilesIncluded: !fontFree, glyph: 'accepted-diffdevil-brand-centre-seam', identities, files: inventory.sort((a, b) => a.path.localeCompare(b.path)), bytes: inventory.reduce((sum, file) => sum + file.bytes, 0) }; +await writeFile('artifacts/browser-extension/build-receipt.json', `${JSON.stringify(receipt, null, 2)}\n`); +await writeFile('artifacts/browser-extension/bundle-metafile.json', `${JSON.stringify(metadata, null, 2)}\n`); +console.log(`Built ${inventory.length} files (${(receipt.bytes / 1024 / 1024).toFixed(2)} MiB). Fonts ${fontFree ? 'omitted; system fallbacks' : 'from locked dependencies'}. No publication performed.`); diff --git a/tools/browser-extension-store.mjs b/tools/browser-extension-store.mjs new file mode 100644 index 00000000..173cbc59 --- /dev/null +++ b/tools/browser-extension-store.mjs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +import { Resvg } from '@resvg/resvg-js'; +import { readFile, writeFile, mkdir, copyFile } from 'node:fs/promises'; +import { resolve, join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { deflateSync } from 'node:zlib'; +const out = resolve('artifacts/browser-extension/store'); await mkdir(out, { recursive: true }); +const qa = resolve('artifacts/browser-extension/qa'); +const receipt = JSON.parse(await readFile(join(qa, 'receipt.json'), 'utf8')); +if (receipt.failed || receipt.passed < 40) throw new Error('Generate and pass the production DOM QA before preparing Store screenshots. Run npm run extension:qa.'); +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +const entries = []; +function pngInfo(bytes) { if (bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') throw new Error('Expected a PNG image.'); return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20), depth: bytes[24], colorType: bytes[25] }; } +const screenshots = [ + ['01-changed-on-github.png', 'github-dark.png', 'Aggregate and lazy per-file Changed, raw churn, dynamic policy bands and local provenance.'], + ['02-inspect-the-report.png', 'report-light.png', 'The persistent anchored report keeps source identity, evidence, decomposition and policy details inspectable.'], + ['03-your-icon-preference.png', 'settings-icon-dark.png', 'Literal stable-setting anchors and the monochrome, full-colour or no-icon choices.'], + ['04-your-policy-bands.png', 'settings-bands-light.png', 'Editable personal thresholds, colour metadata and optional existing-label mappings in the authored light theme.'], + ['05-honest-uncertainty.png', 'evidence-unknown.png', 'Incomplete evidence remains visibly unknown. No unsupported exact total or selected band is manufactured.'], +]; +for (const [name, source, caption] of screenshots) { + const bytes = await readFile(join(qa, source)); const info = pngInfo(bytes); + if (info.width !== 1280 || info.height !== 800 || info.depth !== 8 || info.colorType !== 2) throw new Error(`${source} must be a 1280×800, 24-bit RGB screenshot.`); + await copyFile(join(qa, source), join(out, name)); entries.push({ name, ...info, sha256: sha256(bytes), source: `../qa/${source}`, caption, standing: 'Actual production UI in an explicitly authored fixture; not a live or installed-extension capture.' }); +} +const wordmark = await readFile('design/assets/identity/diffdevil-wordmark-dark.svg'); +const mark = `data:image/svg+xml;base64,${wordmark.toString('base64')}`; +const screenshot = `data:image/png;base64,${(await readFile(join(qa, 'github-dark.png'))).toString('base64')}`; +const header = (width, height) => ``; +const small = header(440, 280) + ` +Count replacements once. + +Changed178 ++60 added −18 deleted ~100 modified +size/M +GitHub pull requests. Your local policy. +Source candidate · No account or source upload`; +const marquee = header(1400, 560) + ` +Changed, not just churn. +Replacement-aware measurements. +Your policy. On the pull request. + +Changed178 ++60 added −18 deleted ~100 modified +Raw +160 −118 · local analysis + + +No account. No telemetry. No source upload. +Source candidate · Authored GitHub fixture · Not a live capture`; +// Encode opaque promotional rasters as PNG24 (RGB), not an RGBA image with a +// nominally opaque alpha channel. No extra image processor or font payload ships. +function crc32(buffer) { let crc = 0xffffffff; for (const byte of buffer) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = crc >>> 1 ^ (crc & 1 ? 0xedb88320 : 0); } return (crc ^ 0xffffffff) >>> 0; } +function chunk(type, content) { const name = Buffer.from(type); const length = Buffer.alloc(4); length.writeUInt32BE(content.length); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([name, content]))); return Buffer.concat([length, name, content, crc]); } +function rgbPng(rendered) { + const { width, height } = rendered; const rgba = rendered.pixels; const rows = Buffer.alloc((width * 3 + 1) * height); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { const source = (y * width + x) * 4; const target = y * (width * 3 + 1) + 1 + x * 3; if (rgba[source + 3] !== 255) throw new Error('Promotional art must be opaque.'); rows[target] = rgba[source]; rows[target + 1] = rgba[source + 1]; rows[target + 2] = rgba[source + 2]; } + const header = Buffer.alloc(13); header.writeUInt32BE(width); header.writeUInt32BE(height, 4); header[8] = 8; header[9] = 2; + return Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', header), chunk('IDAT', deflateSync(rows)), chunk('IEND', Buffer.alloc(0))]); +} +for (const [name, svg] of [['small-promo-440x280', small], ['marquee-1400x560', marquee]]) { + const png = rgbPng(new Resvg(svg, { font: { loadSystemFonts: true, defaultFontFamily: 'DejaVu Sans' } }).render()); + await writeFile(join(out, `${name}.svg`), svg); await writeFile(join(out, `${name}.png`), png); + entries.push({ name: `${name}.png`, ...pngInfo(png), sha256: sha256(png), editableSource: `${name}.svg`, standing: 'Authored promotional composition using accepted identity bytes and exact synthetic fixture measurements. Source-candidate disclosure is retained.' }); +} +const icon = await readFile('artifacts/browser-extension/unpacked/assets/icon-128.png'); +if (pngInfo(icon).width !== 128 || pngInfo(icon).height !== 128) throw new Error('The Store icon must be 128×128.'); +await writeFile(join(out, 'store-icon-128.png'), icon); entries.push({ name: 'store-icon-128.png', ...pngInfo(icon), sha256: sha256(icon), standing: 'Accepted master symbol on a 96×96 consumer field with 16px transparent outer padding; no new product glyph geometry.' }); +await writeFile(join(out, 'asset-manifest.json'), JSON.stringify({ kind: 'diffdevil.store-assets/1', published: false, reservedIdentity: true, fontsEmbedded: false, imageRequirements: 'https://developer.chrome.com/docs/webstore/images', checkedOn: '2026-09-19', entries }, null, 2) + '\n'); +console.log(`Prepared ${entries.length} Store images and editable promotional SVG sources. No listing, deployment or publication performed.`); diff --git a/tools/browser-library-build.mjs b/tools/browser-library-build.mjs new file mode 100644 index 00000000..84f7b4bf --- /dev/null +++ b/tools/browser-library-build.mjs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +import { build } from 'esbuild'; +import { mkdir, writeFile, copyFile, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; +/** Ship a self-contained portable entry, not an undocumented Node alias recipe. */ +export async function buildBrowser() { + await rm('dist/browser', { recursive: true, force: true }); await mkdir('dist/browser', { recursive: true }); + const result = await build({ entryPoints: ['dist/lib/browser/index.js'], outfile: 'dist/browser/index.js', bundle: true, platform: 'browser', format: 'esm', target: 'es2022', legalComments: 'eof', metafile: true, + alias: { 'node:crypto': resolve('dist/lib/browser/runtime/crypto.js'), 'node:util': resolve('dist/lib/browser/runtime/util.js') } }); + const external = Object.values(result.metafile.outputs).flatMap(output => output.imports).filter(item => item.external); + if (external.length) throw new Error(`Portable browser bundle contains unresolved imports: ${external.map(item => item.path).join(', ')}`); + await writeFile('dist/browser/metafile.json', `${JSON.stringify(result.metafile, null, 2)}\n`); + await copyFile('LICENSES/MIT.txt', 'dist/browser/LICENSE.txt'); +} diff --git a/tools/build.mjs b/tools/build.mjs index 2e5fb49e..b548c425 100644 --- a/tools/build.mjs +++ b/tools/build.mjs @@ -1,3 +1,4 @@ +import { buildBrowser } from './browser-library-build.mjs'; import { buildSchemas } from './schema-build.mjs'; import { existsSync, mkdirSync, rmSync, chmodSync } from 'node:fs'; import { resolve } from 'node:path'; @@ -17,6 +18,7 @@ const result = spawnSync(process.execPath, args, { stdio: 'inherit', windowsHide if (result.error) { console.error(result.error.message); process.exit(2); } if (result.status === 0) { buildSchemas(); + await buildBrowser(); if (existsSync('dist/lib/cli/main.js')) chmodSync('dist/lib/cli/main.js', 0o755); } process.exit(result.status ?? 2); From d709a4155c7c8a2b21363d5074ea1032e8d1424f Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Tue, 22 Sep 2026 00:53:29 +0200 Subject: [PATCH 2/2] docs: currentize extension landing Add the browser-extension application to the maintained project map, record its current source and qualification boundary, and remove stale replay-era claims about the already-landed presenter. Co-authored-via: Katja | Codex desktop | GPT-5.6 Sol | xhigh Co-authored-by: Katja --- apps/browser-extension/ARCHITECTURE.md | 2 +- apps/browser-extension/QA.md | 2 +- apps/browser-extension/QUALIFICATION.md | 2 +- docs/DIRECTION.md | 8 ++++++++ docs/PROJECT-MAP.md | 10 +++++++--- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/browser-extension/ARCHITECTURE.md b/apps/browser-extension/ARCHITECTURE.md index aceebe6e..0f4461b6 100644 --- a/apps/browser-extension/ARCHITECTURE.md +++ b/apps/browser-extension/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## Meaning -The browser is a new host for the existing measurement and policy system. DOM markup is placement and acquisition evidence, not a second analysis engine. This contribution is independent of the unmerged terminal-presenter candidate and does not modify the website or its public marketing surface. +The browser is a new host for the existing measurement and policy system. DOM markup is placement and acquisition evidence, not a second analysis engine. The extension uses the presenter and application foundations already on its current `main` base without modifying the website or its public marketing surface. ## Boundaries and ownership diff --git a/apps/browser-extension/QA.md b/apps/browser-extension/QA.md index ed99346f..c520e44d 100644 --- a/apps/browser-extension/QA.md +++ b/apps/browser-extension/QA.md @@ -35,7 +35,7 @@ The installed runner is fully authored. On an unrestricted browser it verifies a ## This source session -The source was reconstructed and verified against main commit `86dcf193a3186467d5b5f24a4246767adaf0a069`, tree `0658aa5f8b0e97f1774d05abb2bfc4b9f22bf733`. The local recovery commit is synthetic; it is not that upstream commit's ancestry. The extension does not depend on unmerged presenter PR #25 or claim that earlier bounded PR #26 contains this full application. +The source was reconstructed and verified against main commit `86dcf193a3186467d5b5f24a4246767adaf0a069`, tree `0658aa5f8b0e97f1774d05abb2bfc4b9f22bf733`, then replayed onto current `main` after presenter PR #25 landed. The final extension does not duplicate or modify that presenter implementation, and earlier bounded PR #26 does not contain this full application. The supplied receipts describe Linux Node 22.16.0, TypeScript 5.8.3 and managed Chromium 143. Core and application checks passed, as did rendered DOM and acquisition fixtures. The exact final counts, versions, hashes and durations belong to the generated receipts and room return, not a permanently frozen number in this manual. diff --git a/apps/browser-extension/QUALIFICATION.md b/apps/browser-extension/QUALIFICATION.md index 75c0820a..d275186d 100644 --- a/apps/browser-extension/QUALIFICATION.md +++ b/apps/browser-extension/QUALIFICATION.md @@ -4,7 +4,7 @@ This is a full authored local extension source candidate, not a published extension or an accepted live-GitHub integration. Its source, reproducible build, configuration application, tests, Store assets and evidence are carried together. Provider publication and final acceptance remain separate. -The initial verified source is `Wolfsblvt/diffdevil@86dcf193a3186467d5b5f24a4246767adaf0a069`, tree `0658aa5f8b0e97f1774d05abb2bfc4b9f22bf733`. This contribution does not depend on unmerged presenter PR #25. The earlier draft PR #26 contains a different bounded browser-core attempt; it is not evidence that this full application has been transferred or reviewed. +The initial verified source is `Wolfsblvt/diffdevil@86dcf193a3186467d5b5f24a4246767adaf0a069`, tree `0658aa5f8b0e97f1774d05abb2bfc4b9f22bf733`. The final source was replayed onto current `main` after presenter PR #25 landed and preserves that accepted base without duplicating its implementation. The earlier draft PR #26 contains a different bounded browser-core attempt; it is not evidence for this full application. ## Executed evidence diff --git a/docs/DIRECTION.md b/docs/DIRECTION.md index d09c0ca0..d31312ad 100644 --- a/docs/DIRECTION.md +++ b/docs/DIRECTION.md @@ -44,6 +44,14 @@ its primary action, and keeps the open package visibly first-class. The playgrou the trial surface, not a fourth route. The public category descriptor is *Composable diff analysis and automation for GitHub and the CLI.* +Current source now contains the complete local-first Chrome extension under +`apps/browser-extension/` and the reusable browser entry under +`src/diffdevil/browser/`. Its exact-head browser and repository verification is green, +including an installed Manifest V3 worker and native Chrome storage. Live authenticated +GitHub journeys, Windows interactive accessibility, native writable-label readback, +managed-App report delivery, Store submission and publication remain separate release +acceptance and provider effects. + Current source contains the first read-only measurement playground under `apps/playground/`, backed by the shared engine and versioned response contract. That exact application is live at diff --git a/docs/PROJECT-MAP.md b/docs/PROJECT-MAP.md index d89f4f02..13cab4c7 100644 --- a/docs/PROJECT-MAP.md +++ b/docs/PROJECT-MAP.md @@ -13,6 +13,7 @@ and disposable outputs without introducing independently versioned packages. | `src/diffdevil/` | Reusable MIT product core: shared engine, CLI, provider/Action hosts, core tests, contracts and presets. | | `apps/playground/` | AGPL application adapter: shared local/Worker public-PR route, browser assets, versioned response contract and application tests. | | `apps/github-app/` | AGPL managed-App adapter: verified webhook ingress, Queue execution, D1 recovery/history state, migrations and operator boundary. | +| `apps/browser-extension/` | AGPL Chrome Manifest V3 application: GitHub pull-request projection, settings, provider acquisition, bounded local storage, browser qualification and unpublished Store-preparation material. | | `apps/website/` | AGPL public website: Astro static output + Starlight manual rendered from repository Markdown, the complete playground as one lazy React island running the shared engine in the browser, examples catalogue, browser-extension and App pages, legal/privacy routes. Built and qualified locally; not deployed by any command here. | | `apps/shared/` | Small presenters both AGPL applications use (currently the App check-summary composer). | | `actions/` | Three sub-action entry points and their shared, committed runtime. | @@ -25,7 +26,7 @@ and disposable outputs without introducing independently versioned packages. | `wrangler.jsonc` | Source-owned Cloudflare Worker name, runtime compatibility and static-asset routing; it contains no account ID, token, custom domain or deployment state. | | `README.md`, `AGENTS.md` | Public product front door and repository-local contribution contract. | -`apps/playground/`, `apps/github-app/` and `apps/website/` consume the built reusable engine from `dist/lib`; none duplicates measurement, policy, or provider semantics. The website's playground bundles that same engine for the browser through two small shims for its Node-only imports. Their browser/server/Worker and managed ingress/Queue/D1 adapters are application code, not npm-package contents. +`apps/playground/`, `apps/github-app/`, `apps/browser-extension/` and `apps/website/` consume the built reusable engine; none duplicates measurement, policy, or provider semantics. The website's playground bundles that engine through two small shims for its Node-only imports, while the extension consumes the closed browser export and lightweight formatter. Their browser/server/Worker, managed ingress/Queue/D1 and GitHub-DOM adapters are application code, not npm-package contents. `dist/`, root `node_modules/` and `artifacts/` are ignored generated/local material, not extra products. They are recreated by documented commands and are not part of @@ -53,8 +54,11 @@ All paths below are relative to `src/diffdevil/`: The playground server, browser assets, response schema, Worker adapter and application tests live under `apps/playground/`; the managed App has separate Worker/Queue/D1 -adapters, migrations, and focused application tests under `apps/github-app/`. Both reuse -the core fake-provider fixture without moving application code into the MIT source boundary. +adapters, migrations, and focused application tests under `apps/github-app/`. The +browser extension owns its Chrome/GitHub lifecycle, settings and qualification under +`apps/browser-extension/`, while reusable browser-safe engine code remains under +`src/diffdevil/browser/`. These applications reuse the core without moving their +AGPL application code into the MIT source boundary. `wrangler.jsonc` routes only the playground; `apps/github-app/wrangler.jsonc` carries source-level App bindings with a non-operational D1 placeholder. Remaining hosted state is deliberately outside source.