From b1190a35a80b135a67834e714109ee9024b9604e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 26 May 2026 10:22:19 +0100 Subject: [PATCH 1/3] feat(codegen): embed affine-vscode adapter at compile time (root-cause fix for #139/#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `--vscode-extension` codegen previously emitted a runtime require() of `@hyperpolymath/affine-vscode` to source the WASM's `Vscode` and `VscodeLanguageClient` import modules. That package isn't published to npm yet (gated on owner action in #104), so the require failed, extraImports() returned {}, and the WASM activation failed: WebAssembly.instantiate(): Import #1 "Vscode": module is not an object or function The previous CI workaround (PR #377) added a `file:` install of the in-tree adapter as a smoke-test-step bridge — useful but not a root fix. This commit eliminates the runtime npm dependency entirely: 1. New dune rule generates `lib/affine_vscode_adapter_source.ml` from `packages/affine-vscode/mod.js` at build time, exposing the source as an OCaml string constant. 2. `vscode_extension_wiring` in `lib/codegen_node.ml` now inlines the adapter source into the generated `.cjs` as a CJS-style closure: try { _makeVscodeBindings = require("@hyperpolymath/affine-vscode"); } catch (_e) { /* fall through to embedded adapter */ } if (typeof _makeVscodeBindings !== "function") { const _adapterModule = { exports: null }; (function (module, exports) { /* …embedded packages/affine-vscode/mod.js source… */ })(_adapterModule, _adapterModule.exports); _makeVscodeBindings = _adapterModule.exports; } The require() is still attempted first so an upgraded adapter installed via npm can override the embedded copy — but it's no longer load-bearing. 3. Regenerated `editors/vscode/out/extension.cjs` with the new codegen (618 lines, up from 121 — the delta is the inlined adapter source). 4. Dropped `optionalDependencies` for `@hyperpolymath/affine-vscode` from `editors/vscode/package.json` — the extension no longer needs it at install time. The package keeps its in-tree home at `packages/affine-vscode/` as the source of truth for JSR/npm publishing and for `--vscode-extension` codegen consumption. 5. Dropped `continue-on-error: true` from the vscode-smoke job in `.github/workflows/ci.yml`. The job is now expected to be reliable; a real regression should turn it red and gate. Verified locally: the regenerated `extension.cjs` loads cleanly under plain Node (`require()` works, `extraImports` is a function), and the existing `tools/run_codegen_wasm_tests.sh` suite still passes. Refs #139 (smoke harness), #104 (pending npm publish — no longer a runtime blocker), gitbot-fleet#148. Closes the "fundamental root cause" path opened in PR #377. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 32 +- editors/vscode/out/extension.cjs | 521 ++++++++++++++++++++++++++++++- editors/vscode/package.json | 3 - lib/codegen_node.ml | 46 ++- lib/dune | 14 + 5 files changed, 569 insertions(+), 47 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91102fb3..a81a68f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,19 +211,13 @@ jobs: # (see CLAUDE.md "Runtime Exemptions") because the VS Code extension # host is npm/Node-native and no Deno/JSR equivalent exists. # - # Visibility-only until #104 lands (continue-on-error). The extension's - # wasm module imports the `Vscode` / `VscodeLanguageClient` module - # objects from the @hyperpolymath/affine-vscode adapter; without the - # adapter (gated on owner action — npm org create + NPM_TOKEN + - # affine-vscode-v0.1.0 tag push) the wasm cannot instantiate and - # activation fails. The defensive `optionalDependencies` move in - # editors/vscode/package.json and the try/catch around the adapter - # require in editors/vscode/out/extension.cjs already prevent the - # `npm install` step from 404ing — activation degradation is the - # remaining gap that #104 closes. Remove the `continue-on-error: true` - # line when #104 publishes the adapter to npm. + # Self-contained as of the codegen-embed fix: `--vscode-extension` + # codegen now inlines the affine-vscode adapter source into the + # generated .cjs at compile time, so activation no longer depends on + # the @hyperpolymath/affine-vscode npm package being installed + # (previously gated on #104's npm publish). A real regression should + # turn this job red and gate, so no `continue-on-error`. runs-on: ubuntu-latest - continue-on-error: true steps: - name: Checkout code @@ -236,14 +230,12 @@ jobs: - name: Install test runner dependencies working-directory: editors/vscode - # The compiled out/extension.cjs is checked in (see #35 Phase 3), - # so the smoke test does not need the OCaml toolchain — only the - # Node-side test runner deps. peerDeps `vscode` is provided by - # @vscode/test-electron at launch; the extension's host-bindings - # adapter @hyperpolymath/affine-vscode is an `optionalDependency` - # — npm install succeeds when it is unpublished (issue #104), and - # the smoke suite below detects the missing adapter and reports - # SKIPPED rather than failing. + # The compiled out/extension.cjs is checked in (see #35 Phase 3) + # and now embeds the affine-vscode adapter inline (codegen-embed), + # so the smoke test only needs the @vscode/test-electron runner + # deps. `vscode` itself is provided by the test runner at launch. + # The #381 SKIP-when-missing guard remains downstream as a safety + # net but is now expected to find the adapter present. run: npm install --no-audit --no-fund - name: Report adapter availability diff --git a/editors/vscode/out/extension.cjs b/editors/vscode/out/extension.cjs index 565167e7..e0ccfef2 100644 --- a/editors/vscode/out/extension.cjs +++ b/editors/vscode/out/extension.cjs @@ -8,7 +8,7 @@ "use strict"; -const _wasmBase64 = "AGFzbQEAAAABWhFgBH9/f38Bf2ACf38Bf2ABfwF/YAN/f38Bf2AAAX9gBX9/f39/AX9gAn9/AX9gAX8Bf2ACf38Bf2AAAX9gAAF/YAABf2AAAX9gAAF/YAABf2ABfwF/YAABfwL9BBYWd2FzaV9zbmFwc2hvdF9wcmV2aWV3MQhmZF93cml0ZQAABlZzY29kZQ9yZWdpc3RlckNvbW1hbmQAAQZWc2NvZGUQZ2V0Q29uZmlndXJhdGlvbgACBlZzY29kZRZ3b3Jrc3BhY2VDb25maWdHZXRCb29sAAMGVnNjb2RlGHdvcmtzcGFjZUNvbmZpZ0dldFN0cmluZwADBlZzY29kZRBzaG93RXJyb3JNZXNzYWdlAAIGVnNjb2RlEnNob3dXYXJuaW5nTWVzc2FnZQACBlZzY29kZRZzaG93SW5mb3JtYXRpb25NZXNzYWdlAAIGVnNjb2RlDmNyZWF0ZVRlcm1pbmFsAAIGVnNjb2RlDHRlcm1pbmFsU2hvdwACBlZzY29kZRB0ZXJtaW5hbFNlbmRUZXh0AAEGVnNjb2RlEHB1c2hTdWJzY3JpcHRpb24AAQZWc2NvZGUUZWRpdG9yQWN0aXZlRmlsZVBhdGgABAZWc2NvZGUWZWRpdG9yQWN0aXZlTGFuZ3VhZ2VJZAAEBlZzY29kZQpjb25zb2xlTG9nAAIGVnNjb2RlCGV4ZWNTeW5jAAIGVnNjb2RlDHN0cmluZ0NvbmNhdAABBlZzY29kZQ5zdHJpbmdFbmRzV2l0aAABBlZzY29kZRNzdHJpbmdSZXBsYWNlU3VmZml4AAMUVnNjb2RlTGFuZ3VhZ2VDbGllbnQRbmV3TGFuZ3VhZ2VDbGllbnQABRRWc2NvZGVMYW5ndWFnZUNsaWVudBNsYW5ndWFnZUNsaWVudFN0YXJ0AAIUVnNjb2RlTGFuZ3VhZ2VDbGllbnQSbGFuZ3VhZ2VDbGllbnRTdG9wAAIDDAsGBwgJCgsMDQ4PEAQBAAUDAQABBgEAB3oIBm1lbW9yeQIADWhhbmRsZXJfY2hlY2sAGQxoYW5kbGVyX2V2YWwAGg9oYW5kbGVyX2NvbXBpbGUAGw5oYW5kbGVyX2Zvcm1hdAAcE2hhbmRsZXJfcmVzdGFydF9sc3AAHQhhY3RpdmF0ZQAfCmRlYWN0aXZhdGUAIAkBAArZBAscAQF/IAAQCCECIAIQCRogAiABEAoaQQAPGkEACykBAX8QDSEBIAFBgBAQEUEBRgR/EAwPGkEABUGQEBAFGkGtEA8aQQALCyEBAX9BsRAgABAQQcIQEBAhAiACIAEQEEHIEBAQDxpBAAsyAQF/Qc0QEBchACAAQdYQEBFBAEYEf0EADxpBAAVBAAsaQeEQQc0QIAAQGBAWDxpBAAsyAQF/QfcQEBchACAAQdYQEBFBAEYEf0EADxpBAAVBAAsaQf8QQfcQIAAQGBAWDxpBAAtQAQN/QZQREBchACAAQdYQEBFBAEYEf0EADxpBAAVBAAsaIABB1hBBnxEQEiEBQagRIAAQEEHCERAQIAFByBAQEBAQIQJBzBEgAhAWDxpBAAtSAQN/QeQREBchACAAQdYQEBFBAEYEf0EADxpBAAVBAAsaQe4RIAAQGCEBIAEQDyECIAJBAEYEf0H1ERAHGkEADxpBAAVBlBIQBRogAg8aQQALCw4AQakSEAcaQQAPGkEAC2ABBX9BgBAQAiEAIABB4BJB8hIQBCEBQYYTIAEQECECIAIQDyEDIANBAEcEf0GQExAGGkEBDxpBAAVBAAsaQdITQeUTIAFBrRBBABATIQQgBBAUGkGFFBAHGkEADxpBAAtrAQF/QaEUEA4aIABBxRRBABABEAsaIABB2xRBARABEAsaIABB8BRBAhABEAsaIABBiBVBAxABEAsaIABBnxVBBBABEAsaQYAQEAIhASABQboVQQEQA0EARwR/EB4aQQAFQQALGkEADxpBAAsIAEEADxpBAAsLnAcjAEGAEAsQDAAAAGFmZmluZXNjcmlwdABBkBALHRkAAABObyBBZmZpbmVTY3JpcHQgZmlsZSBvcGVuAEGtEAsEAAAAAABBsRALEQ0AAABhZmZpbmVzY3JpcHQgAEHCEAsGAgAAACAiAEHIEAsFAQAAACIAQc0QCwkFAAAAY2hlY2sAQdYQCwsHAAAALmFmZmluZQBB4RALFhIAAABBZmZpbmVTY3JpcHQgQ2hlY2sAQfcQCwgEAAAAZXZhbABB/xALFREAAABBZmZpbmVTY3JpcHQgRXZhbABBlBELCwcAAABjb21waWxlAEGfEQsJBQAAAC53YXNtAEGoEQsaFgAAAGFmZmluZXNjcmlwdCBjb21waWxlICIAQcIRCwoGAAAAIiAtbyAiAEHMEQsYFAAAAEFmZmluZVNjcmlwdCBDb21waWxlAEHkEQsKBgAAAGZvcm1hdABB7hELBwMAAABmbXQAQfURCx8bAAAARmlsZSBmb3JtYXR0ZWQgc3VjY2Vzc2Z1bGx5AEGUEgsVEQAAAEZvcm1hdHRpbmcgZmFpbGVkAEGpEgs3MwAAAEFmZmluZVNjcmlwdCBMU1Agc3RvcHBlZCAocmVsb2FkIHdpbmRvdyB0byByZXN0YXJ0KQBB4BILEg4AAABsc3Auc2VydmVyUGF0aABB8hILFBAAAABhZmZpbmVzY3JpcHQtbHNwAEGGEwsKBgAAAHdoaWNoIABBkBMLQj4AAABBZmZpbmVTY3JpcHQgTFNQIHNlcnZlciBub3QgZm91bmQuIExhbmd1YWdlIGZlYXR1cmVzIGRpc2FibGVkLgBB0hMLEw8AAABhZmZpbmVzY3JpcHRMc3AAQeUTCyAcAAAAQWZmaW5lU2NyaXB0IExhbmd1YWdlIFNlcnZlcgBBhRQLHBgAAABBZmZpbmVTY3JpcHQgTFNQIHN0YXJ0ZWQAQaEUCyQgAAAAQWZmaW5lU2NyaXB0IGV4dGVuc2lvbiBhY3RpdmF0ZWQAQcUUCxYSAAAAYWZmaW5lc2NyaXB0LmNoZWNrAEHbFAsVEQAAAGFmZmluZXNjcmlwdC5ldmFsAEHwFAsYFAAAAGFmZmluZXNjcmlwdC5jb21waWxlAEGIFQsXEwAAAGFmZmluZXNjcmlwdC5mb3JtYXQAQZ8VCxsXAAAAYWZmaW5lc2NyaXB0LnJlc3RhcnRMc3AAQboVCw8LAAAAbHNwLmVuYWJsZWQAYxZhZmZpbmVzY3JpcHQub3duZXJzaGlwCwAAABYAAAACAAAAFwAAAAEAABgAAAACAAAAGQAAAAAAGgAAAAAAGwAAAAAAHAAAAAAAHQAAAAAAHgAAAAAAHwAAAAEAACAAAAAAAA=="; +const _wasmBase64 = "AGFzbQEAAAABeBZgBH9/f38Bf2ACf38Bf2ABfwF/YAN/f38Bf2AAAX9gBX9/f39/AX9gAn9/AX9gAX8Bf2ACf38Bf2AAAX9gAAF/YAABf2AAAX9gAAF/YAABf2ABfwF/YAJ/fwF/YAJ/fwF/YAJ/fwF/YAJ/fwF/YAJ/fwF/YAABfwL9BBYWd2FzaV9zbmFwc2hvdF9wcmV2aWV3MQhmZF93cml0ZQAABlZzY29kZQ9yZWdpc3RlckNvbW1hbmQAAQZWc2NvZGUQZ2V0Q29uZmlndXJhdGlvbgACBlZzY29kZRZ3b3Jrc3BhY2VDb25maWdHZXRCb29sAAMGVnNjb2RlGHdvcmtzcGFjZUNvbmZpZ0dldFN0cmluZwADBlZzY29kZRBzaG93RXJyb3JNZXNzYWdlAAIGVnNjb2RlEnNob3dXYXJuaW5nTWVzc2FnZQACBlZzY29kZRZzaG93SW5mb3JtYXRpb25NZXNzYWdlAAIGVnNjb2RlDmNyZWF0ZVRlcm1pbmFsAAIGVnNjb2RlDHRlcm1pbmFsU2hvdwACBlZzY29kZRB0ZXJtaW5hbFNlbmRUZXh0AAEGVnNjb2RlEHB1c2hTdWJzY3JpcHRpb24AAQZWc2NvZGUUZWRpdG9yQWN0aXZlRmlsZVBhdGgABAZWc2NvZGUWZWRpdG9yQWN0aXZlTGFuZ3VhZ2VJZAAEBlZzY29kZQpjb25zb2xlTG9nAAIGVnNjb2RlCGV4ZWNTeW5jAAIGVnNjb2RlDHN0cmluZ0NvbmNhdAABBlZzY29kZQ5zdHJpbmdFbmRzV2l0aAABBlZzY29kZRNzdHJpbmdSZXBsYWNlU3VmZml4AAMUVnNjb2RlTGFuZ3VhZ2VDbGllbnQRbmV3TGFuZ3VhZ2VDbGllbnQABRRWc2NvZGVMYW5ndWFnZUNsaWVudBNsYW5ndWFnZUNsaWVudFN0YXJ0AAIUVnNjb2RlTGFuZ3VhZ2VDbGllbnQSbGFuZ3VhZ2VDbGllbnRTdG9wAAIDERAGBwgJCgsMDQ4PFRAREhMUBAUBcAEFBQUDAQABBgcBfwFBgAgLB5YBCQZtZW1vcnkCAA1oYW5kbGVyX2NoZWNrABkMaGFuZGxlcl9ldmFsABoPaGFuZGxlcl9jb21waWxlABsOaGFuZGxlcl9mb3JtYXQAHBNoYW5kbGVyX3Jlc3RhcnRfbHNwAB0IYWN0aXZhdGUAHwpkZWFjdGl2YXRlACAZX19pbmRpcmVjdF9mdW5jdGlvbl90YWJsZQEACQsBAEEACwUhIiMkJQrwBRAcAQF/IAAQCCECIAIQCRogAiABEAoaQQAPGkEACykBAX8QDSEBIAFBgBAQEUEBRgR/EAwPGkEABUGQEBAFGkGtEA8aQQALCyEBAX9BsRAgABAQQcIQEBAhAiACIAEQEEHIEBAQDxpBAAsyAQF/Qc0QEBchACAAQdYQEBFBAEYEf0EADxpBAAVBAAsaQeEQQc0QIAAQGBAWDxpBAAsyAQF/QfcQEBchACAAQdYQEBFBAEYEf0EADxpBAAVBAAsaQf8QQfcQIAAQGBAWDxpBAAtQAQN/QZQREBchACAAQdYQEBFBAEYEf0EADxpBAAVBAAsaIABB1hBBnxEQEiEBQagRIAAQEEHCERAQIAFByBAQEBAQIQJBzBEgAhAWDxpBAAtSAQN/QeQREBchACAAQdYQEBFBAEYEf0EADxpBAAVBAAsaQe4RIAAQGCEBIAEQDyECIAJBAEYEf0H1ERAHGkEADxpBAAVBlBIQBRogAg8aQQALCw4AQakSEAcaQQAPGkEAC2ABBX9BgBAQAiEAIABB4BJB8hIQBCEBQYYTIAEQECECIAIQDyEDIANBAEcEf0GQExAGGkEBDxpBAAVBAAsaQdITQeUTIAFBrRBBABATIQQgBBAUGkGFFBAHGkEADxpBAAvoAQEGf0GhFBAOGiAAQcUUIwAjAEEIaiQAIQEgAUEANgIAIAFBADYCBCABEAEQCxogAEHbFCMAIwBBCGokACECIAJBATYCACACQQA2AgQgAhABEAsaIABB8BQjACMAQQhqJAAhAyADQQI2AgAgA0EANgIEIAMQARALGiAAQYgVIwAjAEEIaiQAIQQgBEEDNgIAIARBADYCBCAEEAEQCxogAEGfFSMAIwBBCGokACEFIAVBBDYCACAFQQA2AgQgBRABEAsaQYAQEAIhBiAGQboVQQEQA0EARwR/EB4aQQAFQQALGkEADxpBAAsIAEEADxpBAAsEABAZCwQAEBoLBAAQGwsEABAcCwQAEB0LC5wHIwBBgBALEAwAAABhZmZpbmVzY3JpcHQAQZAQCx0ZAAAATm8gQWZmaW5lU2NyaXB0IGZpbGUgb3BlbgBBrRALBAAAAAAAQbEQCxENAAAAYWZmaW5lc2NyaXB0IABBwhALBgIAAAAgIgBByBALBQEAAAAiAEHNEAsJBQAAAGNoZWNrAEHWEAsLBwAAAC5hZmZpbmUAQeEQCxYSAAAAQWZmaW5lU2NyaXB0IENoZWNrAEH3EAsIBAAAAGV2YWwAQf8QCxURAAAAQWZmaW5lU2NyaXB0IEV2YWwAQZQRCwsHAAAAY29tcGlsZQBBnxELCQUAAAAud2FzbQBBqBELGhYAAABhZmZpbmVzY3JpcHQgY29tcGlsZSAiAEHCEQsKBgAAACIgLW8gIgBBzBELGBQAAABBZmZpbmVTY3JpcHQgQ29tcGlsZQBB5BELCgYAAABmb3JtYXQAQe4RCwcDAAAAZm10AEH1EQsfGwAAAEZpbGUgZm9ybWF0dGVkIHN1Y2Nlc3NmdWxseQBBlBILFREAAABGb3JtYXR0aW5nIGZhaWxlZABBqRILNzMAAABBZmZpbmVTY3JpcHQgTFNQIHN0b3BwZWQgKHJlbG9hZCB3aW5kb3cgdG8gcmVzdGFydCkAQeASCxIOAAAAbHNwLnNlcnZlclBhdGgAQfISCxQQAAAAYWZmaW5lc2NyaXB0LWxzcABBhhMLCgYAAAB3aGljaCAAQZATC0I+AAAAQWZmaW5lU2NyaXB0IExTUCBzZXJ2ZXIgbm90IGZvdW5kLiBMYW5ndWFnZSBmZWF0dXJlcyBkaXNhYmxlZC4AQdITCxMPAAAAYWZmaW5lc2NyaXB0THNwAEHlEwsgHAAAAEFmZmluZVNjcmlwdCBMYW5ndWFnZSBTZXJ2ZXIAQYUUCxwYAAAAQWZmaW5lU2NyaXB0IExTUCBzdGFydGVkAEGhFAskIAAAAEFmZmluZVNjcmlwdCBleHRlbnNpb24gYWN0aXZhdGVkAEHFFAsWEgAAAGFmZmluZXNjcmlwdC5jaGVjawBB2xQLFREAAABhZmZpbmVzY3JpcHQuZXZhbABB8BQLGBQAAABhZmZpbmVzY3JpcHQuY29tcGlsZQBBiBULFxMAAABhZmZpbmVzY3JpcHQuZm9ybWF0AEGfFQsbFwAAAGFmZmluZXNjcmlwdC5yZXN0YXJ0THNwAEG6FQsPCwAAAGxzcC5lbmFibGVkAGMWYWZmaW5lc2NyaXB0Lm93bmVyc2hpcAsAAAAWAAAAAgAAABcAAAABAAAYAAAAAgAAABkAAAAAABoAAAAAABsAAAAAABwAAAAAAB0AAAAAAB4AAAAAAB8AAAABAAAgAAAAAAA="; const _wasmBytes = Buffer.from(_wasmBase64, "base64"); // Per-process opaque-handle table for host objects (ExtensionContext, @@ -51,10 +51,10 @@ function _buildImports() { return 0; }, }; - // Phase 2 hook: callers can replace exports.extraImports with a function - // returning a `{ ModuleName: { exportName: fn, ... } }` map of concrete - // host bindings (e.g. the @hyperpolymath/affine-vscode adapter). Default - // is empty so the shim works standalone. + // Phase 2 hook: a caller may install an `extraImports` factory on the + // exports object returning a `{ ModuleName: { exportName: fn, ... } }` + // map of concrete host bindings (this is what the --vscode-extension + // wiring installs). Default is empty so the shim works standalone. const extras = (typeof exports.extraImports === "function") ? exports.extraImports() : {}; @@ -99,17 +99,514 @@ exports._freeHandle = _freeHandle; // file is directly loadable as a VS Code extension's `main`. Replaces the // previously hand-written index.cjs + vendored adapter boilerplate. // -// Defensive load (issue #104): the adapter package may not yet be on npm -// when this .cjs is bundled into a smoke harness or distributed before the -// `affine-vscode-v*` publish-tag lands. Treat MODULE_NOT_FOUND as a soft -// failure — extraImports degrades to empty so activate()/deactivate() still -// resolve. Any other require error (syntax, transitive failure) is rethrown -// so real bugs are not masked. +// Self-contained (issue #139 / #104 root-cause fix): the +// affine-vscode adapter source is *embedded* at codegen time so the +// generated .cjs needs no npm dependency to instantiate. A +// require("@hyperpolymath/affine-vscode") is still attempted first so an installed package can +// override the embedded copy; if the require fails for any reason +// (MODULE_NOT_FOUND, syntax, transitive failure) we fall back to the +// embedded adapter and the extension activates cleanly anyway. let _makeVscodeBindings = null; try { _makeVscodeBindings = require("@hyperpolymath/affine-vscode"); } catch (_e) { - if (_e && _e.code !== "MODULE_NOT_FOUND") throw _e; + // Any require failure falls through to the embedded adapter below. +} +if (typeof _makeVscodeBindings !== "function") { + // Embedded adapter source (CJS-style module wrapper). + const _adapterModule = { exports: null }; + (function (module, exports) { + +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// affine-vscode: JS-side adapter for stdlib/Vscode.affine + stdlib/VscodeLanguageClient.affine. +// +// Issue #35 Phase 2 deliverable. Resolves each `extern fn` declared in the +// bindings to the right vscode API call. +// +// Preferred wiring (issue #105): compile with `--vscode-extension` and the +// generated .cjs installs `exports.extraImports` calling this adapter +// automatically — no hand-written entry point. +// +// Manual wiring (fallback), from a hand-written .cjs: +// +// const shim = require("./extension.cjs"); +// shim.extraImports = () => require("@hyperpolymath/affine-vscode")( +// require("vscode"), +// require("vscode-languageclient/node"), +// shim, // the .cjs shim module (hostShim) +// ); +// +// The adapter maintains a per-process JS-side handle table keyed by Int +// so opaque handles passed across the FFI boundary survive round-trips. + +"use strict"; + +module.exports = function makeVscodeBindings(vscode, lcModule, hostShim) { + // `hostShim` is the .cjs module produced by `affinescript compile -o ...`. + // We share its handle table (so ExtensionContext registered at activate + // time is visible here) and read its `_instance` lazily so this adapter + // can be constructed BEFORE `WebAssembly.instantiate` runs — the calls + // back into adapter functions happen later, once `_instance` is live. + const reg = (obj) => hostShim._registerHandle(obj); + const get = (h) => hostShim._getHandle(h); + const getInstance = () => hostShim._instance; + // Settled host-Thenable values, keyed by Thenable handle (issue #205). + const __thenableResults = new Map(); + + // ── String marshalling ───────────────────────────────────────────── + // AffineScript's WASM 1.0 codegen stores string literals at the offset + // returned by the call-site; the layout is [u32 length][utf-8 bytes]. + // Read that shape out of the module's exported memory. + function readString(ptr) { + const inst = getInstance(); + if (!inst || !inst.exports.memory) return ""; + const dv = new DataView(inst.exports.memory.buffer); + const len = dv.getUint32(ptr, true); + const bytes = new Uint8Array(inst.exports.memory.buffer, ptr + 4, len); + return new TextDecoder("utf-8").decode(bytes); + } + + // ── Wasm closure callbacks → JS callable ─────────────────────────── + // Post-#199 (function-value callback ABI) a handler arrives as a + // *closure pointer*, not a bare table index: an 8-byte heap pair + // [i32 function_id @ +0][i32 env_ptr @ +4] (codegen.ml). To invoke, + // read the pair from exported memory, look the compiled lambda up in + // __indirect_function_table by function_id, and call it with env_ptr + // as the first argument (the closure calling convention), zero-filling + // any further declared params (e.g. the `Unit` handler arg). + function wrapHandler(closurePtr) { + return () => { + const inst = getInstance(); + if (!inst || !inst.exports || !inst.exports.memory) return; + const tbl = inst.exports.__indirect_function_table; + if (!tbl) return; + const dv = new DataView(inst.exports.memory.buffer); + const fnId = dv.getInt32(closurePtr, true); + const envPtr = dv.getInt32(closurePtr + 4, true); + const fn = tbl.get(fnId); + if (typeof fn !== "function") return; + const args = [envPtr]; + while (args.length < fn.length) args.push(0); + return fn(...args); + }; + } + + // Returned shape is namespaced by the AffineScript module that declared + // each extern: cross-module imports in the wasm reference module="Vscode" + // and module="VscodeLanguageClient" (the dotted module path), so the + // import map's top-level keys must match. + const Vscode = { + // ── vscode.commands ────────────────────────────────────────────── + registerCommand: (namePtr, handlerPtr) => { + const name = readString(namePtr); + const handler = wrapHandler(handlerPtr); + const disposable = vscode.commands.registerCommand(name, handler); + return reg(disposable); + }, + + // ── vscode.workspace ───────────────────────────────────────────── + getConfiguration: (sectionPtr) => + reg(vscode.workspace.getConfiguration(readString(sectionPtr))), + + workspaceConfigGetString: (cfgHandle, keyPtr, defPtr) => { + const cfg = get(cfgHandle); + const result = cfg.get(readString(keyPtr), readString(defPtr)); + // Returning a string-pointer would require allocating in the Wasm + // module's memory. Until that helper exists, return a sentinel + // handle that the caller treats as "look me up via getResultString". + // For now: register the JS string and return its handle. + return reg(String(result)); + }, + + createFileSystemWatcher: (globPtr) => + reg(vscode.workspace.createFileSystemWatcher(readString(globPtr))), + + // ── vscode.window ──────────────────────────────────────────────── + activeTextEditor: () => { + const ed = vscode.window.activeTextEditor; + return ed ? reg(ed) : 0; + }, + showErrorMessage: (msgPtr) => reg(vscode.window.showErrorMessage(readString(msgPtr))), + showWarningMessage: (msgPtr) => reg(vscode.window.showWarningMessage(readString(msgPtr))), + showInformationMessage: (msgPtr) => reg(vscode.window.showInformationMessage(readString(msgPtr))), + + createTerminal: (namePtr) => + reg(vscode.window.createTerminal(readString(namePtr))), + terminalShow: (tHandle) => { const t = get(tHandle); if (t) t.show(); return 0; }, + terminalSendText: (tHandle, textPtr) => { + const t = get(tHandle); if (t) t.sendText(readString(textPtr)); return 0; + }, + + // ── ExtensionContext ──────────────────────────────────────────── + pushSubscription: (ctxHandle, dHandle) => { + const ctx = get(ctxHandle); + const d = get(dHandle); + if (ctx && d) ctx.subscriptions.push(d); + return 0; + }, + + // ── Editor document helpers ─────────────────────────────────────── + editorActiveFilePath: () => { + const ed = vscode.window.activeTextEditor; + return ed ? reg(ed.document.uri.fsPath) : reg(""); + }, + editorActiveLanguageId: () => { + const ed = vscode.window.activeTextEditor; + return ed ? reg(ed.document.languageId) : reg(""); + }, + + // ── Boolean config ─────────────────────────────────────────────── + workspaceConfigGetBool: (cfgHandle, keyPtr, defVal) => { + const cfg = get(cfgHandle); + if (!cfg) return defVal; + return cfg.get(readString(keyPtr), defVal !== 0) ? 1 : 0; + }, + + // ── Host process / IO ──────────────────────────────────────────── + consoleLog: (msgPtr) => { console.log(readString(msgPtr)); return 0; }, + execSync: (cmdPtr) => { + try { + require("child_process").execSync(readString(cmdPtr), { stdio: "ignore" }); + return 0; + } catch (e) { + return e.status ?? 1; + } + }, + + // ── String helpers ──────────────────────────────────────────────── + stringConcat: (aPtr, bPtr) => reg(readString(aPtr) + readString(bPtr)), + stringEndsWith: (sPtr, suffixPtr) => + readString(sPtr).endsWith(readString(suffixPtr)) ? 1 : 0, + stringReplaceSuffix: (sPtr, suffixPtr, replacementPtr) => { + const s = readString(sPtr); + const suffix = readString(suffixPtr); + const replacement = readString(replacementPtr); + const out = s.endsWith(suffix) ? s.slice(0, -suffix.length) + replacement : s; + return reg(out); + }, + stringIsEmpty: (sPtr) => readString(sPtr).length === 0 ? 1 : 0, + + // ── Workspace ─────────────────────────────────────────────────── + workspaceFolderFirstPath: () => { + const folders = vscode.workspace.workspaceFolders; + const first = folders && folders[0]; + return reg(first ? first.uri.fsPath : ""); + }, + workspaceRootUri: () => { + const folders = vscode.workspace.workspaceFolders; + const first = folders && folders[0]; + return first ? reg(first.uri) : 0; + }, + + // ── URI / file-system / text documents ───────────────────────── + uriFromPath: (pathPtr) => reg(vscode.Uri.file(readString(pathPtr))), + uriJoinPath: (baseHandle, segPtr) => { + const base = get(baseHandle); + if (!base) return 0; + return reg(vscode.Uri.joinPath(base, readString(segPtr))); + }, + uriPath: (uHandle) => { + const u = get(uHandle); + return reg(u ? u.fsPath : ""); + }, + fsWriteFile: (uHandle, contentPtr) => { + const u = get(uHandle); + if (!u) return 1; + try { + // Fire-and-forget the Thenable. The host serialises FS ops so + // a subsequent openTextDocument on the same URI sees the file. + vscode.workspace.fs.writeFile(u, Buffer.from(readString(contentPtr))); + return 0; + } catch (e) { + return 1; + } + }, + openTextDocument: (uHandle) => { + const u = get(uHandle); + if (!u) return 0; + // openTextDocument returns a Thenable. The synchronous + // FFI returns a handle to the Thenable itself; showTextDocument is + // also Thenable-returning and chains via vscode's internal queue, + // so this works in practice for the open-then-show pattern. + return reg(vscode.workspace.openTextDocument(u)); + }, + showTextDocument: (dHandle) => { + const d = get(dHandle); + if (!d) return 1; + // If `d` is itself a Thenable, vscode unwraps it. + Promise.resolve(d).then((doc) => vscode.window.showTextDocument(doc)); + return 0; + }, + + // ── Status bar ───────────────────────────────────────────────── + createStatusBarItem: (alignment, priority) => { + const align = alignment === 1 + ? vscode.StatusBarAlignment.Right + : vscode.StatusBarAlignment.Left; + return reg(vscode.window.createStatusBarItem(align, priority)); + }, + statusBarItemSetText: (sHandle, tPtr) => { + const s = get(sHandle); + if (s) s.text = readString(tPtr); + return 0; + }, + statusBarItemSetTooltip: (sHandle, tPtr) => { + const s = get(sHandle); + if (s) s.tooltip = readString(tPtr); + return 0; + }, + statusBarItemSetCommand: (sHandle, cPtr) => { + const s = get(sHandle); + if (s) s.command = readString(cPtr); + return 0; + }, + statusBarItemSetBackgroundColorTheme: (sHandle, cPtr) => { + const s = get(sHandle); + if (!s) return 0; + const name = readString(cPtr); + s.backgroundColor = name.length === 0 ? undefined : new vscode.ThemeColor(name); + return 0; + }, + statusBarItemShow: (sHandle) => { const s = get(sHandle); if (s) s.show(); return 0; }, + statusBarItemHide: (sHandle) => { const s = get(sHandle); if (s) s.hide(); return 0; }, + statusBarItemAsDisposable: (sHandle) => sHandle, // same JS object is a Disposable + + // ── Diagnostics ──────────────────────────────────────────────── + createDiagnosticCollection: (namePtr) => + reg(vscode.languages.createDiagnosticCollection(readString(namePtr))), + diagnosticCollectionClear: (cHandle) => { + const c = get(cHandle); + if (c) c.clear(); + return 0; + }, + diagnosticCollectionSetForUri: (cHandle, uHandle, jsonPtr) => { + const c = get(cHandle); + const u = get(uHandle); + if (!c || !u) return 1; + let arr; + try { arr = JSON.parse(readString(jsonPtr)); } + catch (e) { return 2; } + if (!Array.isArray(arr)) return 3; + const diagnostics = arr.map((d) => { + const range = new vscode.Range( + d.startLine | 0, d.startCol | 0, + d.endLine | 0, d.endCol | 0 + ); + const severity = [ + vscode.DiagnosticSeverity.Error, + vscode.DiagnosticSeverity.Warning, + vscode.DiagnosticSeverity.Information, + vscode.DiagnosticSeverity.Hint, + ][Math.max(0, Math.min(3, d.severity | 0))]; + return new vscode.Diagnostic(range, String(d.message ?? ""), severity); + }); + c.set(u, diagnostics); + return 0; + }, + diagnosticCollectionAsDisposable: (cHandle) => cHandle, + + // ── Webview ──────────────────────────────────────────────────── + createWebviewPanel: (vtPtr, titlePtr, vc) => { + const viewColumn = + vc === 2 ? vscode.ViewColumn.Two : + vc === 3 ? vscode.ViewColumn.Three : + vscode.ViewColumn.One; + return reg(vscode.window.createWebviewPanel( + readString(vtPtr), readString(titlePtr), viewColumn, {} + )); + }, + webviewPanelSetHtml: (pHandle, htmlPtr) => { + const p = get(pHandle); + if (p) p.webview.html = readString(htmlPtr); + return 0; + }, + webviewPanelAsDisposable: (pHandle) => pHandle, + + // ── Clipboard ────────────────────────────────────────────────── + clipboardWriteText: (tPtr) => { + try { + vscode.env.clipboard.writeText(readString(tPtr)); + return 0; + } catch (e) { + return 1; + } + }, + + // ── Events ───────────────────────────────────────────────────── + onDidSaveTextDocument: (handlerPtr) => { + const thunk = wrapHandler(handlerPtr); + // The vscode event ships a TextDocument; we deliberately drop it at + // the FFI boundary (see Vscode.affine docstring). Handlers that + // need the saved file path can call editorActiveFilePath(). + return reg(vscode.workspace.onDidSaveTextDocument(() => thunk())); + }, + + // ── Path helpers ─────────────────────────────────────────────── + pathBasename: (pPtr) => reg(require("path").basename(readString(pPtr))), + pathJoin: (aPtr, bPtr) => + reg(require("path").join(readString(aPtr), readString(bPtr))), + processPlatform: () => reg(process.platform), + + // ── ExtensionContext helpers ─────────────────────────────────── + extensionAbsolutePath: (ctxHandle, relPtr) => { + const ctx = get(ctxHandle); + return reg(ctx ? ctx.asAbsolutePath(readString(relPtr)) : ""); + }, + + // ── Thenable resolution (issue #205) ─────────────────────────── + // The wasm guest cannot await; these let it observe a settled host + // Thenable. thenableThen registers the guest closure (reusing the + // #199 closure-pointer marshalling via wrapHandler) and stores the + // settled value keyed by the Thenable handle; thenableResultJson + // returns it JSON-encoded (same reg(string) return convention as + // every other `-> String` extern). + thenableThen: (tHandle, onSettlePtr) => { + const thenable = get(tHandle); + const cb = wrapHandler(onSettlePtr); + if (!thenable || typeof thenable.then !== "function") { + return reg({ dispose() {} }); + } + Promise.resolve(thenable).then( + (val) => { __thenableResults.set(tHandle, val); try { cb(); } catch (_e) {} }, + (err) => { + __thenableResults.set(tHandle, { __error: String(err) }); + try { cb(); } catch (_e) {} + } + ); + return reg({ dispose() {} }); + }, + thenableResultJson: (tHandle) => { + if (!__thenableResults.has(tHandle)) return reg(""); + try { return reg(JSON.stringify(__thenableResults.get(tHandle))); } + catch (_e) { return reg(""); } + }, + + // `httpPostJson(url, body_json)` — out-of-process JSON POST for BoJ + // cartridge calls (e.g. boj-server :7700 reposystem_run_audit). Like + // languageClientSendRequest, we register the response Thenable in the + // handle table and let the guest observe it via thenableThen / + // thenableResultJson. Resolves with the parsed JSON body so + // thenableResultJson re-serialises it consistently; a non-JSON or + // failed response settles as { __error } (same shape thenableThen + // uses for rejections), so the guest can branch to its fallback. + httpPostJson: (urlPtr, bodyPtr) => { + const url = readString(urlPtr); + const body = readString(bodyPtr); + const doFetch = (typeof fetch === "function") + ? fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }).then((r) => r.json()) + : Promise.reject(new Error("fetch unavailable")); + return reg(doFetch.catch((err) => ({ __error: String(err) }))); + }, + + // `jsonField(json, key)` — minimal one-level JSON field read for + // guests with no JSON parser. Mirrors thenableResultJson's + // synchronous reg(string) shape; "" on parse failure / non-object / + // missing key (the guest treats "" as absent). Scalars are coerced + // to their string form; objects/arrays are re-serialised so the + // guest can at least detect presence / pass them on. + jsonField: (jsonPtr, keyPtr) => { + const raw = readString(jsonPtr); + const key = readString(keyPtr); + try { + const obj = JSON.parse(raw); + if (obj === null || typeof obj !== "object") return reg(""); + if (!(key in obj)) return reg(""); + const v = obj[key]; + if (v === null || v === undefined) return reg(""); + return reg(typeof v === "object" ? JSON.stringify(v) : String(v)); + } catch (_e) { + return reg(""); + } + }, + + // `withProgressNotification(title, work)` (issue #212) — wrap a guest + // async unit of work in a VS Code progress notification. `work` is the + // #199 closure (`fn(Unit) -> Thenable`); invoking it returns the guest's + // Thenable *handle*, which we resolve through the shared handle table. + // We register the overall progress Thenable so the guest observes + // completion with thenableThen / thenableResultJson, identical to + // httpPostJson. If the host has no withProgress (non-VS Code / test + // runner) the work still runs — only the progress chrome is skipped. + // Failures settle as { __error } (the established reject shape). + withProgressNotification: (titlePtr, workPtr) => { + const title = readString(titlePtr); + const work = wrapHandler(workPtr); + const runWork = () => { + const h = work(); + const inner = (typeof h === "number") ? get(h) : h; + return Promise.resolve(inner); + }; + const canProgress = + typeof vscode.window.withProgress === "function" && + vscode.ProgressLocation; + const result = canProgress + ? vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title }, + () => runWork() + ) + : runWork(); + return reg( + Promise.resolve(result).catch((err) => ({ __error: String(err) })) + ); + }, + }; + + const VscodeLanguageClient = { + // ── vscode-languageclient/node ────────────────────────────────── + newLanguageClient: (idPtr, namePtr, cmdPtr, argsNlPtr, transportKind) => { + const id = readString(idPtr); + const name = readString(namePtr); + const command = readString(cmdPtr); + const args = readString(argsNlPtr).split("\n").filter(s => s.length > 0); + const transport = transportKind === 1 ? lcModule.TransportKind.ipc : lcModule.TransportKind.stdio; + const serverOptions = { run: { command, args, transport }, debug: { command, args, transport } }; + const clientOptions = { documentSelector: [{ scheme: "file" }] }; + return reg(new lcModule.LanguageClient(id, name, serverOptions, clientOptions)); + }, + languageClientStart: (cHandle) => { + const c = get(cHandle); + if (c) c.start(); + return 0; + }, + languageClientStop: (cHandle) => { + const c = get(cHandle); + if (c) c.stop(); + return 0; + }, + // `LanguageClient.sendRequest(method, params)` (issue #103). `params` + // arrives as a JSON string (the binding's synchronous extern shape); + // we parse it, invoke the LSP request, and register the returned + // Thenable in the handle table. The consumer awaits it on the + // source-to-source path (the wasm path additionally needs the + // thenable-resolution primitives — tracked in #199). An empty or + // malformed params string is treated as no params. + languageClientSendRequest: (cHandle, methodPtr, paramsJsonPtr) => { + const c = get(cHandle); + if (!c) return 0; + const method = readString(methodPtr); + const raw = readString(paramsJsonPtr); + let params; + if (raw && raw.length > 0) { + try { params = JSON.parse(raw); } catch (_e) { params = undefined; } + } + const thenable = params === undefined + ? c.sendRequest(method) + : c.sendRequest(method, params); + return reg(thenable); + }, + }; + + return { Vscode, VscodeLanguageClient }; +}; + + })(_adapterModule, _adapterModule.exports); + _makeVscodeBindings = _adapterModule.exports; } exports.extraImports = function() { if (typeof _makeVscodeBindings !== "function") return {}; diff --git a/editors/vscode/package.json b/editors/vscode/package.json index d54f7a98..26010690 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -151,8 +151,5 @@ }, "dependencies": { "vscode-languageclient": "^9.0.0" - }, - "optionalDependencies": { - "@hyperpolymath/affine-vscode": "^0.1.0" } } diff --git a/lib/codegen_node.ml b/lib/codegen_node.ml index 68b1f78e..dcdfb461 100644 --- a/lib/codegen_node.ml +++ b/lib/codegen_node.ml @@ -119,10 +119,19 @@ let default_vscode_adapter = "@hyperpolymath/affine-vscode" Returns the JS that installs [exports.extraImports] so the generated [.cjs] is directly loadable as a VS Code extension's [main] — no - hand-written [index.cjs], no vendored adapter. [adapter] is the - require() specifier for the adapter; when [no_lc] is set the extension - ships no language client, so the [vscode-languageclient/node] require - is skipped and [null] is passed in its place. *) + hand-written [index.cjs], no vendored adapter. + + The adapter source (packages/affine-vscode/mod.js) is embedded + inline at codegen time via the auto-generated + [Affine_vscode_adapter_source] module — eliminating the runtime + [require("@hyperpolymath/affine-vscode")] that used to fail when the + adapter package wasn't installed (e.g. before #104 publishes to npm + or in any smoke harness without the file:-link bridge). The + [adapter] parameter is now only a *fallback* require() specifier: + if the require succeeds it overrides the embedded adapter (this + preserves the upgrade-the-adapter-without-rebuilding-affinescript + escape hatch). [no_lc] omits the [vscode-languageclient/node] + require for extensions that ship no language client. *) let vscode_extension_wiring ~(adapter : string) ~(no_lc : bool) : string = let lc_arg = if no_lc then "null" @@ -133,17 +142,26 @@ let vscode_extension_wiring ~(adapter : string) ~(no_lc : bool) : string = // file is directly loadable as a VS Code extension's `main`. Replaces the // previously hand-written index.cjs + vendored adapter boilerplate. // -// Defensive load (issue #104): the adapter package may not yet be on npm -// when this .cjs is bundled into a smoke harness or distributed before the -// `affine-vscode-v*` publish-tag lands. Treat MODULE_NOT_FOUND as a soft -// failure — extraImports degrades to empty so activate()/deactivate() still -// resolve. Any other require error (syntax, transitive failure) is rethrown -// so real bugs are not masked. +// Self-contained (issue #139 / #104 root-cause fix): the +// affine-vscode adapter source is *embedded* at codegen time so the +// generated .cjs needs no npm dependency to instantiate. A +// require("%s") is still attempted first so an installed package can +// override the embedded copy; if the require fails for any reason +// (MODULE_NOT_FOUND, syntax, transitive failure) we fall back to the +// embedded adapter and the extension activates cleanly anyway. let _makeVscodeBindings = null; try { _makeVscodeBindings = require("%s"); } catch (_e) { - if (_e && _e.code !== "MODULE_NOT_FOUND") throw _e; + // Any require failure falls through to the embedded adapter below. +} +if (typeof _makeVscodeBindings !== "function") { + // Embedded adapter source (CJS-style module wrapper). + const _adapterModule = { exports: null }; + (function (module, exports) { +%s + })(_adapterModule, _adapterModule.exports); + _makeVscodeBindings = _adapterModule.exports; } exports.extraImports = function() { if (typeof _makeVscodeBindings !== "function") return {}; @@ -153,7 +171,11 @@ exports.extraImports = function() { exports, ); }; -|} (js_string_escape adapter) lc_arg +|} + (js_string_escape adapter) + (js_string_escape adapter) + Affine_vscode_adapter_source.source + lc_arg (** Wrap [m] in a Node-CJS shim. The shim is a single self-contained JavaScript string suitable for writing to a [.cjs] file. diff --git a/lib/dune b/lib/dune index 7fef36a0..e3e49431 100644 --- a/lib/dune +++ b/lib/dune @@ -1,8 +1,22 @@ +; AUTO-GENERATED module: `affine_vscode_adapter_source` exposes the +; in-tree `packages/affine-vscode/mod.js` adapter as an OCaml string +; constant so `--vscode-extension` codegen can embed it directly into +; the generated `.cjs` (eliminating the runtime `require()` of +; @hyperpolymath/affine-vscode, which used to fail when the adapter +; wasn't installed — see issues #104 / #139). +(rule + (target affine_vscode_adapter_source.ml) + (deps (file ../packages/affine-vscode/mod.js)) + (action + (with-stdout-to %{target} + (bash "echo '(* AUTO-GENERATED from packages/affine-vscode/mod.js. Do not edit. *)'; echo 'let source = {affine_vscode|'; cat %{dep:../packages/affine-vscode/mod.js}; echo '|affine_vscode}'")))) + (library (name affinescript) (public_name affinescript) (modes byte native) (modules + affine_vscode_adapter_source ast borrow c_codegen From f66aba816b2282b23e2a2ef83f106a1523f3a432 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 26 May 2026 12:33:20 +0100 Subject: [PATCH 2/3] test(codegen-embed): match require() wiring not bare specifier (fix false positives) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two E2E tests `--vscode-extension-adapter overrides the require specifier` and `--vscode-extension-no-lc skips the language client` were checking for the *absence* of bare substrings `@hyperpolymath/affine-vscode` and `vscode-languageclient/node`. Codegen-embed now inlines the adapter source (packages/affine-vscode/mod.js), which carries documentation comments showing example usage of the default specifier and a `vscode-languageclient/node` section delimiter. Those bare substrings legitimately appear inside the embedded source — but they are not require() wiring. The off-by-default test at line 2994 already uses the precise pattern `require("@hyperpolymath/affine-vscode")` for exactly this reason. Bringing the override + no-lc tests to the same precision restores their semantic intent: assert which require() fires, not which strings appear anywhere. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/test_e2e.ml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/test/test_e2e.ml b/test/test_e2e.ml index db1ebf72..ea185383 100644 --- a/test/test_e2e.ml +++ b/test/test_e2e.ml @@ -3014,14 +3014,24 @@ let test_vscode_extension_adapter_override () = ~vscode_extension_adapter:"../local/adapter.cjs" activate_src in Alcotest.(check bool) "uses the overridden adapter specifier" true (contains cjs {|require("../local/adapter.cjs")|}); - Alcotest.(check bool) "does not fall back to the default adapter" - false (contains cjs "@hyperpolymath/affine-vscode") + (* Match the require wiring, not the bare specifier — the embedded + adapter source (packages/affine-vscode/mod.js) carries documentation + comments that legitimately mention the default specifier as an + example usage; the override semantics is about which require() + fires, not which strings appear anywhere in the file. *) + Alcotest.(check bool) "does not fall back to the default adapter require" + false (contains cjs {|require("@hyperpolymath/affine-vscode")|}) let test_vscode_extension_no_lc () = let cjs = cjs_of ~vscode_extension:true ~vscode_extension_no_lc:true activate_src in + (* Match the require wiring, not the bare specifier — the embedded + adapter source includes a `vscode-languageclient/node` section + delimiter comment that is harmless because no actual require fires + unless the language-client argument is wired in (which `no_lc` skips + by passing `null`). *) Alcotest.(check bool) "skips the language-client require" - false (contains cjs "vscode-languageclient/node"); + false (contains cjs {|require("vscode-languageclient/node")|}); Alcotest.(check bool) "passes null in its place" true (contains cjs " null,\n"); Alcotest.(check bool) "still requires vscode + adapter" From 24add1c5171a72baf42512fbbf40582c8e9738fd Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 26 May 2026 14:01:10 +0100 Subject: [PATCH 3/3] ci: nudge after Actions outage