diff --git a/.github/workflows/platform-smoke.yml b/.github/workflows/platform-smoke.yml new file mode 100644 index 0000000..537b000 --- /dev/null +++ b/.github/workflows/platform-smoke.yml @@ -0,0 +1,63 @@ +name: Platform Package Smoke + +on: + pull_request: + paths: + - "package.json" + - "bun.lock" + - "tsconfig.json" + - "src/**" + - "scripts/native-deps-smoke.mjs" + - "scripts/smoke-test.mjs" + - "scripts/copy-web-assets.mjs" + - ".github/workflows/platform-smoke.yml" + workflow_dispatch: + +jobs: + package-smoke: + name: ${{ matrix.os }} / package smoke + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-13 + - macos-latest + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Install source dependencies + run: bun install + + - name: Build package artifacts + run: bun run build + + - name: Pack and install published package shape + shell: bash + run: | + set -euo pipefail + + pack_dir="$RUNNER_TEMP/opencode-mem-pack" + smoke_dir="$RUNNER_TEMP/opencode-mem-smoke" + mkdir -p "$pack_dir" "$smoke_dir" + + npm pack --pack-destination "$pack_dir" + tarball_path=$(find "$pack_dir" -name "opencode-mem-*.tgz" -print -quit) + test -n "$tarball_path" + + cd "$smoke_dir" + npm init -y + npm install --ignore-scripts "$tarball_path" + cp "$GITHUB_WORKSPACE/scripts/native-deps-smoke.mjs" ./native-deps-smoke.mjs + cp "$GITHUB_WORKSPACE/scripts/smoke-test.mjs" ./smoke-test.mjs + node ./native-deps-smoke.mjs + node ./smoke-test.mjs diff --git a/package.json b/package.json index c3ac42c..a2582a1 100644 --- a/package.json +++ b/package.json @@ -51,10 +51,16 @@ "@opencode-ai/sdk": "^1.3.0", "franc-min": "^6.2.0", "iso-639-3": "^3.0.1", - "usearch": "^2.21.4", "zod": "^4.3.6", "@huggingface/transformers": "^4.2.0" }, + "optionalDependencies": { + "usearch": "^2.21.4" + }, + "//": "Remove onnxruntime-node override after https://github.com/microsoft/onnxruntime/issues/27961 is resolved", + "overrides": { + "onnxruntime-node": "1.22.0" + }, "devDependencies": { "@types/bun": "^1.3.8", "husky": "^9.1.7", diff --git a/scripts/native-deps-smoke.mjs b/scripts/native-deps-smoke.mjs new file mode 100644 index 0000000..d527f98 --- /dev/null +++ b/scripts/native-deps-smoke.mjs @@ -0,0 +1,242 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { basename, join, relative, sep } from "node:path"; +import { pathToFileURL } from "node:url"; + +const requireFromHere = createRequire(import.meta.url); +const projectRoot = process.cwd(); +const lockfilePath = join(projectRoot, "package-lock.json"); +const artifactPattern = /(?:\.node|\.dll|\.dylib|\.so(?:\.\d+)*)$/; +const nativeScriptPattern = + /node-gyp|node-gyp-build|node-pre-gyp|prebuild|prebuild-install|cmake-js|install\/check|node\s+.*install/i; +const nativeDependencyPattern = + /node-gyp|node-gyp-build|node-pre-gyp|prebuild|prebuild-install|cmake-js|node-addon-api|detect-libc|napi-build-utils/i; +const platformPackagePattern = + /(^|[-_/])(win32|windows|linux|darwin|macos|x64|arm64|ia32|musl|glibc)([-_/]|$)/i; + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function getPackageNameFromLockPath(lockPath) { + const parts = lockPath.split("/"); + const nodeModulesIndex = parts.lastIndexOf("node_modules"); + if (nodeModulesIndex === -1) return null; + const first = parts[nodeModulesIndex + 1]; + if (!first) return null; + if (first.startsWith("@")) { + const second = parts[nodeModulesIndex + 2]; + return second ? `${first}/${second}` : null; + } + return first; +} + +function getLockPathForPackage(name) { + return `node_modules/${name}`; +} + +function isPlatformCompatible(pkg) { + return ( + matchesSelectorList(pkg.os, process.platform) && matchesSelectorList(pkg.cpu, process.arch) + ); +} + +function matchesSelectorList(selectors, current) { + if (!Array.isArray(selectors) || selectors.length === 0) return true; + const positives = selectors.filter((selector) => !selector.startsWith("!")); + const negatives = selectors + .filter((selector) => selector.startsWith("!")) + .map((selector) => selector.slice(1)); + if (negatives.includes(current)) return false; + return positives.length === 0 || positives.includes(current); +} + +function collectRuntimeClosure(lockfile) { + const packages = lockfile.packages ?? {}; + const root = packages[getLockPathForPackage("opencode-mem")]; + if (!root) { + throw new Error("Installed opencode-mem package not found in package-lock.json"); + } + + const queue = ["opencode-mem"]; + const seen = new Set(); + + while (queue.length > 0) { + const name = queue.shift(); + if (!name || seen.has(name)) continue; + seen.add(name); + + const entry = packages[getLockPathForPackage(name)]; + if (!entry) continue; + for (const dependencyName of Object.keys(entry.dependencies ?? {})) queue.push(dependencyName); + for (const dependencyName of Object.keys(entry.optionalDependencies ?? {})) + queue.push(dependencyName); + } + + return [...seen] + .map((name) => ({ + name, + lockPath: getLockPathForPackage(name), + lockEntry: packages[getLockPathForPackage(name)], + })) + .filter((entry) => entry.lockEntry); +} + +function findPackageDir(lockPath) { + const packageDir = join(projectRoot, ...lockPath.split("/")); + return existsSync(join(packageDir, "package.json")) ? packageDir : null; +} + +function discoverArtifacts(packageDir) { + const artifacts = []; + const stack = [packageDir]; + let visited = 0; + + while (stack.length > 0 && visited < 2000) { + const current = stack.pop(); + if (!current) continue; + visited += 1; + + for (const entry of readdirSync(current, { withFileTypes: true })) { + if (entry.name === ".git" || entry.name === "node_modules") continue; + const fullPath = join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + if (artifactPattern.test(entry.name)) { + artifacts.push(relative(packageDir, fullPath).split(sep).join("/")); + } + } + } + + return artifacts; +} + +function classifyNativeCandidate(pkg, packageDir, artifacts) { + const reasons = []; + const scripts = pkg.scripts ?? {}; + const dependencyNames = [ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.optionalDependencies ?? {}), + ...Object.keys(pkg.peerDependencies ?? {}), + ]; + + if (pkg.gypfile === true) reasons.push("package.json gypfile=true"); + if (pkg.binary) reasons.push("package.json binary field"); + if (pkg.napi) reasons.push("package.json napi field"); + for (const [scriptName, script] of Object.entries(scripts)) { + if ( + /^(preinstall|install|postinstall)$/.test(scriptName) && + nativeScriptPattern.test(String(script)) + ) { + reasons.push(`${scriptName} script references native tooling`); + } + } + if (dependencyNames.some((name) => nativeDependencyPattern.test(name))) { + reasons.push("depends on native build/prebuild tooling"); + } + if ( + Object.keys(pkg.optionalDependencies ?? {}).some((name) => platformPackagePattern.test(name)) + ) { + reasons.push("has platform-specific optional dependencies"); + } + if (platformPackagePattern.test(pkg.name ?? basename(packageDir))) { + reasons.push("package name is platform-specific"); + } + if (artifacts.length > 0) reasons.push("contains native binary artifacts"); + + return reasons; +} + +function isPlatformArtifactPackage(pkg, artifacts) { + return platformPackagePattern.test(pkg.name ?? "") && artifacts.length > 0; +} + +async function canLoadPackage(packageDir) { + try { + requireFromHere(packageDir); + return { ok: true, mode: "require" }; + } catch (error) { + if (!(error instanceof Error) || error.code !== "ERR_REQUIRE_ESM") { + return { ok: false, mode: "require", error }; + } + } + + try { + await import(pathToFileURL(packageDir).href); + return { ok: true, mode: "import" }; + } catch (error) { + return { ok: false, mode: "import", error }; + } +} + +function formatError(error) { + return error instanceof Error ? `${error.name}: ${error.message}` : String(error); +} + +const lockfile = readJson(lockfilePath); +const opencodeMemPkgPath = join(projectRoot, "node_modules", "opencode-mem", "package.json"); +const rootPkg = existsSync(opencodeMemPkgPath) ? readJson(opencodeMemPkgPath) : readJson(join(projectRoot, "package.json")); +const candidates = []; +const failures = []; + +for (const entry of collectRuntimeClosure(lockfile)) { + const packageDir = findPackageDir(entry.lockPath); + if (!packageDir) continue; + + const pkg = readJson(join(packageDir, "package.json")); + if (!isPlatformCompatible(pkg)) continue; + + const artifacts = discoverArtifacts(packageDir); + const reasons = classifyNativeCandidate(pkg, packageDir, artifacts); + if (reasons.length === 0) continue; + + candidates.push({ + name: pkg.name ?? getPackageNameFromLockPath(entry.lockPath), + packageDir, + pkg, + reasons, + artifacts, + }); +} + +if (candidates.length === 0) { + failures.push( + "No native dependency candidates were discovered in the opencode-mem runtime closure." + ); +} + +for (const candidate of candidates) { + const artifactOnly = isPlatformArtifactPackage(candidate.pkg, candidate.artifacts); + const load = artifactOnly + ? { ok: true, mode: "artifact-only" } + : await canLoadPackage(candidate.packageDir); + const artifactSummary = candidate.artifacts.length > 0 ? candidate.artifacts.join(", ") : "none"; + const relativeDir = relative(projectRoot, candidate.packageDir).split(sep).join("/"); + + const isOptional = !!( + rootPkg.optionalDependencies && rootPkg.optionalDependencies[candidate.name] + ); + + console.log( + `${candidate.name}: ${load.ok ? `loaded via ${load.mode}` : "load failed"}${isOptional && !load.ok ? " (allowed for optional)" : ""}; artifacts=${artifactSummary}; reasons=${candidate.reasons.join("; ")}` + ); + + if (candidate.artifacts.length === 0 && !load.ok) { + const msg = `${candidate.name} at ${relativeDir} has native markers but no native artifacts and could not load: ${formatError(load.error)}`; + if (isOptional) console.log(`[WARN] ${msg}`); + else failures.push(msg); + } + if (candidate.artifacts.length > 0 && !load.ok) { + const msg = `${candidate.name} at ${relativeDir} has native artifacts but could not load on ${process.platform}/${process.arch}: ${formatError(load.error)}`; + if (isOptional) console.log(`[WARN] ${msg}`); + else failures.push(msg); + } +} + +if (failures.length > 0) { + throw new Error(`Native dependency smoke failed:\n- ${failures.join("\n- ")}`); +} + +console.log(`native dependency smoke test passed (${candidates.length} native candidates checked)`); diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs new file mode 100644 index 0000000..c361da2 --- /dev/null +++ b/scripts/smoke-test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; + +const pluginModule = await import("opencode-mem"); +const tagsModule = await import("opencode-mem/tags"); + +assert.equal(typeof pluginModule.default, "object", "default export must be a plugin object"); +assert.equal(pluginModule.default.id, "opencode-mem", "plugin id must match package name"); +assert.equal(typeof pluginModule.default.server, "function", "plugin server must be callable"); + +assert.equal(typeof tagsModule.getTags, "function", "getTags export must be callable"); +assert.equal( + typeof tagsModule.getProjectTagInfo, + "function", + "getProjectTagInfo export must be callable" +); +assert.equal( + typeof tagsModule.getUserTagInfo, + "function", + "getUserTagInfo export must be callable" +); + +console.log("opencode-mem package smoke test passed");