diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94d89322..d79d54f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,13 +129,15 @@ jobs: export NODE_AUTH_TOKEN=${{ secrets.GITHUB_TOKEN }} PUBLISH_REGISTRY=https://npm.pkg.github.com node --experimental-strip-types scripts/publish-scoped.ts # The release claim, checked instead of assumed. Without this the job - # reported success while `@viztor/dsh-opencode*` sat four versions behind + # reported success while `@viztor/dsh-opencode-patch` sat four versions behind # the canonical package, because a publisher that was never registered # fails with the same E404 as a transient fault. - name: verify every published target run: | VER=$(node -p "require('./package.json').version") - pending="dsh-opencode-patch@$VER @viztor/dsh-opencode-patch@$VER @viztor/dsh-opencode@$VER" + # One line: scripts/check.ts reads it to prove this list equals what + # publish-scoped.ts publishes (scripts/scoped-targets.ts). + pending="dsh-opencode-patch@$VER @viztor/dsh-opencode-patch@$VER" # Polls, because a successful `npm publish` does not mean the version # is readable yet: npm answers "Your package is being processed and diff --git a/AGENTS.md b/AGENTS.md index 382d2acd..1fafe063 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,8 @@ A file's name does not decide whether it is public — its content does. Inciden Host bundle (`lib/index.mjs`), a thin `apply` barrel over small modules: -- `src/index.ts` · `src/identity.ts` · `src/lifecycle.ts` — the public barrel, the identity constants, and the `apply()` composition (one installer per side effect, in load-bearing order). +- `src/index.ts` · `src/identity.ts` · `src/lifecycle.ts` — the public barrel, the identity constants, and the `apply()` composition (one installer per side effect, in load-bearing order). The barrel is the host's contract and nothing more: `apply`, `Config`, `inject`, `name` and the config types. Everything else is internal, and `test/public-api.test.ts` pins the list, so widening it is a reviewed change. +- `test/support/src.ts` — the one door tests use into `src`: each name re-exported from the module that DEFINES it, never through the barrel, so a module move edits one file. `test/architecture.test.ts` enforces both halves — every name is declared in its target, and no test outside the named exemptions (`vi.mock` targets, fresh re-imports after `vi.resetModules()`, the architecture and public-API tests, the jsdom client tests, the `reset-process-state.ts` setup file) imports a `src` path, and no jsdom test loads the door. - `src/config.ts` · `src/config-values.ts` · `src/utils/guards.ts` — the schema and its defaults; dependency-free readers and type guards. The client bundle may import these, and it does — along with the contract and presentation modules (`usage-contract.ts`, `login-contract.ts`, `settings-copy.ts`, `settings-fields.ts`, `usage-ui.ts`, `go-limits.ts`). The rule is not a list of names: **the client may import a module that reaches nothing host-only** — no cordis context, no credential seam, no node built-in — and the bundle gate is what enforces it, by failing the build rather than by anyone remembering. An earlier version of this line named two files as the only ones allowed, which the tree had already outgrown. ## `src/utils/` — pure by construction, enforced by test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f571cf96..a9d07091 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,7 @@ The web profile wires this checkout with `link:`, so builds are picked up like t - **Restore globals.** Tests that touch `globalThis.fetch` or `process.env` must restore them in `afterEach` (`vi.unstubAllGlobals()`, `delete process.env.…`). - **Offline by construction.** `test/support/offline.ts` refuses every non-loopback request in the unit and mock suites. The catalog's background revalidation used to reach `models.opencode.ai` dozens of times per run, so a networked runner's results could depend on the vendor's live catalog. A case that means to fetch stubs `fetch` itself. - **No order dependence.** `test/support/reset-process-state.ts` clears the plugin's module-level stores (captured keys, session spend, the live catalog) after every test. A new module-level store gets a reset next to its siblings. `pnpm run test:shuffle` is how to check, and CI runs it. -- **Tests reach `src` through `test/support/src.ts`.** It re-exports, from each defining module, everything a test needs, so a module move or a barrel change edits one file instead of thirty. A new test imports from `./support/src.ts` (`../support/src.ts` under `test/mock` and `test/e2e`); add a missing name there rather than importing a `src` path. The exceptions are `vi.mock("../src/x.ts")` targets (and their `importOriginal` type), which must name the module; the architecture tests, whose subject is the `src` layout itself; and the jsdom (client) tests, which import the defining module directly, because `src.ts` re-exports the host graph (`node:sqlite`, pi, `lifecycle.ts`) and the next rule keeps that out of client tests. Importing the door loads the whole `src` graph, host and client, so a module that fails to load fails every converted file; a client-safe door, limited to what the bundle gate admits, is P2's to add if that cost shows. The remaining pre-existing direct imports are converted in P2. +- **Tests reach `src` through `test/support/src.ts`.** It re-exports, from each defining module, everything a test needs, so a module move or a barrel change edits one file instead of thirty. A new test imports from `./support/src.ts` (`../support/src.ts` under `test/mock` and `test/e2e`); add a missing name there rather than importing a `src` path. The exceptions are `vi.mock("../src/x.ts")` targets (and their `importOriginal` type), which must name the module; fresh-module re-imports after `vi.resetModules()`, whose subject is module identity; the architecture and public-API tests, whose subject is the `src` layout and the barrel; the jsdom (client) tests, which import the defining module directly, because `src.ts` re-exports the host graph (`node:sqlite`, pi, `lifecycle.ts`) and the next rule keeps that out of client tests; and the setup file `test/support/reset-process-state.ts`, which runs before the jsdom tests too. `test/architecture.test.ts` fails on a static or side-effect `src` import anywhere else, on a dynamic `import("../src/…")` in a file that never calls `vi.resetModules()`, and on a jsdom test that value-imports the door. Importing the door loads the whole `src` graph, host and client, so a module that fails to load fails every test that comes through it. - **Host-only fixtures stay out of shared helpers.** `test/test-helpers.ts` is imported by the jsdom (client) tests, so anything that needs `node:sqlite` or the filesystem belongs in `test/support/`, as `opencode-store-fixture.ts` does. - **A test for a fix fails without it.** Before committing a regression test, revert the fix locally and watch it fail. - **Meaningful coverage.** Every config field in the `SPECS` register (`src/settings-fields.ts`) needs both the on and off path where it has one; every passthrough claim needs a non-OpenCode URL test proving headers are untouched; helper functions exported from `usage-ui.ts` are asserted directly, not re-derived in the test. diff --git a/README.md b/README.md index 19b336b8..2fcf3e05 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ cd ~/.dsh/profiles/web npm install dsh-opencode-patch ``` -The package is `dsh-opencode-patch` under every name it is used by: the dependency you install, the bundle entry in your profile, and the row the host resolves. +The package is `dsh-opencode-patch` under every name it is used by: the dependency you install, the bundle entry in your profile, and the row the host resolves. It is also published as [`@viztor/dsh-opencode-patch`](https://www.npmjs.com/package/@viztor/dsh-opencode-patch); the retired name `@viztor/dsh-opencode` is no longer published, so an install still on it should move to either of those. **2. Enable the bundle** — add the package to the profile's `dsh.profile.bundles` array: @@ -423,6 +423,13 @@ These exist in the schema but render no control — each is a literal, a marker, --- +## Changed in 1.x + +- **A smaller public API.** The package's entry exports `apply`, `Config`, `inject` and `name` — the names the host reads — and the config types (`PluginConfig`, `ResolvedPluginConfig`, `ModelOverrideConfig`, `ModelRouteConfig`, `EndpointFamily`, `KeySourcePolicy`). Everything else it used to re-export is internal now. +- **`./src/*` is no longer an export.** `src/` was never in the published files, so the subpath resolved in the repository and nowhere else. +- **`internalRoutes` was removed.** The per-protocol routes always mount. A profile row that still carries `internalRoutes` validates and the key is ignored. +- **`@viztor/dsh-opencode` is no longer published.** Its last version's "Renamed to @viztor/dsh-opencode-patch" deprecation stands as the final message; install `dsh-opencode-patch` or `@viztor/dsh-opencode-patch` instead. + ## 📜 Compatibility & Verification **Verified on DeepSeek Harness `0.2.1` — both the `alpha` and `rc` channels (Node 24+)** diff --git a/README.zh-CN.md b/README.zh-CN.md index 6548dc7b..d023f588 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -82,7 +82,7 @@ cd ~/.dsh/profiles/web npm install dsh-opencode-patch ``` -包名在所有用到它的地方都是 `dsh-opencode-patch`:你安装的依赖、profile 里的 bundle 条目,以及宿主解析的那一行。 +包名在所有用到它的地方都是 `dsh-opencode-patch`:你安装的依赖、profile 里的 bundle 条目,以及宿主解析的那一行。它也以 [`@viztor/dsh-opencode-patch`](https://www.npmjs.com/package/@viztor/dsh-opencode-patch) 发布;旧名 `@viztor/dsh-opencode` 已不再发布,仍在使用它的安装请改用上述任一名称。 **2. 启用 bundle**——把该包加入 profile 的 `dsh.profile.bundles` 数组: @@ -412,6 +412,13 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 --- +## 1.x 变更 + +- **更小的公开 API。** 包入口只导出 `apply`、`Config`、`inject` 和 `name`(宿主读取的名称),以及配置类型(`PluginConfig`、`ResolvedPluginConfig`、`ModelOverrideConfig`、`ModelRouteConfig`、`EndpointFamily`、`KeySourcePolicy`)。此前额外重新导出的其余内容现在都是内部实现。 +- **`./src/*` 不再是导出路径。** `src/` 从未包含在发布文件中,所以这个子路径只在仓库内可解析,安装后并不存在。 +- **`internalRoutes` 已移除。** 按协议划分的内部路由总是会挂载。仍带有 `internalRoutes` 的 profile 配置行可以通过校验,该键会被忽略。 +- **`@viztor/dsh-opencode` 不再发布。** 其最后一个版本上的 "Renamed to @viztor/dsh-opencode-patch" 弃用提示即为最终信息;请改装 `dsh-opencode-patch` 或 `@viztor/dsh-opencode-patch`。 + ## 📜 兼容性与验证 **已在 DeepSeek Harness `0.2.1` 上验证 —— `alpha` 与 `rc` 两个通道(Node 24+)** diff --git a/cordis.patch.yml b/cordis.patch.yml index ca0ce89a..027aa346 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -47,8 +47,6 @@ # maxContextWindow: number optional ceiling clamping model context windows (e.g. 131072). # modelOverrides: object per-model overrides for contextWindow, maxTokens and metadata (default {}). # modelRoutes: object per-model wire routing: { : { api, baseURL } } (default {}). -# internalRoutes: boolean mount the internal per-protocol routes; off for a deployment that -# declares its own providers for the other protocols (default true). # debug: true|false log every streamed call that receives the header. # debugFile: path optional append target for stream debug JSONL; written only while debug is on. - insert: diff --git a/docs/credentials.md b/docs/credentials.md index bfec70fb..183d64aa 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -278,7 +278,7 @@ The two modules existed and were tested before anything called them. The wiring 2. `src/go-discovery.ts` — one more entry in the step map `firstResolved` takes, and **last in every policy**, because the user's own declaration wins: `opencodeStore: () => readFromStore(ctx, plane)`. It resolves the path with `opencodeDatabasePath({ channel: process.env.OPENCODE_CHANNEL, env: process.env, home: os.homedir() }, os.tmpdir())`, reads with `readOpencodeCredentials`, picks the entry whose `integrationId` matches the plane, and answers `undefined` for everything else — a missing file included. 3. `src/settings-fields.ts` — one `CARD_FIELDS` control, which is what makes the switch reachable. 4. **Which of the two mechanisms to use, now that both have been read.** `discoverGoConfig` walks `ctx.loader.entries()` and reads `entry.options.config`, but that is the PROVIDER REGISTRY — the rows that describe `opencode-go` and `opencode` — not this plugin's own configuration. This plugin's own config arrives as a VALUE: `fetch-patch.ts` receives `config: ResolvedPluginConfig` as a parameter, and `lifecycle.ts` is what passes it. So the new step should take the flag as an argument threaded from `lifecycle` rather than dig for it in the loader, and that means `resolveGoApiKey` needs one more parameter (it currently takes `ctx`, `targetProvider` and `policy`). -5. The config travels as VALUES rather than as a config object: `lifecycle.ts` resolves it with `resolveConfig` and passes fields down. `usage.ts:285` calls `resolveGoApiKey(...)` for the meter and `fetch-patch.ts:253` reads `config.keySource` directly for the headers, which is the precedent for how a field arrives. **The model-call path is now traced, and it is `fetch-patch.ts`**: the interceptor's Authorization fallback is what fills the `Bearer unused` sentinel the internal routes carry, so that is where a request's own key is resolved. The other one is `google-bridge.ts`'s `resolveApiKey`, the per-request resolver for the `opencode-google` route specifically — `responses-provider.ts` calls neither `resolveRoutedKey` nor `resolveApiKey`, as the grep said; it hands the bridge its inputs, and the flag travels that way. Both consult the store only after the captured key and the declared environment, so they agree with the meter. +5. The config travels as VALUES rather than as a config object: `lifecycle.ts` resolves it with `resolveConfig` and passes fields down. `usage.ts:285` calls `resolveGoApiKey(...)` for the meter and `fetch-patch.ts:253` reads `config.keySource` directly for the headers, which is the precedent for how a field arrives. **The model-call path is now traced, and it is `fetch-patch.ts`**: the interceptor's Authorization fallback is what fills the `Bearer unused` sentinel the internal routes carry, so that is where a request's own key is resolved. The other one is `google-bridge.ts`'s `resolveApiKey`, the per-request resolver for the `opencode-google` route specifically — `responses-provider.ts` calls neither `resolveRoutedKey` (since deleted in 1.x: it was exported and never called from `src/`) nor `resolveApiKey`, as the grep said; it hands the bridge its inputs, and the flag travels that way. Both consult the store only after the captured key and the declared environment, so they agree with the meter. 6. Tests: the step answers a key from a store this suite builds, answers `undefined` with the switch off, and still loses to a declared credential and to a captured one; the fetch patch fills its Authorization header from the store, per plane; and the bridge carries a stored login over the store. **One bug the wiring found, worth recording because the login reader masked it.** `google-bridge.ts` derived the plane with `String(route).startsWith("opencode-go")`, and `"opencode-google"` starts with `"opencode-go"` — so the Zen route was read as the Go one. `loginTokenReader` hid that by falling back to the other plane; an integration-id lookup does not, so the derivation is now an exact route comparison. diff --git a/docs/deep-dive.md b/docs/deep-dive.md index 34c301c9..fbd5184a 100644 --- a/docs/deep-dive.md +++ b/docs/deep-dive.md @@ -186,7 +186,7 @@ Behind the **Enable Composer Usage Meter** switch (on by default): ### 9. Key resolution per routed model & overage behavior -Different models may route to different accounts (a corporate Go subscription alongside a personal Zen key). `resolveRoutedKey(ctx, provider)` inspects the loaded Cordis rows for the `apiKeyEnv` / literal `apiKey` assigned to each route, and **the route decides which account is queried: not the shape of the key**. The prefix test that used to sit here was removed once it was measured: OpenCode issues `oc_sk_…` for Go credentials too, so the string says nothing about the route in use. The meter queries accordingly: +Different models may route to different accounts (a corporate Go subscription alongside a personal Zen key). `discoverGoConfig(ctx, provider)` reads the loaded Cordis row for the `apiKeyEnv` / literal `apiKey` assigned to the route, `resolveGoApiKey` resolves it under this plugin's own `keySource` setting, and **the route decides which account is queried: not the shape of the key**. The prefix test that used to sit here was removed once it was measured: OpenCode issues `oc_sk_…` for Go credentials too, so the string says nothing about the route in use. The meter queries accordingly: - OpenCode Go (`opencode-go`): fixed subscription quotas across three windows (5-hour rolling, weekly, monthly %). At 100% the gateway answers `GoUsageLimitError` (HTTP 402/429). Server-side overflow into Zen balance works only if that Go account has _Use balance_ enabled ([opencode.ai/workspace/go](https://opencode.ai/workspace/go)): a Zen key on a _separate_ account is never debited automatically. - OpenCode Zen (`opencode`): per-token pay-as-you-go against the account balance; no rolling windows. At $0.00 the gateway returns `HTTP 402 Insufficient account funds`: top up via the Console link in the popover. diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index 173018a2..5bd75629 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -21,7 +21,7 @@ Phases land as **stacked pull requests**. Each one is based on the previous phas | P0 | suite hardening, 12 defect fixes, this plan | in review | | P1a | `fix:` shipped breakage | in review | | P1b | `chore:` hygiene | in review | -| P2 | `feat:` a smaller public API | planned | +| P2 | `feat:` a smaller public API | in review | | P-T1a | `build:` pin the host | planned | | P-T1b | `test:` a harness that is the real host | planned | | P-H | `refactor:` one way to patch a host method | planned | diff --git a/package.json b/package.json index 5438b158..c7ac6f54 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "./client": { "default": "./lib/client.js" }, - "./src/*": "./src/*", "./package.json": "./package.json", "./cordis.patch.yml": "./cordis.patch.yml", "./locale/*.json": "./locale/*.json" diff --git a/scripts/check.ts b/scripts/check.ts index f1af2485..b417ffc5 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -24,6 +24,12 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { en as cardCopy } from "../src/settings-copy.ts"; +import { + exportsOutsideFiles, + publishListMismatch, + releasePendingTargets, +} from "./package-checks.ts"; +import { SCOPED_TARGETS } from "./scoped-targets.ts"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const failures: string[] = []; @@ -458,6 +464,43 @@ if (releaseYml.includes("scripts/publish-scoped.ts")) { ); } +/* ----------------- 10b. what exports names ships, and what ships is checked */ + +// An `exports` target outside `files` resolves in this repository and nowhere +// else: `./src/*` was exported for releases while `src/` was never packed. +const outside = exportsOutsideFiles(pkg); +if (outside.length === 0) { + ok("every exports target is inside files"); +} else { + for (const target of outside) { + fail( + `exports names ${target}, which files does not pack; a consumer resolving ` + + `${target} from the tarball gets ENOENT` + ); + } +} + +// The verify step polls its own list. A name publish-scoped.ts publishes and +// the step never polls goes unchecked; a name it polls and nothing publishes +// fails every release. +const pending = releasePendingTargets(releaseYml); +if (pending === undefined) { + fail( + 'release.yml must carry exactly one pending="…" verify line (none, or more than one, found); the publish list cannot be checked' + ); +} else { + const mismatch = publishListMismatch([pkg.name, ...SCOPED_TARGETS], pending); + if (mismatch.length === 0) { + ok( + `release.yml verifies exactly what is published (${pending.join(", ")})` + ); + } else { + for (const message of mismatch) { + fail(message); + } + } +} + /* ------------------------------------------ 11. the client stays lean */ /* diff --git a/scripts/package-checks.ts b/scripts/package-checks.ts new file mode 100644 index 00000000..acabdce5 --- /dev/null +++ b/scripts/package-checks.ts @@ -0,0 +1,140 @@ +/** + * Pure checks over the package's publish shape, shared by `scripts/check.ts` + * and `test/package-checks.test.ts`. + * + * Every function here is a function of its arguments — the caller reads + * `package.json` and `release.yml`, and these decide — so the test can prove + * each answer on a fixture whose answer is known before it trusts the answer + * on the real tree. + * + * @module scripts/package-checks + */ + +/** + * Every string target of an `exports` value, in declaration order: a bare + * string, a subpath map and nested condition objects all flatten to strings. + * + * @param exports - `package.json`'s `exports`, as parsed. + * @returns the targets, such as `./lib/index.mjs`. + */ +export const exportTargets = (exports: unknown): string[] => { + if (typeof exports === "string") { + return [exports]; + } + if (exports === null || typeof exports !== "object") { + return []; + } + return Object.values(exports).flatMap((value: unknown) => + exportTargets(value) + ); +}; + +/** A `files` glob as a whole-path pattern: `*` stays inside one segment. */ +const globPattern = (entry: string): RegExp => + new RegExp( + `^${entry + .split("*") + .map((part) => part.replaceAll(/[.+?^${}()|[\]\\]/gu, String.raw`\$&`)) + .join("[^/]*")}$`, + "u" + ); + +/** + * Whether npm packs `target` under this `files` allowlist. + * + * Deliberately no more of npm than the package uses: an entry equal to the + * target, a directory entry that is a prefix of it (`lib` covers + * `lib/index.mjs`), and a glob entry (`*` within one segment) that matches it — + * or equals it, for an `exports` subpath pattern such as `./locale/*.json`. + * `package.json` is always packed. A target with a `*` that no glob entry + * equals is outside: a subpath pattern resolves to files the allowlist has to + * name the same way. + * + * @param target - an `exports` target, usually `./`-relative. + * @param files - `package.json`'s `files`. + * @returns `true` when the tarball carries the target. + */ +export const coveredByFiles = ( + target: string, + files: readonly string[] +): boolean => { + const path = target.replace(/^\.\//u, ""); + if (path === "package.json") { + return true; + } + return files.some((raw) => { + const entry = raw.replace(/^\.\//u, "").replace(/\/$/u, ""); + if (entry === path) { + return true; + } + if (entry.includes("*")) { + return !path.includes("*") && globPattern(entry).test(path); + } + return !path.includes("*") && path.startsWith(`${entry}/`); + }); +}; + +/** + * The `exports` targets the published tarball does not carry. Each one + * resolves in this repository and fails with ENOENT in every install. + * + * @param pkg - the parsed `package.json`. + * @returns the uncovered targets, in `exports` order. + */ +export const exportsOutsideFiles = (pkg: { + readonly exports?: unknown; + readonly files?: readonly string[]; +}): string[] => + exportTargets(pkg.exports).filter( + (target) => !coveredByFiles(target, pkg.files ?? []) + ); + +/** + * The package names release.yml's verify step polls, read off its + * `pending="name@$VER …"` line. + * + * @param releaseYml - the workflow's text. + * @returns the names with `@$VER` stripped, or `undefined` unless there is + * exactly one such line — absent, or ambiguous because a second list + * elsewhere in the workflow could be the one read. The caller must treat + * `undefined` as a failure, never as an empty list that agrees with nothing. + */ +export const releasePendingTargets = ( + releaseYml: string +): string[] | undefined => { + const lines = [...releaseYml.matchAll(/^\s*pending="([^"]*)"/gmu)]; + const line = lines.length === 1 ? lines[0]?.[1] : undefined; + if (line === undefined) { + return undefined; + } + return line + .split(/\s+/u) + .filter((entry) => entry !== "") + .map((entry) => entry.replace(/@\$VER$/u, "")); +}; + +/** + * Where the published list and the verified list disagree, one message per + * name that is in one and not the other. + * + * @param published - what the release publishes. + * @param verified - what the verify step polls for. + * @returns the messages; empty when the lists hold the same names. + */ +export const publishListMismatch = ( + published: readonly string[], + verified: readonly string[] +): string[] => [ + ...published + .filter((name) => !verified.includes(name)) + .map( + (name) => + `${name} is published but release.yml's verify step never checks it` + ), + ...verified + .filter((name) => !published.includes(name)) + .map( + (name) => + `${name} is verified by release.yml but nothing publishes it, so every release fails on it` + ), +]; diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts index 23e4ab15..6a3a0168 100644 --- a/scripts/publish-scoped.ts +++ b/scripts/publish-scoped.ts @@ -4,16 +4,16 @@ * The package ships as `dsh-opencode-patch` — the unscoped name DSH resolves, * the name the docs use, the name release-please versions. * - * It also publishes under: - * - `@viztor/dsh-opencode-patch` — the CANONICAL scoped name. Same tree, same - * bundle, same version as the unscoped one; this is the name new installs - * should use, and the one the client bundle registers its card under - * (`SCOPED_PKG`). - * - `@viztor/dsh-opencode` — RETIRED. A deprecated thin wrapper that ships no - * `lib/client.js` and forwards its row to `dsh-opencode-patch`. It is kept - * publishing only so an existing install keeps resolving; nothing in the - * source tree registers or advertises it any more, which is what stops one - * package answering to two spellings. + * It also publishes under `@viztor/dsh-opencode-patch` — the CANONICAL scoped + * name. Same tree, same bundle, same version as the unscoped one; this is the + * name new installs should use, and the one the client bundle registers its + * card under (`SCOPED_PKG`). The list is `SCOPED_TARGETS` + * (`scripts/scoped-targets.ts`), which `scripts/check.ts` holds in step with + * release.yml's verify step. + * + * The retired name `@viztor/dsh-opencode` is no longer published: its last + * version's "Renamed to @viztor/dsh-opencode-patch" deprecation stands as the + * final message on the registry. * * Usage: node --experimental-strip-types scripts/publish-scoped.ts * PUBLISH_DRY_RUN=1 node --experimental-strip-types scripts/publish-scoped.ts @@ -21,8 +21,7 @@ * * `PUBLISH_DRY_RUN=1` builds every scratch tree, prints the exact manifest each * alias would publish, and stops before the first network call. The per-alias - * transformations — the thin wrapper in particular — are otherwise only - * observable after a real release. + * transformations are otherwise only observable after a real release. */ import { execFileSync } from "node:child_process"; @@ -37,10 +36,9 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; -import { writeWrapperEntries } from "./wrapper-forwarder.ts"; +import { SCOPED_TARGETS } from "./scoped-targets.ts"; const ROOT = path.resolve(import.meta.dirname, ".."); -const SCOPED_TARGETS = ["@viztor/dsh-opencode-patch", "@viztor/dsh-opencode"]; const pkgRaw: unknown = JSON.parse( readFileSync(path.join(ROOT, "package.json"), "utf-8") @@ -121,73 +119,6 @@ for (const target of SCOPED_TARGETS) { 'name: "@viztor/dsh-opencode-patch"' ) ); - } else if (target === "@viztor/dsh-opencode") { - // Point at the CANONICAL name, not the other alias: the message a user - // reads on the registry is the one place that should not offer a choice - // between two spellings of the same package. - manifest.deprecated = - "Renamed to @viztor/dsh-opencode-patch. Please install that instead: https://www.npmjs.com/package/@viztor/dsh-opencode-patch"; - const existingDeps = - (manifest.dependencies as Record | undefined) ?? {}; - manifest.dependencies = { - ...existingDeps, - "dsh-opencode-patch": `^${version}`, - }; - - // Thin wrapper entrypoints: `export *` only — the barrel has no default - // export, and re-exporting one is a link error on every host load. - writeWrapperEntries(path.join(scratch, "lib")); - - // The wrapper carries no client half of its own. - // - // Its patch forwards to the `dsh-opencode-patch` row, and that row's own - // package supplies the browser bundle. Shipping a second copy here — the - // manifest's whole-`lib` `files` entry copies it in — would hand the page - // the same bundle twice under the same module ids, mounting the client - // half twice. A ~38 kB duplicate in a package whose stated job is to - // forward two lines is also simply not thin. - const dsh = manifest.dsh as Record | undefined; - if (dsh !== undefined) delete dsh.client; - const wrapperExports = manifest.exports as - | Record - | undefined; - if (wrapperExports !== undefined) delete wrapperExports["./client"]; - rmSync(path.join(scratch, "lib", "client.js"), { force: true }); - - // Forwarding cordis patch to mount dsh-opencode-patch - const forwarderPatch = [ - "# Thin wrapper patch forwarding to dsh-opencode-patch", - "- insert:", - " - id: dsh-opencode-patch", - ' name: "dsh-opencode-patch"', - ].join("\n"); - writeFileSync( - path.join(scratch, "cordis.patch.yml"), - `${forwarderPatch}\n` - ); - - // The page leads with the CANONICAL name. A retired name may appear as the - // reason the reader is here — never as the package's own title, which is - // what made three npm pages look like three packages. - const redirectReadme = [ - "# dsh-opencode-patch", - "", - "> ⚠️ **This npm name is retired.** The package is `dsh-opencode-patch`,", - "> scoped as [`@viztor/dsh-opencode-patch`](https://www.npmjs.com/package/@viztor/dsh-opencode-patch).", - "", - "You are on the page for the old name `@viztor/dsh-opencode`. It is a **thin compatibility", - "wrapper**: it depends on and re-exports `dsh-opencode-patch` and ships no code of its own.", - "", - "### How to migrate", - "", - "```sh", - '# DSH Web UI (recommended): Settings → Plugins → Install Plugin → "dsh-opencode-patch"', - "", - "# Or from your profile directory:", - "npm install dsh-opencode-patch", - "```", - ].join("\n"); - writeFileSync(path.join(scratch, "README.md"), redirectReadme); } writeFileSync( path.join(scratch, "package.json"), diff --git a/scripts/scoped-targets.ts b/scripts/scoped-targets.ts new file mode 100644 index 00000000..484c7300 --- /dev/null +++ b/scripts/scoped-targets.ts @@ -0,0 +1,15 @@ +/** + * The scoped aliases `scripts/publish-scoped.ts` publishes the package under. + * + * Release.yml's verify step polls the same names on its `pending="…"` line, and + * `scripts/check.ts` fails when the two lists disagree. The list lives apart + * from `publish-scoped.ts` because that script publishes at import time, so the + * check and its test could not read the list from there. + * + * `@viztor/dsh-opencode` is no longer published (retired in 1.x, D9): its last + * version's "Renamed to @viztor/dsh-opencode-patch" deprecation stands as the + * final message on the registry. + * + * @module scripts/scoped-targets + */ +export const SCOPED_TARGETS: readonly string[] = ["@viztor/dsh-opencode-patch"]; diff --git a/scripts/wrapper-forwarder.ts b/scripts/wrapper-forwarder.ts deleted file mode 100644 index 627f241a..00000000 --- a/scripts/wrapper-forwarder.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * The entry the deprecated `@viztor/dsh-opencode` wrapper ships. - * - * Both its `lib/index.mjs` and its `lib/index.d.mts` are exactly these bytes: - * `export *` from the canonical package and nothing else. The barrel - * (`src/index.ts`) has named exports only, so a `default` re-export is an ESM - * link error the moment the host loads the wrapper — `SyntaxError: The - * requested module 'dsh-opencode-patch' does not provide an export named - * 'default'`. The text and the write both live here, so - * `test/wrapper-forwarder.test.ts` links what `publish-scoped.ts` writes by - * calling the same function, instead of linking a copy of it. - * - * @module scripts/wrapper-forwarder - */ -import { writeFileSync } from "node:fs"; -import path from "node:path"; - -export const WRAPPER_FORWARDER = 'export * from "dsh-opencode-patch";\n'; - -/** - * Write the wrapper's two entry files into a `lib/` directory. - * - * @param libDir - the wrapper package's `lib/` directory. - */ -export const writeWrapperEntries = (libDir: string): void => { - writeFileSync(path.join(libDir, "index.mjs"), WRAPPER_FORWARDER); - writeFileSync(path.join(libDir, "index.d.mts"), WRAPPER_FORWARDER); -}; diff --git a/src/config.ts b/src/config.ts index e7ac0704..fca8e03b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -138,7 +138,6 @@ export const CONFIG_DEFAULTS = { maxContextWindow: undefined as number | undefined, modelOverrides: {} as Readonly>, modelRoutes: {} as Readonly>, - internalRoutes: true, originClient: DEFAULT_ORIGIN_CLIENT, providers: DEFAULT_PROVIDERS, readOpencodeStore: false, @@ -169,22 +168,9 @@ export interface PluginConfig { * * This is the OVERRIDE layer over what the shim and its live refresh computed. * A model whose protocol the catalog puts on an internal route can be pinned - * here instead, and a curated deployment that declares its own providers for - * the other protocols can switch {@link internalRoutes} off entirely. + * here instead. */ modelRoutes?: Record; - /** - * Whether to mount the internal per-protocol routes. - * - * On (default) the plugin registers a route for every protocol a plane serves, - * so a declared model that speaks Anthropic or Google is reachable with no - * configuration. Each mounted route is a provider whose model list is the - * WHOLE protocol catalog — the price of not being able to see the deployment's - * own selection from inside a plugin. A deployment that writes its own provider - * entries for those protocols turns this off and declares `modelRoutes` for the - * models that need it. - */ - internalRoutes?: boolean; originClient?: string; providers?: string[]; readOpencodeStore?: boolean; @@ -235,8 +221,6 @@ export interface ResolvedPluginConfig { readonly modelOverrides: Readonly>; /** The per-model protocol/base overrides, keyed by model id. */ readonly modelRoutes: Readonly>; - /** Whether the internal per-protocol routes are mounted. */ - readonly internalRoutes: boolean; originClient: string; providers: Set; /** @@ -429,10 +413,6 @@ export const resolveConfig = ( maxContextWindow: readPositiveInteger(config.maxContextWindow), modelOverrides: resolveModelOverrides(config.modelOverrides), modelRoutes: resolveModelRoutes(config.modelRoutes), - internalRoutes: readBoolean( - config.internalRoutes, - CONFIG_DEFAULTS.internalRoutes - ), originClient: readString(config.originClient) ?? CONFIG_DEFAULTS.originClient, providers: new Set( @@ -595,14 +575,6 @@ export const Config = z.object({ "Per-model wire routing overrides: { : { api, baseURL } }. The api " + "names a pi protocol; baseURL replaces the catalog's endpoint." ), - internalRoutes: z - .boolean() - .default(CONFIG_DEFAULTS.internalRoutes) - .volatile() - .description( - "Mount the internal per-protocol routes. Off for a deployment that " + - "declares its own providers for the other protocols." - ), usageBaseURL: z .string() .default(CONFIG_DEFAULTS.usageBaseURL) diff --git a/src/go-discovery.ts b/src/go-discovery.ts index 06af45fa..f44f7f08 100644 --- a/src/go-discovery.ts +++ b/src/go-discovery.ts @@ -534,77 +534,6 @@ export const resolveZenCreditInfo = async ( return { isConfigured: zenKey !== undefined }; }; -/** Key resolution details for a routed provider. */ -export interface RoutedKeyDetails { - key?: string; - keyPrefix?: string; - provider: string; - tier: "go" | "zen" | "unknown"; -} - -/** - * Resolve the effective API key and account tier for a currently routed provider. - * - * The routed provider *is* the tier, so a Zen route legitimately resolves a - * Zen key — and so does {@link resolveGoApiKey}, which does not reject one - * either. An earlier guard there refused a key starting `oc_sk_` on the belief - * that the prefix meant "Zen, no Go entitlement"; the same prefix answers `200` - * with real windows, and the guard was removed. What separates the tiers is the - * observed request, never a string. - * - * @param ctx - plugin context - * @param provider - routed provider name (e.g. "opencode-go", "opencode") - * @param policy - which source wins when several resolve. - * @param readOpencodeStore - whether the installation's own store may be read - * as the last resort. Off by default. - */ -export const resolveRoutedKey = async ( - ctx: unknown, - provider: string, - policy: KeySourcePolicy = "auto", - readOpencodeStore = false -): Promise => { - const discovered = discoverGoConfig(ctx, provider); - const isGoRoute = provider === "opencode-go"; - const configuredRef = - discovered.keyEnv ?? - (isGoRoute ? DEFAULT_USAGE_KEY_ENV : "OPENCODE_API_KEY"); - - const key = await firstResolved(KEY_SOURCE_ORDER[policy], { - captured: () => getCapturedApiKey(provider, isGoRoute ? "go" : undefined), - capturedAny: () => getCapturedApiKey(undefined, isGoRoute ? "go" : "zen"), - configured: () => resolveRef(ctx, configuredRef), - literal: () => discovered.literalKey, - opencodeStore: () => - resolveOpencodeStoreKey( - isGoRoute ? "opencode-go" : "opencode", - readOpencodeStore - ), - }); - - // The routed provider decides, and the key's prefix never does: OpenCode - // issues `oc_sk_…` for Go credentials too (see `tierForRequest`). - let tier: "go" | "zen" | "unknown" = "unknown"; - if (isGoRoute) { - tier = "go"; - } else if (provider === "opencode") { - tier = "zen"; - } - - // The floor matches the slice, so an 8- or 9-character key is not reported - // whole: a "prefix" that happens to be the entire secret leaks more than the - // eight characters it was supposed to. - const keyPrefix = - key !== undefined && key.length >= 10 ? key.slice(0, 10) : undefined; - - return { - key, - keyPrefix, - provider, - tier, - }; -}; - /** One model entry as the operator declared it in a provider row. */ export interface DeclaredModel { readonly id: string; diff --git a/src/identity.ts b/src/identity.ts index 883d4bc8..6dee1a74 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -2,7 +2,11 @@ * Component and package identity. * * The same plugin answers to several names, and they are not interchangeable: - * see `AGENTS.md` ("Package vs component") for the full mapping. Keeping the + * `name` is the component id (and the unscoped npm name), `LEGACY_NAME` and + * `LEGACY_PKG` are the pre-rename ids an old row may still use, and + * `SCOPED_PKG` in `settings-page.tsx` is the scoped npm name the settings card + * registers under. README "Changed in 1.x" records the retired + * `@viztor/dsh-opencode` wrapper. Keeping the * literals here — rather than scattered across `index.ts`, `lifecycle.ts` and * the client bundle — makes the one place that must agree with * `package.json`/`cordis.patch.yml` obvious. diff --git a/src/index.ts b/src/index.ts index ac2befc1..a6a663f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,215 +1,25 @@ /** * `dsh-opencode-patch` — host plugin entry (public barrel). * - * This module is intentionally thin: it re-exports identity, the `apply()` - * lifecycle, and the public surface of every module. Each concern lives in - * the module that owns it: + * This is the package's contract with the host and nothing more: `apply`, + * `Config`, `inject` and `name` — the four names a cordis host reads off a + * plugin — and the config types a user of `Config` needs to name. Every other + * module is internal; `test/public-api.test.ts` pins this list, so widening it + * is a reviewed change rather than a side effect. * - * | module | concern | - * | ------------------ | ------------------------------------------------ | - * | `identity.ts` | component/package name constants + `inject` | - * | `lifecycle.ts` | `apply()` and its per-side-effect installers | - * | `guards.ts` | structural `unknown` predicates | - * | `config-values.ts` | host/client-shared config coercion readers | - * | `config.ts` | defaults, types, resolver, settings schema | - * | `session.ts` | deterministic `ses_…` session identity | - * | `turn-store.ts` | ALS turn state carried across a streamed turn | - * | `tool-fallback.ts` | free-tier `/responses` read+bash schema fallback | - * | `fetch-patch.ts` | gateway request recognition + header fixes | - * | `stream-hook.ts` | `llm/stream` turn capture | - * | `cordis-context.ts`| structural view of the Cordis host context | - * | `go-discovery.ts` | Go credential/gateway discovery + resolution | - * | `usage.ts` | Go quota service + typert remote registration | - * | `usage-contract.ts`| remote shapes, validation, typert descriptor | - * | `session-cost.ts` | per-turn token/dollar accounting | - * | `catalog-data.ts` | the static Go/Zen model catalog (pure data) | - * | `models-catalog.ts`| catalog parsing, SWR refresh, `/models` enrich | - * | `models-discovery.ts` | provider-aware model-discovery decoration | - * | `debug.ts` | JSONL stream-debug append log | + * Tests reach the internals through `test/support/src.ts`, which re-exports + * each name from the module that defines it, never through this file. * * @module dsh-opencode-patch */ -// ── identity & lifecycle ──────────────────────────────────────────────────── -export { - inject, - LEGACY_NAME, - LEGACY_PKG, - name, - OPENCODE_CLI_VERSION, - OPENCODE_PATCH_USER_AGENT, - OPENCODE_USER_AGENT, -} from "./identity.ts"; +export { inject, name } from "./identity.ts"; export { apply } from "./lifecycle.ts"; - -// ── configuration ─────────────────────────────────────────────────────────── export { - CONFIG_DEFAULTS, Config, - DEFAULT_ENDPOINT_FAMILY, - DEFAULT_FREE_MODEL_MARKER, - DEFAULT_GATEWAY_URLS, - DEFAULT_INJECT_PROJECT, - DEFAULT_KEY_SOURCE, - DEFAULT_ORIGIN_CLIENT, - DEFAULT_PROVIDERS, - DEFAULT_SESSION_ID_ENV, - DEFAULT_USAGE_BASE_URL, - DEFAULT_USAGE_KEY_ENV, - ENDPOINT_FAMILIES, - KEY_SOURCE_POLICIES, - resolveConfig, - type EndpointFamily, - type KeySourcePolicy, type ModelOverrideConfig, + type ModelRouteConfig, type PluginConfig, type ResolvedPluginConfig, } from "./config.ts"; -export { - ALL_MODELS_MARKER, - readBoolean, - readString, - readStringList, - unwrapNode, -} from "./config-values.ts"; - -// ── session identity & turn state ─────────────────────────────────────────── -export { - fallbackSessionId, - isOpenCodeMessageId, - isOpenCodeSessionId, - headerValueFor, - newOpenCodeMessageId, - openCodeSessionIdFor, - OPENCODE_UA, - pinnedSessionId, - sessionIdForRequest, - PARENT_SESSION_ALT_HEADER, - PARENT_SESSION_HEADER, - SESSION_AFFINITY_HEADER, - SESSION_HEADER, -} from "./session.ts"; -export { type ActiveTurnState, withStore } from "./turn-store.ts"; - -// ── request patching ──────────────────────────────────────────────────────── -export { - DUMMY_BASH_TOOL, - DUMMY_READ_TOOL, - isCoreToolModel, - maybeInjectCoreTools, - RESPONSES_PATH, -} from "./tool-fallback.ts"; -export { - hasSessionHeader, - isOpenCodeRequest, - patchFetch, -} from "./fetch-patch.ts"; -export { type CordisContext } from "./cordis-context.ts"; - -// ── Go discovery & quota ──────────────────────────────────────────────────── -export { - clearCapturedApiKeys, - extractApiKeyFromHeaders, - getCapturedApiKey, - isPlaceholderApiKey, - recordCapturedApiKey, - tierForRequest, -} from "./key-capture.ts"; -export { - discoverGoConfig, - effectiveGoKeyRef, - resolveGoApiKey, - resolveGoBaseURL, - resolveOpencodeStoreKey, - resolveRoutedKey, - resolveZenCreditInfo, - toGoBaseURL, - type RoutedKeyDetails, -} from "./go-discovery.ts"; -export { GoUsageService } from "./usage.ts"; -export { - parseGoUsage, - parseUsageQuery, - type GoUsage, - type UsageQuery, - type UsageWindow, -} from "./usage-contract.ts"; -export { - calculateTurnCost, - clearSessionUsageStore, - describeModel, - formatModelRate, - formatUsd, - getSessionUsage, - recordTurnUsage, - type ModelCostRate, - type SessionUsageSnapshot, -} from "./session-cost.ts"; - -// ── model catalog & discovery ─────────────────────────────────────────────── -export { - CATALOG_REVALIDATION_TTL_MS, - DSH_SUPPORTED_MODALITIES, - MODELS_DEV_TIMEOUT_MS, - MODELS_DEV_URL, - MODELS_OPENCODE_URL, - OPENCODE_GO_CATALOG, - OPENCODE_ZEN_CATALOG, - enrichModelsResponse, - catalogPlaneForRoute, - findModelSpec, - findModelSpecOn, - getLiveCatalog, - getLiveGoCatalog, - getLiveZenCatalog, - isDeprecatedModelOn, - isGoModelsListingUrl, - isModelsListingUrl, - parseModelsDevCatalog, - refreshCatalog, - sanitizeModalities, - type CatalogModelSpec, - type CatalogProvider, - type ParsedCatalogs, -} from "./models-catalog.ts"; -export { - decorateModelDiscovery, - decorateModelInfo, - decoratePlaneAdapters, - hideResponsesRoute, - isRouteRegistered, - mergeDiscoveredModels, - resolveDiscoveryProvider, - resolveEffectiveContextWindow, - type DiscoveryCandidate, -} from "./models-discovery.ts"; -export { - ANTHROPIC_ROUTE, - ANTHROPIC_SDK, - GOOGLE_INTERNAL_ROUTE, - GOOGLE_SDK, - MISTRAL_SDK, - SELF_MOUNTED_ROUTES, - INTERNAL_ROUTES, - PLANES, - PROTOCOL_FOR_SDK, - RESPONSES_ROUTE, - RESPONSES_SDK, - GO_RESPONSES_ROUTE, - GO_ANTHROPIC_ROUTE, - ROUTE_FOR_PLANE_PROTOCOL, - ROUTE_FOR_PROTOCOL, - UNSERVED_SDKS, - internalRouteFor, - isInternalRoute, - isServableSdk, -} from "./responses-routes.ts"; -export { - inheritedCredentialRef, - loadPiAi, - modelsForSdk, - narrowToDeclared, - undescribedDeclaredModels, - unroutedDeclaredModels, - registerResponsesProvider, -} from "./responses-provider.ts"; +export { type EndpointFamily, type KeySourcePolicy } from "./config-values.ts"; diff --git a/src/key-capture.ts b/src/key-capture.ts index 0d9dd0da..59512469 100644 --- a/src/key-capture.ts +++ b/src/key-capture.ts @@ -212,9 +212,9 @@ export const recordCapturedApiKey = ( if (key.length === 0 || isPlaceholderApiKey(key)) { return; } - // Deliberately NOT `resolveRoutedKey`'s precedence: a key seen on a live - // request is classified by the request that carried it, not by the route the - // user named. See `tierForRequest`. + // Deliberately NOT the meter's route-first precedence (`resolveGoApiKey`): a + // key seen on a live request is classified by the request that carried it, + // not by the route the user named. See `tierForRequest`. const tier = tierForRequest(url, provider); if (typeof provider === "string" && provider.length > 0) { diff --git a/src/responses-provider.ts b/src/responses-provider.ts index 35119a82..a4aa707f 100644 --- a/src/responses-provider.ts +++ b/src/responses-provider.ts @@ -764,10 +764,12 @@ const mountScope = ( /** * Declared models that speak a protocol nothing in this composition serves. * - * The case this exists for: `internalRoutes: false` turns the automatic mount off, - * and a declared model whose protocol differs from its provider's then has no - * route to answer for it. Silence would be the worst outcome — the model is in the - * picker and every call fails — so the caller logs these by name and protocol. + * The case this exists for: the automatic mount stood down (no `llm-pi-ai` in + * this composition, or the early exit with nothing left to mount, where a + * route that did not mount is still missing), so a declared model whose + * protocol differs from its provider's has no route to answer for it. Silence would + * be the worst outcome — the model is in the picker and every call fails — so + * the caller logs these by name and protocol. * * A model the catalog does not describe is skipped: it cannot be known to need * anything else, and guessing would produce a warning that is wrong more often @@ -1047,7 +1049,7 @@ export const registerResponsesProvider = async ( console.warn( `[dsh-opencode-patch] ${gaps.length} declared model(s) speak a protocol no ` + `route serves: ${gaps.join(", ")} — declare provider routes for them ` + - "(see modelRoutes), or set internalRoutes: true" + "(see modelRoutes)" ); }; // A route the deployment declared is served by that declaration. @@ -1059,17 +1061,6 @@ export const registerResponsesProvider = async ( } } - // A curated deployment can turn the mount off: every mounted route is a - // provider whose model list is the protocol's catalog, which is how models - // nobody selected reach a picker. Such a deployment declares its own provider - // entries (and `modelRoutes` for the ids that need them) instead. - if (config?.internalRoutes === false) { - ctx.logger?.info?.( - "[dsh-opencode-patch] internal routes are off (internalRoutes: false); a declared provider must serve each protocol" - ); - reportGaps(); - return bridgeDispose; - } const wanted = Object.entries(ROUTE_FOR_PLANE_PROTOCOL).flatMap( ([plane, entry]) => Object.entries(entry.routes) diff --git a/src/settings-fields.ts b/src/settings-fields.ts index 78248c82..f21e9ad1 100644 --- a/src/settings-fields.ts +++ b/src/settings-fields.ts @@ -105,12 +105,6 @@ export const CONFIG_ONLY_FIELDS: readonly { "per-model wire routing (api and baseURL); it overrides what the catalog " + "computed, which is a deployment concern rather than a card control", }, - { - field: "internalRoutes", - reason: - "whether to mount the per-protocol routes; off is for a deployment that " + - "declares its own provider entries for those protocols", - }, { field: "usageBaseURL", reason: "endpoint override; auto-discovered from the composition", diff --git a/src/settings-page.tsx b/src/settings-page.tsx index 195baca8..fd07d84d 100644 --- a/src/settings-page.tsx +++ b/src/settings-page.tsx @@ -69,8 +69,8 @@ export const patchRemote = { * The scoped npm name — published from this same tree, and the one a new * install gets. It must answer to the SAME pages as `PKG` or the settings card * never appears for whoever installed the name we tell them to. The retired - * `@viztor/dsh-opencode` is deliberately absent: it is a deprecated wrapper - * with no client half (see AGENTS.md, "Package vs component"). + * `@viztor/dsh-opencode` is deliberately absent: it is no longer published, + * and its last version was a wrapper with no client half. */ export const SCOPED_PKG = "@viztor/dsh-opencode-patch"; diff --git a/test/architecture.test.ts b/test/architecture.test.ts index cfd06fd5..2f9474fd 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -18,6 +18,8 @@ import { join, posix } from "node:path"; import { describe, expect, it } from "vitest"; +import { blankCommentLines } from "./support/strip-comments.ts"; + const SRC_DIR = join(import.meta.dirname, "..", "src"); const UTILS_DIR = join(SRC_DIR, "utils"); @@ -46,12 +48,14 @@ const filesUnder = (dir: string): string[] => { /** Every utility, at any depth under `src/utils/`. */ const utilityFiles = (): string[] => filesUnder(UTILS_DIR); -/** The source with `//` and block comments removed, keeping line structure. */ +/** + * The source with its comments blanked, keeping line structure: comment lines + * through {@link blankCommentLines}, which only ever errs loudly, then a + * trailing `// …` after code. The trailing strip needs a space before the + * `//`, so a `"https://…"` string is not cut short. + */ const stripComments = (source: string): string => - source - .replaceAll(/\/\*[\s\S]*?\*\//gu, "") - .replaceAll(/^\s*\/\/.*$/gmu, "") - .replaceAll(/\/\/.*$/gmu, ""); + blankCommentLines(source).replaceAll(/(?<=\s)\/\/.*$/gmu, ""); /** * Runtime imports of one file. A type-only import is erased by the compiler, @@ -272,6 +276,20 @@ describe("architecture · utils/ holds only pure functions", () => { expect(utilityFiles().length).toBeGreaterThan(0); }); + it("strips comments without cutting code short, keeping the line count", () => { + // Stripping only ever removes text and every gate asserts an empty list, + // so over-stripping can never fail a gate; these answers pin the rule. + expect(stripComments('const u = "https://x"; process.env.A')).toContain( + "process.env.A" + ); + expect(stripComments("f(); // process.env")).not.toMatch(/process\.env/u); + expect( + stripComments("/**\n * process.env\n */\nconst a = 1;").split("\n") + ).toEqual(["", "", "", "const a = 1;"]); + expect(stripComments("/* a */ fetch(x)")).toContain("fetch("); + expect(stripComments("f(/* process.env */ 1)")).toContain("process.env"); + }); + it("imports nothing host-only, and nothing outside utils/", () => { const violations: string[] = []; for (const file of utilityFiles()) { @@ -354,12 +372,7 @@ describe("architecture · utils/ holds only pure functions", () => { for (const file of utilityFiles()) { // Block comments are blanked rather than removed, so a line number in a // violation is the line in the file. - const source = stripComments( - readFileSync(join(UTILS_DIR, file), "utf8").replaceAll( - /\/\*[\s\S]*?\*\//gu, - (comment) => comment.replaceAll(/[^\n]/gu, "") - ) - ); + const source = stripComments(readFileSync(join(UTILS_DIR, file), "utf8")); for (const [index, line] of source.split("\n").entries()) { const match = AMBIENT_EFFECT.exec(line); if (match !== null) { @@ -560,10 +573,10 @@ describe("architecture · tests reach src through one door", () => { }); it("test/support/src.ts re-exports each name from the module that declares it", () => { - // Re-exporting through `src/index.ts` would tie every test to the barrel - // P2 shrinks, which is the coupling this file exists to remove; and a name - // taken from a module that only passes it on breaks when P10a moves the - // module that really defines it. + // Re-exporting through `src/index.ts` would tie every test to the barrel, + // which carries only the host's contract, and that coupling is what this + // file exists to remove; and a name taken from a module that only passes + // it on breaks when P10a moves the module that really defines it. const source = readFileSync(SUPPORT, "utf8"); expect(source).not.toMatch(/src\/index\.ts/u); const blocks = reExportBlocks(source); @@ -586,3 +599,160 @@ describe("architecture · tests reach src through one door", () => { expect(notDeclared).toEqual([]); }); }); + +describe("architecture · every test reaches src through one door", () => { + const TEST_DIR = import.meta.dirname; + + /** + * A static `import`/`export … from "(../)+src/…"`, multi-line blocks + * included, and a side-effect `import "(../)+src/…";`. A + * `vi.mock("../src/x.ts")` target and its `importOriginal` + * type are not statements of this shape: module identity is their subject, + * so they keep their paths. + */ + const STATIC_SRC_IMPORT = + /^(?:(?:import|export)\b[^;]*?\bfrom\s+|import\s+)"(?:\.\.\/)+src\/[^"]+"/gmu; + + /** + * A dynamic `import("(../)+src/…")` that is not a `typeof import(…)` type. + * It is allowed only as a fresh re-import, so only in a file that calls + * `vi.resetModules(`; anywhere else it is the door bypassed at runtime. + */ + const DYNAMIC_SRC_IMPORT = + /(? + /@(?:vitest|jest)-environment\s+([\w-]+)\b/u.exec(source)?.[1] === "jsdom"; + + /** + * Files whose subject is the `src` layout or the barrel, the door itself, + * and the setup file that imports `src` directly — a setup file that loaded + * the door would pull the host graph into every jsdom test. Every other + * `support/` file is walked like a test. + */ + const exempt = (file: string): boolean => + file === "support/src.ts" || + file === "support/reset-process-state.ts" || + file === "architecture.test.ts" || + file === "public-api.test.ts"; + + /** A value import of the door: a client test must not load the host graph. */ + const DOOR_VALUE_IMPORT = + /^import\s+(?!type\b)[^;]*?\bfrom\s+"(?:\.\.\/)*(?:\.\/)?support\/src\.ts"/gmu; + + /** + * Comments blanked out with their line breaks kept, so `file:line` is true. + * Line by line, so a `/*` inside a string cannot open a fake block that + * hides the code after it (see `support/strip-comments.ts`). + */ + const blankComments = blankCommentLines; + + /** The test tree the gate walks: every `.ts`/`.tsx` not exempt by name. */ + const walked = (): string[] => filesUnder(TEST_DIR).filter((f) => !exempt(f)); + + it("knows a static import from a mock target or a fresh re-import", () => { + const matches = (line: string): boolean => + new RegExp(STATIC_SRC_IMPORT.source, "mu").test(line); + expect(matches('import { a } from "../src/a.ts";')).toBe(true); + expect(matches('import type { A } from "../../src/a.ts";')).toBe(true); + expect(matches('import {\n a,\n b,\n} from "../src/a.ts";')).toBe(true); + expect( + matches('vi.mock("../src/a.ts", async (importOriginal) => ({}));') + ).toBe(false); + expect(matches('importOriginal()')).toBe( + false + ); + expect(matches('const m = await import("../src/a.ts");')).toBe(false); + // A side-effect import loads the module just the same. + expect(matches('import "../src/a.ts";')).toBe(true); + const dynamic = (text: string): boolean => + new RegExp(DYNAMIC_SRC_IMPORT.source, "u").test(text); + expect(dynamic('const m = await import("../src/a.ts");')).toBe(true); + expect(dynamic('importOriginal()')).toBe( + false + ); + // The pragma as vitest reads it, built so this file stays a node test. + const pragma = ["@vitest", "environment jsdom"].join("-"); + expect(isClientTest(`// ${pragma}\n`)).toBe(true); + expect(isClientTest(`/** ${pragma} */\n`)).toBe(true); + expect(isClientTest("// a node test\n")).toBe(false); + expect(isClientTest(readFileSync(import.meta.filename, "utf8"))).toBe( + false + ); + // The first pragma decides, as in vitest, and its name is read whole. + const nodePragma = ["@vitest", "environment node"].join("-"); + expect( + isClientTest(`// ${nodePragma}\n// unlike the ${pragma} tests\n`) + ).toBe(false); + expect( + isClientTest(`// ${pragma}\n// unlike the ${nodePragma} tests\n`) + ).toBe(true); + expect(isClientTest(`// ${pragma}-global\n`)).toBe(false); + // A `/*` inside a string opens no comment: the import after it is seen. + const tricky = 'x("a/*b");\nimport "../src/x.ts";\n// c */\n'; + expect( + new RegExp(STATIC_SRC_IMPORT.source, "mu").test(blankComments(tricky)) + ).toBe(true); + const trickyDynamic = + 'x("a/*b");\nconst m = await import("../src/x.ts");\n// c */\n'; + expect( + new RegExp(DYNAMIC_SRC_IMPORT.source, "u").test( + blankComments(trickyDynamic) + ) + ).toBe(true); + const door = (text: string): boolean => + new RegExp(DOOR_VALUE_IMPORT.source, "mu").test(text); + expect(door('import { a } from "./support/src.ts";')).toBe(true); + expect(door('import { a } from "../support/src.ts";')).toBe(true); + expect(door('import type { A } from "./support/src.ts";')).toBe(false); + // The walk is real: it reaches the suites, the mocks and the e2e tree, + // and leaves the door itself out. + const files = walked(); + expect(files).toContain("catalog.test.ts"); + expect(files).toContain("mock/helpers.ts"); + expect(files).toContain("e2e/protocol-routing.e2e.ts"); + expect(files).not.toContain("support/src.ts"); + expect(files).toContain("support/catalog-fixture.ts"); + }); + + it("no static src import outside the exemptions", () => { + // A test that names a `src` path breaks on every module move; the door, + // test/support/src.ts, is the one file that knows where names live. + const offenders = walked().flatMap((file) => { + const raw = readFileSync(join(TEST_DIR, file), "utf8"); + const source = blankComments(raw); + const at = (match: RegExpExecArray, why: string): string => { + const end = (match.index ?? 0) + match[0].length; + const line = source.slice(0, end).split("\n").length; + return `${file}:${String(line)} ${why}`; + }; + if (isClientTest(raw)) { + // A client test may name its own `src` module; what it must not do + // is load the door, which reaches the whole host graph. + return [...source.matchAll(DOOR_VALUE_IMPORT)].map((match) => + at(match, "jsdom test loads the door") + ); + } + const dynamic = source.includes("vi.resetModules(") + ? [] + : [...source.matchAll(DYNAMIC_SRC_IMPORT)].map((match) => + at(match, "dynamic src import without vi.resetModules()") + ); + return [ + ...[...source.matchAll(STATIC_SRC_IMPORT)].map((match) => + at(match, "static src import") + ), + ...dynamic, + ]; + }); + expect(offenders).toEqual([]); + }); +}); diff --git a/test/attachment-services.test.ts b/test/attachment-services.test.ts index f1647c72..a5b60128 100644 --- a/test/attachment-services.test.ts +++ b/test/attachment-services.test.ts @@ -3,10 +3,12 @@ import { createRequire } from "node:module"; import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai"; import { expect, it, vi } from "vitest"; -import { attachmentResolvers } from "../src/attachment-services.ts"; -import { OPENCODE_GO_CATALOG } from "../src/catalog-data.ts"; -import { registerGoogleBridge } from "../src/google-bridge.ts"; import { testPi } from "./google-runtime-fixture.ts"; +import { + OPENCODE_GO_CATALOG, + attachmentResolvers, + registerGoogleBridge, +} from "./support/src.ts"; const scopedServices = (services: Record) => { const cleanups: (() => void)[] = []; diff --git a/test/auth-api-key.test.ts b/test/auth-api-key.test.ts index 73e4ad06..05b2db5a 100644 --- a/test/auth-api-key.test.ts +++ b/test/auth-api-key.test.ts @@ -21,7 +21,6 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { GatewayErrorPayload } from "./mock/protocols/index.ts"; import { GatewayTestRunner, @@ -30,6 +29,7 @@ import { MOCK_TEXT, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/catalog-family-api.test.ts b/test/catalog-family-api.test.ts index be19d171..ae9e714d 100644 --- a/test/catalog-family-api.test.ts +++ b/test/catalog-family-api.test.ts @@ -17,7 +17,7 @@ import { describe, expect, it } from "vitest"; -import { parseModelsDevCatalog } from "../src/models-catalog.ts"; +import { parseModelsDevCatalog } from "./support/src.ts"; describe("the family map", () => { it("reads the same whatever order the vendor lists its models in", () => { diff --git a/test/catalog.test.ts b/test/catalog.test.ts index 97d5d2a5..7fe60fdd 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -11,15 +11,18 @@ import { readFile } from "node:fs/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { OPENCODE_FAMILY_API } from "../src/catalog-data.ts"; import { + type ActiveTurnState, + OPENCODE_FAMILY_API, OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, PROTOCOL_FOR_SDK, ROUTE_FOR_PROTOCOL, SESSION_HEADER, + UNSERVED_PLANE_SDKS, UNSERVED_SDKS, enrichModelsResponse, + findModelSpec, getLiveGoCatalog, getLiveZenCatalog, internalRouteFor, @@ -30,13 +33,10 @@ import { parseModelsDevCatalog, patchFetch, refreshCatalog, + resetCatalogState, resolveConfig, - resolveRoutedKey, sanitizeModalities, - type ActiveTurnState, -} from "../src/index.ts"; -import { findModelSpec, resetCatalogState } from "../src/models-catalog.ts"; -import { UNSERVED_PLANE_SDKS } from "../src/responses-routes.ts"; +} from "./support/src.ts"; import { createCaptureFetch, headerOf, SESSION_RE } from "./test-helpers.ts"; afterEach(() => { @@ -771,47 +771,6 @@ describe("OpenCode Model Catalog & Enrichment", () => { ); }); - it("resolveRoutedKey identifies routed provider tier and key prefix", async () => { - const mockCtx = { - loader: { - entries: () => [ - { - options: { - config: { - providers: { - "opencode-go": { - apiKeyEnv: "OPENCODE_GO_API_KEY", - }, - opencode: { - apiKeyEnv: "OPENCODE_API_KEY", - }, - }, - }, - id: "llm-pi-ai", - name: "@deepseek-ai/dsh-llm-pi-ai", - }, - }, - ], - }, - }; - - process.env.OPENCODE_GO_API_KEY = "sk-68klEy0x2_test_go_key"; - process.env.OPENCODE_API_KEY = "oc_sk_ac6304e0f930_test_zen_key"; - - try { - const goDetails = await resolveRoutedKey(mockCtx, "opencode-go"); - expect(goDetails.tier).toBe("go"); - expect(goDetails.keyPrefix).toBe("sk-68klEy0"); - - const zenDetails = await resolveRoutedKey(mockCtx, "opencode"); - expect(zenDetails.tier).toBe("zen"); - expect(zenDetails.keyPrefix).toBe("oc_sk_ac63"); - } finally { - delete process.env.OPENCODE_GO_API_KEY; - delete process.env.OPENCODE_API_KEY; - } - }); - it("sanitizeModalities restricts modalities strictly to text and image", () => { expect( sanitizeModalities(["text", "image", "video", "audio", "pdf"]) diff --git a/test/client-channels.test.ts b/test/client-channels.test.ts index ed8879ff..c0a712e8 100644 --- a/test/client-channels.test.ts +++ b/test/client-channels.test.ts @@ -15,8 +15,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { opencodeDatabaseFile, opencodeRoots } from "../src/opencode-paths.ts"; -import { readOpencodeCredentials } from "../src/opencode-store.ts"; +import { + opencodeDatabaseFile, + opencodeRoots, + readOpencodeCredentials, +} from "./support/src.ts"; describe("OpenCode Client Channels & Database File Resolution", () => { it.each([ diff --git a/test/client-headers-affinity.test.ts b/test/client-headers-affinity.test.ts index 1dc86863..63d53133 100644 --- a/test/client-headers-affinity.test.ts +++ b/test/client-headers-affinity.test.ts @@ -24,19 +24,19 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; -import { - PARENT_SESSION_ALT_HEADER, - PARENT_SESSION_HEADER, - SESSION_AFFINITY_HEADER, - SESSION_HEADER, -} from "../src/session.ts"; import { ClientSimulator, GatewayTestRunner, MOCK_SESSION, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { + PARENT_SESSION_ALT_HEADER, + PARENT_SESSION_HEADER, + SESSION_AFFINITY_HEADER, + SESSION_HEADER, + clearCapturedApiKeys, +} from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/composition.test.ts b/test/composition.test.ts index 1c6d09af..9d89df1e 100644 --- a/test/composition.test.ts +++ b/test/composition.test.ts @@ -26,8 +26,7 @@ import { Context } from "@deepseek-ai/cordis"; import { describe, expect, it } from "vitest"; -import { apply, inject } from "../src/index.ts"; -import type { CordisContext } from "../src/index.ts"; +import { type CordisContext, apply, inject } from "./support/src.ts"; /** The one service the plugin declares, shaped as the plugin reads it. */ const llmService = () => ({ diff --git a/test/config-values.test.ts b/test/config-values.test.ts index e6661443..0608de67 100644 --- a/test/config-values.test.ts +++ b/test/config-values.test.ts @@ -16,7 +16,7 @@ import { readString, readStringList, unwrapNode, -} from "../src/config-values.ts"; +} from "./support/src.ts"; /** A value that is genuinely absent, without writing the `undefined` literal. */ const ABSENT: unknown = undefined; diff --git a/test/config.test.ts b/test/config.test.ts index e4efd16d..4a9335f3 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -8,19 +8,19 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { CONFIG_DEFAULTS } from "../src/config.ts"; import { + type ActiveTurnState, + CONFIG_DEFAULTS, Config, DEFAULT_ENDPOINT_FAMILY, DEFAULT_KEY_SOURCE, ENDPOINT_FAMILIES, - isOpenCodeRequest, KEY_SOURCE_POLICIES, - resolveConfig, - type ActiveTurnState, type ModelOverrideConfig, type PluginConfig, -} from "../src/index.ts"; + isOpenCodeRequest, + resolveConfig, +} from "./support/src.ts"; afterEach(() => { vi.unstubAllGlobals(); @@ -282,6 +282,20 @@ describe("resolveConfig", () => { }); describe("Config schema", () => { + it("accepts a stale row that still carries internalRoutes, and ignores the key", () => { + // Removed in 1.x: the per-protocol routes always mount. An upgrading + // deployment's row may still carry the key, and the host validates the + // row through the schema's standard validator before the plugin loads, + // so a throw or an issue here would stop the plugin. The schema's own + // absence is pinned in deleted-settings.test.ts. + const stale = { internalRoutes: false } as unknown as PluginConfig; + expect(() => Config(stale)).not.toThrow(); + const result = Config["~standard"].validate(stale); + expect(result).not.toBeInstanceOf(Promise); + expect((result as { issues?: unknown }).issues).toBeUndefined(); + expect(resolveConfig(stale)).not.toHaveProperty("internalRoutes"); + }); + it("validates an empty row to the documented defaults", () => { // The harness validates the patch row against this schema before the // plugin sees it, and the settings UI serves the namespace from it — so @@ -301,7 +315,6 @@ describe("Config schema", () => { "injectOriginHeaders", "injectProject", "injectUserAgent", - "internalRoutes", "keySource", "maxContextWindow", "modelOverrides", diff --git a/test/cordis-context.test.ts b/test/cordis-context.test.ts index f0887d5e..5579c6b4 100644 --- a/test/cordis-context.test.ts +++ b/test/cordis-context.test.ts @@ -17,7 +17,7 @@ import { readCredentialsResolver, readEntryOptions, readSessionMetaResolver, -} from "../src/cordis-context.ts"; +} from "./support/src.ts"; describe("cordis-context: readEntryOptions", () => { it("reads id and name from fiber.entry.options", () => { diff --git a/test/debug.test.ts b/test/debug.test.ts index 2bce527a..621806de 100644 --- a/test/debug.test.ts +++ b/test/debug.test.ts @@ -13,7 +13,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { recordDebug } from "../src/debug.ts"; +import { recordDebug } from "./support/src.ts"; // The write path catches whatever appendFile throws, and one of its branches // only exists for a rejection that is not an Error — which a real filesystem diff --git a/test/deleted-settings.test.ts b/test/deleted-settings.test.ts new file mode 100644 index 00000000..02a84a71 --- /dev/null +++ b/test/deleted-settings.test.ts @@ -0,0 +1,95 @@ +/** + * Deleted settings are gone everywhere, not just from the schema. + * + * A setting lives in five places: the schema (`Config`), the resolver + * (`resolveConfig`), the settings registry (`CONFIG_ONLY_FIELDS`), the bundle + * manifest's header (`cordis.patch.yml`) and the READMEs. Removing it from one + * and not the others leaves a row the UI offers and the plugin ignores, or a + * documented switch that does nothing. So each removal is pinned in every + * place at once, and the source walk catches a code path still branching on + * the name. + * + * `internalRoutes` was removed in 1.x (D2): the per-protocol routes always + * mount. `resolveRoutedKey` (D4) was exported and never called from `src/`. + * + * @module test/deleted-settings.test + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { Config, CONFIG_ONLY_FIELDS, resolveConfig } from "./support/src.ts"; +import { blankCommentLines } from "./support/strip-comments.ts"; + +const ROOT = join(import.meta.dirname, ".."); + +/** A deleted name, as a whole word. */ +const DELETED = /\b(?:internalRoutes|resolveRoutedKey|RoutedKeyDetails)\b/u; + +/** Every `.ts`/`.tsx` under `dir`, at any depth, relative to `ROOT`. */ +const sourcesUnder = (dir: string): string[] => + readdirSync(join(ROOT, dir), { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.tsx?$/u.test(entry.name)) + .map((entry) => + join(entry.parentPath, entry.name) + .slice(ROOT.length + 1) + .replaceAll("\\", "/") + ) + .toSorted(); + +/** + * Comments blanked out with their line breaks kept, so `file:line` is true; + * line by line, so a `/*` in a string cannot hide the code after it. + */ +const blankComments = blankCommentLines; + +const read = (file: string): string => readFileSync(join(ROOT, file), "utf8"); + +/** `text` without the `## ` section that starts at `heading`. */ +const withoutSection = (text: string, heading: string): string => { + const start = text.indexOf(`${heading}\n`); + if (start === -1) { + return text; + } + const next = text.indexOf("\n## ", start + heading.length); + return text.slice(0, start) + (next === -1 ? "" : text.slice(next + 1)); +}; + +describe("deleted settings", () => { + it("internalRoutes is gone from the schema, the resolver, the registry and the manifest", () => { + const schemaKeys = Object.keys(Config.dict ?? {}); + // The dict read is real: a live key is there. + expect(schemaKeys).toContain("endpoint"); + expect(schemaKeys).not.toContain("internalRoutes"); + expect(Object.keys(resolveConfig({}))).not.toContain("internalRoutes"); + expect(CONFIG_ONLY_FIELDS.map((f) => f.field)).not.toContain( + "internalRoutes" + ); + expect(read("cordis.patch.yml")).not.toMatch(/\binternalRoutes\b/u); + }); + + it("no source, door or README still names internalRoutes or resolveRoutedKey", () => { + const files = [...sourcesUnder("src"), "test/support/src.ts"]; + // Non-vacuous: the walk reaches nested modules. + expect(files).toContain("src/config.ts"); + expect(files).toContain("src/utils/guards.ts"); + const offenders = files.flatMap((file) => + blankComments(read(file)) + .split("\n") + .flatMap((line, index) => + DELETED.test(line) ? [`${file}:${String(index + 1)}`] : [] + ) + ); + expect(offenders).toEqual([]); + // A README may name a removed setting only where it announces the removal. + for (const [file, heading] of [ + ["README.md", "## Changed in 1.x"], + ["README.zh-CN.md", "## 1.x 变更"], + ] as const) { + const text = read(file); + expect(text).toContain(heading); + expect(withoutSection(text, heading)).not.toMatch(/\binternalRoutes\b/u); + } + }); +}); diff --git a/test/e2e/dispatch-levels.e2e.ts b/test/e2e/dispatch-levels.e2e.ts index 6f1bb651..05aca453 100644 --- a/test/e2e/dispatch-levels.e2e.ts +++ b/test/e2e/dispatch-levels.e2e.ts @@ -29,12 +29,12 @@ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/models"; import { describe, expect, it } from "vitest"; import { - decorateModelInfo, - decoratePlaneAdapters, OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, + decorateModelInfo, + decoratePlaneAdapters, resolveConfig, -} from "../../src/index.ts"; +} from "../support/src.ts"; const ENABLED = process.env.OPENCODE_E2E === "1"; diff --git a/test/e2e/opencode-live.e2e.ts b/test/e2e/opencode-live.e2e.ts index 67514c61..cdd55d27 100644 --- a/test/e2e/opencode-live.e2e.ts +++ b/test/e2e/opencode-live.e2e.ts @@ -20,13 +20,13 @@ import { describe, expect, it } from "vitest"; import { - enrichModelsResponse, + type GoUsage, OPENCODE_ZEN_CATALOG, + SESSION_HEADER, + enrichModelsResponse, openCodeSessionIdFor, parseGoUsage, - SESSION_HEADER, - type GoUsage, -} from "../../src/index.ts"; +} from "../support/src.ts"; const LIVE = process.env.OPENCODE_E2E === "1"; diff --git a/test/e2e/patched-fetch-headers.e2e.ts b/test/e2e/patched-fetch-headers.e2e.ts index 8a92c3d8..d1916351 100644 --- a/test/e2e/patched-fetch-headers.e2e.ts +++ b/test/e2e/patched-fetch-headers.e2e.ts @@ -19,15 +19,15 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { type ActiveTurnState, - openCodeSessionIdFor, OPENCODE_UA, PARENT_SESSION_ALT_HEADER, PARENT_SESSION_HEADER, - patchFetch, - resolveConfig, SESSION_AFFINITY_HEADER, SESSION_HEADER, -} from "../../src/index.ts"; + openCodeSessionIdFor, + patchFetch, + resolveConfig, +} from "../support/src.ts"; const ENABLED = process.env.OPENCODE_E2E === "1"; diff --git a/test/e2e/protocol-routing.e2e.ts b/test/e2e/protocol-routing.e2e.ts index df1e68fb..1a94668c 100644 --- a/test/e2e/protocol-routing.e2e.ts +++ b/test/e2e/protocol-routing.e2e.ts @@ -63,7 +63,7 @@ import { findModelSpecOn, internalRouteFor, isServableSdk, -} from "../../src/index.ts"; +} from "../support/src.ts"; const LIVE = process.env.OPENCODE_E2E === "1"; const ZEN_KEY = process.env.OPENCODE_API_KEY; diff --git a/test/e2e/refusal-classification.e2e.ts b/test/e2e/refusal-classification.e2e.ts index a66df90f..3bd16d37 100644 --- a/test/e2e/refusal-classification.e2e.ts +++ b/test/e2e/refusal-classification.e2e.ts @@ -36,7 +36,7 @@ import { type ActiveTurnState, patchFetch, resolveConfig, -} from "../../src/index.ts"; +} from "../support/src.ts"; const ENABLED = process.env.OPENCODE_E2E === "1"; diff --git a/test/endpoint-completions.test.ts b/test/endpoint-completions.test.ts index c907ceaf..a1e28ed6 100644 --- a/test/endpoint-completions.test.ts +++ b/test/endpoint-completions.test.ts @@ -26,7 +26,6 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { GatewayErrorPayload, V1CompletionsUnaryPayload, @@ -36,6 +35,7 @@ import { MOCK_SESSION, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/endpoint-copy.test.ts b/test/endpoint-copy.test.ts index 9388e11d..79cc797f 100644 --- a/test/endpoint-copy.test.ts +++ b/test/endpoint-copy.test.ts @@ -14,8 +14,7 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; -import { Config } from "../src/config.ts"; -import { en, zh } from "../src/settings-copy.ts"; +import { Config, en, zh } from "./support/src.ts"; /** The first line of a file that contains `marker`, or fail loudly. */ const lineOf = async (file: string, marker: string): Promise => { diff --git a/test/endpoint-google.test.ts b/test/endpoint-google.test.ts index 957d49df..d1c7f0eb 100644 --- a/test/endpoint-google.test.ts +++ b/test/endpoint-google.test.ts @@ -25,7 +25,6 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { GatewayErrorPayload, GoogleUnaryPayload, @@ -35,6 +34,7 @@ import { MOCK_SESSION, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/endpoint-messages.test.ts b/test/endpoint-messages.test.ts index 22561262..345d05fa 100644 --- a/test/endpoint-messages.test.ts +++ b/test/endpoint-messages.test.ts @@ -24,7 +24,6 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { GatewayErrorPayload, MessagesUnaryPayload, @@ -34,6 +33,7 @@ import { MOCK_SESSION, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/endpoint-models-catalog.test.ts b/test/endpoint-models-catalog.test.ts index 78226714..addf2978 100644 --- a/test/endpoint-models-catalog.test.ts +++ b/test/endpoint-models-catalog.test.ts @@ -22,7 +22,6 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { CatalogListingPayload, GatewayErrorPayload, @@ -32,6 +31,7 @@ import { MOCK_SESSION, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/endpoint-responses.test.ts b/test/endpoint-responses.test.ts index 5d7acafb..31219a28 100644 --- a/test/endpoint-responses.test.ts +++ b/test/endpoint-responses.test.ts @@ -29,7 +29,6 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { V2ResponsesUnaryPayload } from "./mock/protocols/index.ts"; import { GatewayTestRunner, @@ -37,6 +36,7 @@ import { MOCK_TEXT, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/endpoint-usage-quota.test.ts b/test/endpoint-usage-quota.test.ts index c81c2f29..cc3cae0e 100644 --- a/test/endpoint-usage-quota.test.ts +++ b/test/endpoint-usage-quota.test.ts @@ -21,9 +21,9 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { UsageQuotaPayload } from "./mock/protocols/index.ts"; import { GatewayTestRunner, GO_KEY } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/go-discovery.test.ts b/test/go-discovery.test.ts index 35429290..16b4e0af 100644 --- a/test/go-discovery.test.ts +++ b/test/go-discovery.test.ts @@ -28,34 +28,30 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createOpencodeStore, + type FixtureCredential, + type FixtureStore, +} from "./support/opencode-store-fixture.ts"; import { DEFAULT_USAGE_BASE_URL, DEFAULT_USAGE_KEY_ENV, + type DiscoveredGoConfig, KEY_SOURCE_POLICIES, -} from "../src/config.ts"; -import { + clearCapturedApiKeys, declaredProviderModels, discoverGoConfig, effectiveGoKeyRef, + getCapturedApiKey, + isPlaceholderApiKey, + recordCapturedApiKey, resolveGoApiKey, resolveGoBaseURL, resolveGoKeyForRef, - resolveRoutedKey, + resolveOpencodeStoreKey, resolveZenCreditInfo, toGoBaseURL, - type DiscoveredGoConfig, -} from "../src/go-discovery.ts"; -import { - clearCapturedApiKeys, - getCapturedApiKey, - isPlaceholderApiKey, - recordCapturedApiKey, -} from "../src/key-capture.ts"; -import { - createOpencodeStore, - type FixtureCredential, - type FixtureStore, -} from "./support/opencode-store-fixture.ts"; +} from "./support/src.ts"; import { createMockContext } from "./test-helpers.ts"; /** The Zen plane, which discovery rewrites onto the Go plane. */ @@ -935,125 +931,6 @@ describe("resolveZenCreditInfo", () => { }); }); -describe("resolveRoutedKey", () => { - it("calls the Go route Go whatever the resolved key looks like", async () => { - // The routed provider is authoritative for the tier: a key that is a - // shared or vendored token still authenticates the Go plane when the route - // in use *is* the Go route. - process.env.OPENCODE_GO_API_KEY = "vendor-token-1234"; - const routed = await resolveRoutedKey(composition(), "opencode-go"); - expect(routed.key).toBe("vendor-token-1234"); - expect(routed.tier).toBe("go"); - expect(routed.provider).toBe("opencode-go"); - }); - - it("does not classify a renamed route by the key's prefix", async () => { - // The route decides, and a renamed route names no plane. The prefix used to - // decide instead, and it is not OpenCode's: `oc_sk_` is issued for Go keys - // too, and `sk-` is every OpenAI key. - for (const key of ["sk-rotated-key", "oc_sk_zen-key", "opaque-token"]) { - clearCapturedApiKeys(); - recordCapturedApiKey(key, "my-relay", "https://relay.test/v1"); - const routed = await resolveRoutedKey(composition(), "my-relay"); - expect(routed.key).toBe(key); - expect(routed.tier).toBe("unknown"); - } - }); - - it("accepts a Zen key for a route that legitimately owns one", async () => { - // Unlike `resolveGoApiKey`, this lookup never rejects a Zen key: here the - // Zen tier is the target rather than a mistake. - process.env.OPENCODE_API_KEY = "oc_sk_zen-only"; - const routed = await resolveRoutedKey(composition(), "opencode"); - expect(routed.key).toBe("oc_sk_zen-only"); - expect(routed.tier).toBe("zen"); - expect( - await resolveGoKeyForRef(composition(), "OPENCODE_API_KEY") - ).toBeUndefined(); - }); - - it("reports the tier of the route it was asked about", async () => { - // A non-Go route is not tier-filtered, so a key captured elsewhere can - // answer for it; the tier reported is still the route's, never a guess - // from the key's shape. - recordCapturedApiKey("sk-go-key-value", "opencode-go", GO_REQUEST); - const routed = await resolveRoutedKey(composition(), "opencode"); - expect(routed.key).toBe("sk-go-key-value"); - expect(routed.tier).toBe("zen"); - }); - - it("uses the route's own declared reference, defaulting each plane differently", async () => { - // The default reference differs by plane on purpose: a Go route must never - // silently fall back to the Zen reference. - const goProbe = withCredentials({}); - await resolveRoutedKey(goProbe.context, "opencode-go"); - expect(goProbe.asked).toEqual([DEFAULT_USAGE_KEY_ENV]); - - const zenProbe = withCredentials({}); - await resolveRoutedKey(zenProbe.context, "opencode"); - expect(zenProbe.asked).toEqual(["OPENCODE_API_KEY"]); - - const declaredProbe = withCredentials({}, [ - registryEntry("opencode-go", { apiKeyEnv: "DSH_TEST_GO_KEY" }), - ]); - await resolveRoutedKey(declaredProbe.context, "opencode-go", "configured"); - expect(declaredProbe.asked).toEqual(["DSH_TEST_GO_KEY"]); - }); - - it("reports a ten-character prefix, and none below it", async () => { - // The prefix identifies the account without disclosing it. The floor is the - // SAME ten characters as the slice: it used to be 8, so an 8- or 9-character - // key was reported WHOLE — a "prefix" that leaked the entire secret, - // precisely the length it was meant to withhold. - recordCapturedApiKey("sk-abcdefghijkl", "opencode-go", GO_REQUEST); - const long = await resolveRoutedKey(composition(), "opencode-go"); - expect(long.keyPrefix).toBe("sk-abcdefg"); - clearCapturedApiKeys(); - recordCapturedApiKey("sk-1234567890", "opencode-go", GO_REQUEST); - const atFloor = await resolveRoutedKey(composition(), "opencode-go"); - expect(atFloor.key).toBe("sk-1234567890"); - // Exactly ten characters, so the ninth and tenth digits stay hidden. - expect(atFloor.keyPrefix).toBe("sk-1234567"); - clearCapturedApiKeys(); - recordCapturedApiKey("sk-12345", "opencode-go", GO_REQUEST); - const belowFloor = await resolveRoutedKey(composition(), "opencode-go"); - // The key is still returned — only the derived prefix is withheld. - expect(belowFloor.key).toBe("sk-12345"); - expect(belowFloor.keyPrefix).toBeUndefined(); - }); - - it("carries both optional fields even when nothing resolved", async () => { - // Consumers destructure `key`/`keyPrefix`, so the fields must be present - // rather than absent — an absent field is a different shape to narrow. - const routed = await resolveRoutedKey(composition(), "opencode-go"); - expect(routed.key).toBeUndefined(); - expect(routed.keyPrefix).toBeUndefined(); - expect(Object.hasOwn(routed, "key")).toBe(true); - expect(Object.hasOwn(routed, "keyPrefix")).toBe(true); - }); - - it("applies the row's keySource policy exactly as the meter does", async () => { - // Both consumers share `KEY_SOURCE_ORDER`, so a policy that changed the - // meter's credential would change what every routed request is signed - // with; the two must not be able to drift. - const context = composition([ - registryEntry("opencode-go", { apiKey: "sk-literal" }), - ]); - process.env.OPENCODE_GO_API_KEY = "sk-env"; - recordCapturedApiKey("sk-captured", "opencode-go", GO_REQUEST); - const automatic = await resolveRoutedKey(context, "opencode-go", "auto"); - const requested = await resolveRoutedKey(context, "opencode-go", "request"); - const declared = await resolveRoutedKey( - context, - "opencode-go", - "configured" - ); - expect(automatic.key).toBe("sk-literal"); - expect(requested.key).toBe("sk-captured"); - expect(declared.key).toBe("sk-literal"); - }); -}); - describe("gaps characterised while pinning the policy", () => { it("never lets a placeholder become the credential the meter sends", async () => { // This repo's README tells users to write exactly `authorization: Bearer @@ -1090,8 +967,6 @@ describe("gaps characterised while pinning the policy", () => { expect(await resolveGoApiKey(context, "opencode-go", "request")).toBe( "sk-real-captured" ); - const routed = await resolveRoutedKey(context, "opencode-go", "request"); - expect(routed.key).toBe("sk-real-captured"); }); it("treats the gateway's keyless sentinel as not a credential", async () => { @@ -1349,16 +1224,10 @@ describe("go-discovery: the installation's own store, and where it sits", () => await expect( resolveGoApiKey(composition(), undefined, "auto", true) ).resolves.toBeUndefined(); - // ...while the Zen route resolves that same row. The plane is what the - // store is keyed by, and a reader that took the first row would hand the Go - // endpoint a Zen credential. - const routed = await resolveRoutedKey( - composition(), - "opencode", - "auto", - true - ); - expect(routed.key).toBe("oc_sk_fixture_zen"); + // ...while the Zen integration resolves that same row. The plane is what + // the store is keyed by, and a reader that took the first row would hand + // the Go endpoint a Zen credential. + expect(resolveOpencodeStoreKey("opencode", true)).toBe("oc_sk_fixture_zen"); }); it("answers undefined for a store that holds nothing", async () => { diff --git a/test/go-google-prepare.test.ts b/test/go-google-prepare.test.ts index ff9fd504..71c746f7 100644 --- a/test/go-google-prepare.test.ts +++ b/test/go-google-prepare.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { resolveConfig } from "../src/config.ts"; -import { decoratePlaneAdapters } from "../src/models-discovery.ts"; +import { decoratePlaneAdapters, resolveConfig } from "./support/src.ts"; const model = "gemini-4-argon-contributor"; const route = "opencode-go-google"; diff --git a/test/go-google.test.ts b/test/go-google.test.ts index edbb292a..1f7e114c 100644 --- a/test/go-google.test.ts +++ b/test/go-google.test.ts @@ -3,22 +3,20 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai"; import { describe, expect, it } from "vitest"; -import { OPENCODE_GO_CATALOG } from "../src/catalog-data.ts"; -import { resolveConfig } from "../src/config.ts"; -import { patchFetch } from "../src/fetch-patch.ts"; +import { testPi } from "./google-runtime-fixture.ts"; import { - registerGoogleBridge, + type ActiveTurnState, GO_GOOGLE_BASE_URL, -} from "../src/google-bridge.ts"; -import { hideResponsesRoute } from "../src/models-discovery.ts"; -import { GO_GOOGLE_INTERNAL_ROUTE, + OPENCODE_GO_CATALOG, + createStreamHook, + getSessionUsage, + hideResponsesRoute, internalRouteFor, -} from "../src/responses-routes.ts"; -import { getSessionUsage } from "../src/session-cost.ts"; -import { createStreamHook } from "../src/stream-hook.ts"; -import type { ActiveTurnState } from "../src/turn-store.ts"; -import { testPi } from "./google-runtime-fixture.ts"; + patchFetch, + registerGoogleBridge, + resolveConfig, +} from "./support/src.ts"; interface WirePart { functionCall?: { name: string }; diff --git a/test/go-limits.test.ts b/test/go-limits.test.ts index 66d6a5fe..c1cdade6 100644 --- a/test/go-limits.test.ts +++ b/test/go-limits.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "vitest"; -import { GO_MODEL_LIMITS } from "../src/go-limits-data.ts"; import { - allowanceFor, - formatUsd, + GO_MODEL_LIMITS, GO_PLAN_TIERS, + WINDOW_SHARE, + allowanceFor, + formatAllowanceUsd as formatUsd, normalizeModelLabel, parseGoLimitsDoc, - WINDOW_SHARE, -} from "../src/go-limits.ts"; +} from "./support/src.ts"; /** The two tier tables, trimmed to what the parser reads. */ const doc = (go: string, plus: string): string => ` diff --git a/test/google-bridge.test.ts b/test/google-bridge.test.ts index 7260ac15..16e051cb 100644 --- a/test/google-bridge.test.ts +++ b/test/google-bridge.test.ts @@ -20,18 +20,19 @@ import { } from "@earendil-works/pi-ai/providers/all"; import { describe, expect, it } from "vitest"; -import { OPENCODE_ZEN_CATALOG } from "../src/catalog-data.ts"; import { - ambientAuthContext, - emptyCredentialStore, + GOOGLE_INTERNAL_ROUTE, + GOOGLE_SDK, type GoogleBridgeInputs, + OPENCODE_ZEN_CATALOG, type PiRuntime, + ambientAuthContext, + emptyCredentialStore, googleProviderFor, loadHostPi, registerGoogleBridge, resolveGatewayKey, -} from "../src/google-bridge.ts"; -import { GOOGLE_INTERNAL_ROUTE, GOOGLE_SDK } from "../src/responses-routes.ts"; +} from "./support/src.ts"; /** The Gemini models of the Zen plane, straight from the generated catalog. */ const geminiSpecs = OPENCODE_ZEN_CATALOG.filter( diff --git a/test/google-replay.test.ts b/test/google-replay.test.ts index ed276035..e89f99b9 100644 --- a/test/google-replay.test.ts +++ b/test/google-replay.test.ts @@ -4,7 +4,7 @@ import { googleHistoryFor, googleReplayFor, googleReplayStream, -} from "../src/utils/google-replay.ts"; +} from "./support/src.ts"; describe("Google replay route boundary", () => { it("leaves error chunks JSON-serializable and does not invent missing replay", async () => { diff --git a/test/google-runtime-fixture.ts b/test/google-runtime-fixture.ts index b1cb5d29..f6380b7b 100644 --- a/test/google-runtime-fixture.ts +++ b/test/google-runtime-fixture.ts @@ -1,7 +1,7 @@ import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy"; import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all"; -import type { PiRuntime } from "../src/google-bridge.ts"; +import type { PiRuntime } from "./support/src.ts"; export const testPi = (): PiRuntime => ({ getBuiltinModels: (plane) => diff --git a/test/guards.test.ts b/test/guards.test.ts index fee80a60..0d91fde9 100644 --- a/test/guards.test.ts +++ b/test/guards.test.ts @@ -17,7 +17,7 @@ import { isFunctionLike, isRecord, isUnknownArray, -} from "../src/utils/guards.ts"; +} from "./support/src.ts"; /** A value that is genuinely absent, without writing the `undefined` literal. */ const ABSENT: unknown = undefined; diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 55dda10c..91539d30 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -12,16 +12,16 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveConfig } from "../src/config.ts"; import { - apply, type CordisContext, - getSessionUsage, - clearSessionUsageStore, RESPONSES_ROUTE, -} from "../src/index.ts"; -import { mountInternalRoutes } from "../src/lifecycle.ts"; -import { findModelSpecOn } from "../src/models-catalog.ts"; + apply, + clearSessionUsageStore, + findModelSpecOn, + getSessionUsage, + mountInternalRoutes, + resolveConfig, +} from "./support/src.ts"; import { collectUnknown, createMockStream, diff --git a/test/mock/helpers.ts b/test/mock/helpers.ts index 7226cb3a..b11b3146 100644 --- a/test/mock/helpers.ts +++ b/test/mock/helpers.ts @@ -26,16 +26,14 @@ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completio import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; import { + type ActiveTurnState, + type FetchPatchHooks, OPENCODE_GO_CATALOG, OPENCODE_PLANE_API, OPENCODE_ZEN_CATALOG, -} from "../../src/catalog-data.ts"; -import type { FetchPatchHooks } from "../../src/fetch-patch.ts"; -import { - patchFetch, - type ActiveTurnState, type ResolvedPluginConfig, -} from "../../src/index.ts"; + patchFetch, +} from "../support/src.ts"; import type { GatewayKind } from "./opencode-gateway.ts"; export { MOCK_TEXT } from "./opencode-gateway.ts"; diff --git a/test/mock/opencode-gateway.ts b/test/mock/opencode-gateway.ts index f0e5c049..bcb1fe09 100644 --- a/test/mock/opencode-gateway.ts +++ b/test/mock/opencode-gateway.ts @@ -15,8 +15,8 @@ import { OPENCODE_GO_CATALOG, OPENCODE_PLANE_API, OPENCODE_ZEN_CATALOG, -} from "../../src/catalog-data.ts"; -import { isRecord } from "../../src/utils/guards.ts"; + isRecord, +} from "../support/src.ts"; import { anthropicStream, anthropicUnary, diff --git a/test/mock/protocol-compat.mock.ts b/test/mock/protocol-compat.mock.ts index 650daa90..6880d3fb 100644 --- a/test/mock/protocol-compat.mock.ts +++ b/test/mock/protocol-compat.mock.ts @@ -32,12 +32,12 @@ import { } from "vitest"; import { - clearCapturedApiKeys, OPENCODE_UA, - resolveConfig, SESSION_HEADER, -} from "../../src/index.ts"; -import { piModelFor } from "../../src/pi-provider.ts"; + clearCapturedApiKeys, + piModelFor, + resolveConfig, +} from "../support/src.ts"; import { startMockGateway, type MockGateway } from "./gateway-server.ts"; import { collectText, diff --git a/test/mock/protocols/models-catalog.protocol.ts b/test/mock/protocols/models-catalog.protocol.ts index 3670e5f3..a97f4bdd 100644 --- a/test/mock/protocols/models-catalog.protocol.ts +++ b/test/mock/protocols/models-catalog.protocol.ts @@ -12,7 +12,7 @@ import { OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, -} from "../../../src/catalog-data.ts"; +} from "../../support/src.ts"; export const CATALOG_PROTOCOL = "models-catalog" as const; export const CATALOG_KIND = "models" as const; diff --git a/test/mock/request-shapes.mock.ts b/test/mock/request-shapes.mock.ts index e8cbc1d8..65d299de 100644 --- a/test/mock/request-shapes.mock.ts +++ b/test/mock/request-shapes.mock.ts @@ -25,19 +25,19 @@ import { vi, } from "vitest"; -import type { FetchPatchHooks } from "../../src/fetch-patch.ts"; import { - clearCapturedApiKeys, + type ActiveTurnState, + type FetchPatchHooks, OPENCODE_UA, PARENT_SESSION_ALT_HEADER, PARENT_SESSION_HEADER, - patchFetch, - resolveConfig, + type ResolvedPluginConfig, SESSION_AFFINITY_HEADER, SESSION_HEADER, - type ActiveTurnState, - type ResolvedPluginConfig, -} from "../../src/index.ts"; + clearCapturedApiKeys, + patchFetch, + resolveConfig, +} from "../support/src.ts"; import { startMockGateway, type MockGateway } from "./gateway-server.ts"; import { firstSpecFor, diff --git a/test/mock/testing-kit.ts b/test/mock/testing-kit.ts index d55eaa29..4d4f1126 100644 --- a/test/mock/testing-kit.ts +++ b/test/mock/testing-kit.ts @@ -17,19 +17,17 @@ import os from "node:os"; import nodePath from "node:path"; import { DatabaseSync } from "node:sqlite"; -import type { FetchPatchHooks } from "../../src/fetch-patch.ts"; import { - patchFetch, - resolveConfig, type ActiveTurnState, - type PluginConfig, -} from "../../src/index.ts"; -import { + type FetchPatchHooks, OPENCODE_UA, PARENT_SESSION_ALT_HEADER, PARENT_SESSION_HEADER, + type PluginConfig, SESSION_AFFINITY_HEADER, -} from "../../src/session.ts"; + patchFetch, + resolveConfig, +} from "../support/src.ts"; import { startMockGateway, type MockGateway } from "./gateway-server.ts"; import { GO_KEY, diff --git a/test/models-discovery.test.ts b/test/models-discovery.test.ts index 2fa6706c..c313e4b8 100644 --- a/test/models-discovery.test.ts +++ b/test/models-discovery.test.ts @@ -12,26 +12,24 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + type CordisContext, + PI_THINKING_LEVELS, + RESPONSES_ROUTE, decorateModelDiscovery, decorateModelInfo, + decoratePlaneAdapters, findModelSpecOn, hideResponsesRoute, + isDeprecatedModelOn, isRouteRegistered, + isServableModel, mergeDiscoveredModels, - isDeprecatedModelOn, parseModelsDevCatalog, resolveConfig, resolveDiscoveryProvider, resolveEffectiveContextWindow, - RESPONSES_ROUTE, - type CordisContext, -} from "../src/index.ts"; -import { - decoratePlaneAdapters, - isServableModel, - PI_THINKING_LEVELS, -} from "../src/models-discovery.ts"; -import { thinkingLevelMapFor } from "../src/pi-provider.ts"; + thinkingLevelMapFor, +} from "./support/src.ts"; afterEach(() => { vi.unstubAllGlobals(); diff --git a/test/mount-protocols.test.ts b/test/mount-protocols.test.ts index 2434146a..b2f0f0bc 100644 --- a/test/mount-protocols.test.ts +++ b/test/mount-protocols.test.ts @@ -8,10 +8,10 @@ import { OPENCODE_ZEN_CATALOG, PROTOCOL_FOR_SDK, ROUTE_FOR_PLANE_PROTOCOL, - UNSERVED_SDKS, SELF_MOUNTED_ROUTES, -} from "../src/index.ts"; -import { UNSERVED_PLANE_SDKS } from "../src/responses-routes.ts"; + UNSERVED_PLANE_SDKS, + UNSERVED_SDKS, +} from "./support/src.ts"; /** * The protocols this plugin mounts must be ones the harness's own pi-ai layer diff --git a/test/no-local-paths.test.ts b/test/no-local-paths.test.ts index 5d496a9d..f2525efe 100644 --- a/test/no-local-paths.test.ts +++ b/test/no-local-paths.test.ts @@ -19,7 +19,7 @@ import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; -import { CARD_FIELDS } from "../src/settings-fields.ts"; +import { CARD_FIELDS } from "./support/src.ts"; const ROOT = new URL("..", import.meta.url).pathname; diff --git a/test/opencode-paths.test.ts b/test/opencode-paths.test.ts index 7f09b52b..63e0a4c6 100644 --- a/test/opencode-paths.test.ts +++ b/test/opencode-paths.test.ts @@ -18,7 +18,7 @@ import { opencodeDatabaseFile, opencodeDatabasePath, opencodeRoots, -} from "../src/opencode-paths.ts"; +} from "./support/src.ts"; const HOME = "/home/you"; // the placeholder the hard-rule test prescribes const environment = ( diff --git a/test/opencode-store-service.test.ts b/test/opencode-store-service.test.ts index a8e10413..ea2e174d 100644 --- a/test/opencode-store-service.test.ts +++ b/test/opencode-store-service.test.ts @@ -16,7 +16,7 @@ import { DatabaseSync } from "node:sqlite"; import { afterEach, describe, expect, it } from "vitest"; -import { OpencodeStoreService } from "../src/opencode-store-service.ts"; +import { OpencodeStoreService } from "./support/src.ts"; import { createMockContext } from "./test-helpers.ts"; const temporary: string[] = []; diff --git a/test/opencode-store.test.ts b/test/opencode-store.test.ts index 977f912c..aa5198e0 100644 --- a/test/opencode-store.test.ts +++ b/test/opencode-store.test.ts @@ -23,7 +23,7 @@ import { inspectOpencodeStore, readOpencodeCredentials, summarizeCredential, -} from "../src/opencode-store.ts"; +} from "./support/src.ts"; const temporary: string[] = []; diff --git a/test/package-checks.test.ts b/test/package-checks.test.ts new file mode 100644 index 00000000..df07e684 --- /dev/null +++ b/test/package-checks.test.ts @@ -0,0 +1,166 @@ +/** + * The package's publish shape, checked against the files that define it. + * + * Two promises live in two files each, and nothing held them together: + * + * - `exports` names what a consumer may resolve, and `files` names what the + * tarball carries. An export whose target is not packed resolves in this + * repository and fails with ENOENT in every install (`./src/*` did exactly + * that: `src/` was never in `files`). + * - `scripts/publish-scoped.ts` publishes a list of aliases, and the release + * workflow's verify step polls its own list. A name in one and not the other + * is either a publish nobody checks or a check that can never pass. + * + * The functions are pure (`scripts/package-checks.ts`), so the fixtures below + * prove each one answers a case whose answer is known, and the real-tree cases + * run the same functions `scripts/check.ts` runs. + * + * @module test/package-checks.test + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + coveredByFiles, + exportsOutsideFiles, + exportTargets, + publishListMismatch, + releasePendingTargets, +} from "../scripts/package-checks.ts"; +import { SCOPED_TARGETS } from "../scripts/scoped-targets.ts"; + +const ROOT = join(import.meta.dirname, ".."); + +const readPackage = (): { + name: string; + exports?: unknown; + files?: readonly string[]; +} => + JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + name: string; + exports?: unknown; + files?: readonly string[]; + }; + +describe("package checks · exports inside files", () => { + it("flattens an exports map to its targets, conditions included", () => { + expect( + exportTargets({ + ".": { default: "./lib/index.mjs", types: "./lib/index.d.mts" }, + "./x": "./src/x.ts", + }) + ).toEqual(["./lib/index.mjs", "./lib/index.d.mts", "./src/x.ts"]); + expect(exportTargets("./lib/index.mjs")).toEqual(["./lib/index.mjs"]); + expect(exportTargets(null)).toEqual([]); + }); + + it("knows what a files entry covers", () => { + expect(coveredByFiles("./lib/index.mjs", ["lib"])).toBe(true); + expect(coveredByFiles("./library.js", ["lib"])).toBe(false); + expect(coveredByFiles("./package.json", [])).toBe(true); + expect(coveredByFiles("./locale/en.json", ["locale/*.json"])).toBe(true); + expect(coveredByFiles("./locale/*.json", ["locale/*.json"])).toBe(true); + expect(coveredByFiles("./locale/nested/en.json", ["locale/*.json"])).toBe( + false + ); + expect(coveredByFiles("./src/*", ["lib"])).toBe(false); + }); + + it("exportsOutsideFiles names a target the files allowlist does not cover (fixture)", () => { + expect( + exportsOutsideFiles({ + exports: { + ".": { import: "./lib/index.mjs", types: "./lib/index.d.mts" }, + "./x": "./src/x.ts", + }, + files: ["lib"], + }) + ).toEqual(["./src/x.ts"]); + expect( + exportsOutsideFiles({ + exports: { + "./client": { default: "./lib/client.js" }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json", + "./locale/en.json": "./locale/en.json", + "./package.json": "./package.json", + }, + files: ["lib", "locale/*.json", "cordis.patch.yml"], + }) + ).toEqual([]); + expect( + exportsOutsideFiles({ exports: { "./src/*": "./src/*" }, files: ["lib"] }) + ).toEqual(["./src/*"]); + }); + + it("every exports target of this package is inside files (real package.json)", () => { + const pkg = readPackage(); + // Non-vacuous: the real map has targets to check. + expect(exportTargets(pkg.exports).length).toBeGreaterThan(3); + expect(exportsOutsideFiles(pkg)).toEqual([]); + }); +}); + +describe("package checks · the publish list and the verify list agree", () => { + it("releasePendingTargets reads the verify line and reports its absence (fixture)", () => { + expect( + releasePendingTargets(' pending="a@$VER b@$VER"\n') + ).toEqual(["a", "b"]); + expect(releasePendingTargets("jobs:\n verify:\n")).toBeUndefined(); + // Two lists are ambiguous: reading either one would leave the other + // unchecked, so the reader refuses rather than picking the first. + expect( + releasePendingTargets( + ' pending="x@$VER"\n pending="a@$VER b@$VER"\n' + ) + ).toBeUndefined(); + // The later unquoted reassignment in the real workflow is not a list. + expect( + releasePendingTargets( + ' pending="a@$VER"\n pending=$(echo "$still" | xargs)\n' + ) + ).toEqual(["a"]); + const mismatch = publishListMismatch(["p", "a"], ["p", "a", "b"]); + expect(mismatch).toHaveLength(1); + expect(mismatch[0]).toContain("b"); + expect(publishListMismatch(["p", "a", "c"], ["p", "a"])[0]).toContain("c"); + expect(publishListMismatch(["p", "a"], ["p", "a"])).toEqual([]); + }); + + it("the scoped publish list and release.yml's verify list agree, and neither names the retired wrapper", () => { + const pkg = readPackage(); + const pending = releasePendingTargets( + readFileSync(join(ROOT, ".github", "workflows", "release.yml"), "utf8") + ); + expect(pending).toEqual([pkg.name, ...SCOPED_TARGETS]); + expect(SCOPED_TARGETS).toEqual(["@viztor/dsh-opencode-patch"]); + expect(pending).not.toContain("@viztor/dsh-opencode"); + }); + + it("the gate and the publish script read those lists rather than keeping their own", () => { + // Structural, because running either script from the suite would copy the + // package per target (publish-scoped) or install it (check). The pure + // helpers are exercised above; this pins that the scripts still use them. + // Comment lines dropped line by line: a block-comment regex would eat + // code between a glob such as `src/*` and the next `*/`. + const code = (file: string): string => + readFileSync(join(ROOT, "scripts", file), "utf8") + .split("\n") + .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/u.test(line)) + .join("\n"); + const publish = code("publish-scoped.ts"); + expect(publish).toMatch( + /import \{[^}]*\bSCOPED_TARGETS\b[^}]*\} from "\.\/scoped-targets\.ts";/u + ); + expect( + [...publish.matchAll(/for \(const \w+ of (\w+)\)/gu)].map((m) => m[1]) + ).toContain("SCOPED_TARGETS"); + expect(publish).not.toContain('"@viztor/dsh-opencode"'); + const check = code("check.ts"); + expect(check).toMatch(/\bexportsOutsideFiles\(/u); + expect(check).toMatch(/\bpublishListMismatch\(/u); + expect(check).toMatch(/\breleasePendingTargets\(/u); + }); +}); diff --git a/test/pi-provider.test.ts b/test/pi-provider.test.ts index 952462ef..791d1602 100644 --- a/test/pi-provider.test.ts +++ b/test/pi-provider.test.ts @@ -5,18 +5,18 @@ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completio import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; import { describe, expect, it } from "vitest"; -import { OPENCODE_PLANE_API } from "../src/catalog-data.ts"; -import type { CatalogModelSpec } from "../src/catalog-data.ts"; -import { ENDPOINT_FAMILIES } from "../src/config-values.ts"; -import { OPENCODE_USER_AGENT } from "../src/identity.ts"; import { BASE_FOR_PROTOCOL, + type CatalogModelSpec, + ENDPOINT_FAMILIES, + OPENCODE_PLANE_API, + OPENCODE_USER_AGENT, baseForProtocol, piModelFor, planeBaseFor, protocolFor, thinkingLevelMapFor, -} from "../src/pi-provider.ts"; +} from "./support/src.ts"; /** * The headers this test feeds the model. diff --git a/test/picker-integration.test.ts b/test/picker-integration.test.ts index 83c525ba..73731aaa 100644 --- a/test/picker-integration.test.ts +++ b/test/picker-integration.test.ts @@ -32,11 +32,11 @@ import { describe, expect, it } from "vitest"; import { + type CordisContext, decorateModelInfo, hideResponsesRoute, resolveConfig, - type CordisContext, -} from "../src/index.ts"; +} from "./support/src.ts"; /** What a deployment declared for its parent route. Three ids, no catalog backfill. */ const DECLARED = [ diff --git a/test/project-name.test.ts b/test/project-name.test.ts index 5ec9211d..a5972ac9 100644 --- a/test/project-name.test.ts +++ b/test/project-name.test.ts @@ -15,7 +15,7 @@ import { basename, join } from "node:path"; import { describe, expect, it } from "vitest"; -import { projectNameFrom } from "../src/stream-hook.ts"; +import { projectNameFrom } from "./support/src.ts"; describe("projectNameFrom", () => { it("takes the folder name from a POSIX path", () => { diff --git a/test/public-api.test.ts b/test/public-api.test.ts new file mode 100644 index 00000000..b246f70a --- /dev/null +++ b/test/public-api.test.ts @@ -0,0 +1,124 @@ +/** + * The package's public API, pinned name by name. + * + * `src/index.ts` is what a consumer of `dsh-opencode-patch` can import, and + * the host reads four names off it: `apply`, `Config`, `inject` and `name`. + * Anything else the barrel exports is a promise the package did not mean to + * make, so the list is pinned here and a change to it is a reviewed change to + * this file, not a side effect of an edit somewhere in `src`. + * + * Two readings, because they can disagree: the SOURCE parse sees type exports, + * which vanish at runtime, and the RUNTIME import sees what a consumer's + * `import *` really gets. The subject of this file is the barrel itself, so it + * imports `src/index.ts` directly rather than through `test/support/src.ts`. + * + * @module test/public-api.test + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { blankCommentLines } from "./support/strip-comments.ts"; + +const ROOT = join(import.meta.dirname, ".."); +const BARREL = join(ROOT, "src", "index.ts"); + +/** The runtime names: what `import * as barrel` answers. */ +const VALUE_EXPORTS: readonly string[] = ["Config", "apply", "inject", "name"]; + +/** + * The type-only names: erased at runtime, still part of the contract. + * `ModelRouteConfig` is here because `PluginConfig.modelRoutes` names it. + */ +const TYPE_EXPORTS: readonly string[] = [ + "EndpointFamily", + "KeySourcePolicy", + "ModelOverrideConfig", + "ModelRouteConfig", + "PluginConfig", + "ResolvedPluginConfig", +]; + +/** + * Every name the barrel's `export { … } from "./…"` blocks pass on, as the + * consumer sees it (an `as` alias is the exported name), with `type ` dropped. + */ +const barrelExportNames = (source: string): string[] => + [...source.matchAll(/^export (?:type )?\{([^}]*)\} from "\.\/[^"]+";/gmu)] + .flatMap((match) => (match[1] ?? "").split(",")) + .map((entry) => + entry + .trim() + .replace(/^type\s+/u, "") + .replace(/^[\w$]+\s+as\s+/u, "") + ) + .filter((entry) => entry !== ""); + +/** + * What is left of the barrel once comments and every block + * {@link barrelExportNames} reads are removed. Empty means the parse was + * total: an `export *`, an `export type *`, a local `export interface` or a + * single-quoted re-export would remain here and fail the pin, instead of + * widening the type half of the contract unseen. + */ +const unparsedRemainder = (source: string): string => + blankCommentLines(source) + .replaceAll(/^export (?:type )?\{[^}]*\} from "\.\/[^"]+";/gmu, "") + .trim(); + +describe("public API · src/index.ts", () => { + it("parses an export block the way a consumer reads it", () => { + // A known answer, so an equal list below means the parser ran. + expect( + barrelExportNames( + 'export { a, type B } from "./x.ts";\nexport { type C } from "./y.ts";\nexport { d as e } from "./z.ts";' + ) + ).toEqual(["a", "B", "C", "e"]); + }); + + it("reads the whole barrel: anything the parser skips is left over", () => { + // Known answers, so an empty remainder below means the check can see. + expect( + unparsedRemainder('/** doc */\n// note\nexport { a } from "./x.ts";\n') + ).toBe(""); + for (const widening of [ + 'export type * from "./x.ts";', + 'export * from "./x.ts";', + "export interface Extra { a: 1 }", + "export type Extra = string;", + "export { a } from './x.ts';", + ]) { + expect( + unparsedRemainder(`export { a } from "./x.ts";\n${widening}`) + ).toBe(widening); + // A `//` comment holding a glob's `/*`, then the widening, then a doc + // block: the `/*` must open nothing that swallows the widening. + expect( + unparsedRemainder( + `// internals live in src/* and stay there\n${widening}\n/** The host entry. */\nexport { apply } from "./lifecycle.ts";\n` + ) + ).toBe(widening); + } + }); + + it("the barrel exports exactly the pinned names (source parse)", () => { + const source = readFileSync(BARREL, "utf8"); + expect(unparsedRemainder(source)).toBe(""); + expect(barrelExportNames(source).toSorted()).toEqual( + [...VALUE_EXPORTS, ...TYPE_EXPORTS].toSorted() + ); + }); + + it("the barrel's runtime surface is the pinned value names, and the four the host calls are real", async () => { + const barrel = await import("../src/index.ts"); + expect(Object.keys(barrel).toSorted()).toEqual(VALUE_EXPORTS.toSorted()); + const pkg: unknown = JSON.parse( + readFileSync(join(ROOT, "package.json"), "utf8") + ); + expect(typeof barrel.apply).toBe("function"); + expect(typeof barrel.Config).toBe("function"); + expect(barrel.inject).toEqual(["llm"]); + expect(barrel.name).toBe((pkg as { name: string }).name); + }); +}); diff --git a/test/purge-contract.test.ts b/test/purge-contract.test.ts index 9d6d5977..0489f81b 100644 --- a/test/purge-contract.test.ts +++ b/test/purge-contract.test.ts @@ -9,7 +9,7 @@ */ import { describe, expect, it } from "vitest"; -import { parsePurgeResult } from "../src/purge-contract.ts"; +import { parsePurgeResult } from "./support/src.ts"; describe("purge contract · the result codec", () => { it("accepts a result and keeps only the fields the card renders", () => { diff --git a/test/purge-service.test.ts b/test/purge-service.test.ts index 55c993eb..daa65693 100644 --- a/test/purge-service.test.ts +++ b/test/purge-service.test.ts @@ -8,11 +8,11 @@ */ import { afterEach, describe, expect, it, vi } from "vitest"; -import { parseModelsDevCatalog } from "../src/models-catalog.ts"; import { ModelPurgeService, + parseModelsDevCatalog, stripRetiredReferences, -} from "../src/purge-service.ts"; +} from "./support/src.ts"; import { createMockContext } from "./test-helpers.ts"; /** A registry reply with one retired id per plane and one live id beside it. */ diff --git a/test/remote-contract.test.ts b/test/remote-contract.test.ts index 7c3eb209..3205665d 100644 --- a/test/remote-contract.test.ts +++ b/test/remote-contract.test.ts @@ -20,7 +20,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { patchRemote } from "../src/settings-page.tsx"; +import { patchRemote } from "./support/src.ts"; const SRC = join(import.meta.dirname, "..", "src"); diff --git a/test/request-shape.test.ts b/test/request-shape.test.ts index c663d3b9..efd06343 100644 --- a/test/request-shape.test.ts +++ b/test/request-shape.test.ts @@ -9,11 +9,11 @@ */ import { describe, expect, it } from "vitest"; -import { catalogPlaneForRoute } from "../src/models-catalog.ts"; -import { INTERNAL_ROUTES } from "../src/responses-routes.ts"; import { ANTHROPIC_VERSION, + INTERNAL_ROUTES, authHeaderFor, + catalogPlaneForRoute, gatewayPlaneFor, headerSourceOf, isGoPlaneUrl, @@ -21,7 +21,7 @@ import { modelIdOf, requestUrlOf, toolNamesOf, -} from "../src/utils/request-shape.ts"; +} from "./support/src.ts"; describe("request-shape · isZenProvider", () => { it("request-shape · isZenProvider: the Zen Google route is Zen, the Go routes are Go", () => { diff --git a/test/response-errors.test.ts b/test/response-errors.test.ts index 1fe55573..6d7ef177 100644 --- a/test/response-errors.test.ts +++ b/test/response-errors.test.ts @@ -28,9 +28,9 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { GatewayErrorPayload } from "./mock/protocols/index.ts"; import { GatewayTestRunner, ZEN_KEY } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/responses-provider.test.ts b/test/responses-provider.test.ts index a004ad21..db7b8fcf 100644 --- a/test/responses-provider.test.ts +++ b/test/responses-provider.test.ts @@ -23,31 +23,29 @@ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai"; */ import { describe, expect, it, vi } from "vitest"; -import { OPENCODE_PLANE_API } from "../src/catalog-data.ts"; -import { resolveConfig } from "../src/config.ts"; import { + type CordisContext, + GOOGLE_INTERNAL_ROUTE, + OPENCODE_PLANE_API, RESPONSES_SDK, ROUTE_FOR_PLANE_PROTOCOL, + SELF_MOUNTED_ROUTES, + baseForProtocol, + declaredProviderModels, getLiveGoCatalog, getLiveZenCatalog, inheritedCredentialRef, internalRouteFor, loadPiAi, + loadPiAiAdapterHost, + loadPiAiModule, modelsForSdk, narrowToDeclared, + reasoningEffortsFor, registerResponsesProvider, undescribedDeclaredModels, unroutedDeclaredModels, - GOOGLE_INTERNAL_ROUTE, - SELF_MOUNTED_ROUTES, -} from "../src/index.ts"; -import type { CordisContext } from "../src/index.ts"; -import { baseForProtocol } from "../src/pi-provider.ts"; -import { - loadPiAiAdapterHost, - loadPiAiModule, - reasoningEffortsFor, -} from "../src/responses-provider.ts"; +} from "./support/src.ts"; const MUSE = "muse-spark-1.2"; @@ -1546,9 +1544,9 @@ describe("responses-provider · declared models a missing route would strand", ( ); it("names the model and the protocol nothing serves", () => { - // The case: `internalRoutes: false`, no declared provider for this protocol, - // and a model the user selected that speaks it. Silence would leave the model - // in the picker with every call failing. + // The case: no declared provider for this protocol, and a model the user + // selected that speaks it. Silence would leave the model in the picker + // with every call failing. if (anthropic === undefined) { throw new Error("the zen catalog serves no anthropic model"); } @@ -1695,7 +1693,6 @@ describe("responses-provider · the declaration needs no credential", () => { // opencode: no models could be read for the route - it declares none, or // its credential is not resolvable in this process - so no internal route // is mounted for it - const { declaredProviderModels } = await import("../src/go-discovery.ts"); const ctx = { loader: { *entries() { @@ -1809,27 +1806,43 @@ describe("Go Google bridge mounting", () => { expect(host.adapters.has("opencode-go-google")).toBe(false); }); - it("hands back the bridges' disposer when the internal routes are off", async () => { - // The bridge mounts before the switch is read, so an exit that answered - // `undefined` here left both Google routes registered with nothing able to - // withdraw them — and the next reload's bridge then failed DUPLICATE_ADAPTER. + it("hands back the bridges' disposer when every internal route is already declared", async () => { + // The bridge mounts before the wrapper's routes are weighed, so an early + // exit that answered `undefined` left both Google routes registered with + // nothing able to withdraw them — and the next reload's bridge then failed + // DUPLICATE_ADAPTER. Every wrapper-mounted route is declared here, which is + // the exit that leaves nothing to mount. const host = createHost(); - const stop = await registerResponsesProvider( - host.ctx, - resolveConfig({ internalRoutes: false }) - ); - expect(host.adapters.has("opencode-google")).toBe(true); - expect(stop).toBeTypeOf("function"); - stop?.(); - expect(host.adapters.has("opencode-google")).toBe(false); - expect(host.adapters.has("opencode-go-google")).toBe(false); - // And a second activation can mount them again, which is what a reload does. - const again = await registerResponsesProvider( - host.ctx, - resolveConfig({ internalRoutes: false }) - ); - expect(host.adapters.has("opencode-google")).toBe(true); - again?.(); + const wrapperRoutes = Object.values(ROUTE_FOR_PLANE_PROTOCOL) + .flatMap((plane) => Object.values(plane.routes)) + .filter((route) => !SELF_MOUNTED_ROUTES.has(route)); + expect(wrapperRoutes.length).toBeGreaterThan(0); + for (const route of wrapperRoutes) { + host.adapters.set(route, { id: route }); + } + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + try { + const stop = await registerResponsesProvider(host.ctx); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("every internal route is already declared") + ); + expect(host.adapters.has("opencode-google")).toBe(true); + expect(host.adapters.has("opencode-go-google")).toBe(true); + expect(stop).toBeTypeOf("function"); + stop?.(); + expect(host.adapters.has("opencode-google")).toBe(false); + expect(host.adapters.has("opencode-go-google")).toBe(false); + // The disposer withdraws only what it mounted: the declared routes stay. + for (const route of wrapperRoutes) { + expect(host.adapters.has(route)).toBe(true); + } + // And a second activation can mount them again, which is what a reload does. + const again = await registerResponsesProvider(host.ctx); + expect(host.adapters.has("opencode-google")).toBe(true); + again?.(); + } finally { + info.mockRestore(); + } }); it("withdraws routes and disposes the fiber when the mount fails to start", async () => { diff --git a/test/responses-routes.test.ts b/test/responses-routes.test.ts index cc0f79c7..5bfc6820 100644 --- a/test/responses-routes.test.ts +++ b/test/responses-routes.test.ts @@ -14,19 +14,19 @@ import { GOOGLE_SDK, GO_RESPONSES_ROUTE, MISTRAL_SDK, + OPENCODE_GO_CATALOG, + OPENCODE_ZEN_CATALOG, + RESPONSES_ROUTE, + RESPONSES_SDK, + ROUTE_FOR_PLANE_PROTOCOL, catalogPlaneForRoute, findModelSpec, findModelSpecOn, internalRouteFor, + isServableModel, isServableSdk, - OPENCODE_GO_CATALOG, - OPENCODE_ZEN_CATALOG, parseModelsDevCatalog, - RESPONSES_ROUTE, - RESPONSES_SDK, - ROUTE_FOR_PLANE_PROTOCOL, -} from "../src/index.ts"; -import { isServableModel } from "../src/models-discovery.ts"; +} from "./support/src.ts"; const MUSE = "muse-spark-1.2"; diff --git a/test/session-cost.test.ts b/test/session-cost.test.ts index 62089062..77e1431b 100644 --- a/test/session-cost.test.ts +++ b/test/session-cost.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + type ModelCostRate, calculateTurnCost, clearSessionUsageStore, describeModel, @@ -18,8 +19,7 @@ import { formatUsd, getSessionUsage, recordTurnUsage, - type ModelCostRate, -} from "../src/index.ts"; +} from "./support/src.ts"; afterEach(() => { vi.unstubAllGlobals(); diff --git a/test/session.test.ts b/test/session.test.ts index 2898c06e..588edd9a 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -10,20 +10,20 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { readSessionMetaResolver } from "../src/cordis-context.ts"; import { + type ActiveTurnState, SESSION_HEADER, + fallbackSessionId, hasSessionHeader, headerValueFor, isOpenCodeMessageId, isOpenCodeSessionId, newOpenCodeMessageId, - fallbackSessionId, openCodeSessionIdFor, + readSessionMetaResolver, sessionIdForRequest, - type ActiveTurnState, withStore, -} from "../src/index.ts"; +} from "./support/src.ts"; import { SESSION_RE, createMockStoreStream } from "./test-helpers.ts"; afterEach(() => { diff --git a/test/settings-boolean-field.test.tsx b/test/settings-boolean-field.test.tsx index 612ce6cf..d82c04f4 100644 --- a/test/settings-boolean-field.test.tsx +++ b/test/settings-boolean-field.test.tsx @@ -11,10 +11,7 @@ import { describe, expect, it, vi } from "vitest"; -import { - type BooleanFieldProps, - SettingsBooleanField, -} from "../src/settings-boolean-field.tsx"; +import { type BooleanFieldProps, SettingsBooleanField } from "./support/src.ts"; import { firstOf } from "./test-helpers.ts"; const baseProps = ( diff --git a/test/settings-choice-field.test.tsx b/test/settings-choice-field.test.tsx index 17440e50..9b0b34c6 100644 --- a/test/settings-choice-field.test.tsx +++ b/test/settings-choice-field.test.tsx @@ -10,10 +10,7 @@ import { describe, expect, it, vi } from "vitest"; -import { - type ChoiceFieldProps, - SettingsChoiceField, -} from "../src/settings-choice-field.tsx"; +import { type ChoiceFieldProps, SettingsChoiceField } from "./support/src.ts"; import { findAll, firstOf } from "./test-helpers.ts"; const OPTIONS = [ diff --git a/test/settings-copy.test.ts b/test/settings-copy.test.ts index 01b8bf42..892a360b 100644 --- a/test/settings-copy.test.ts +++ b/test/settings-copy.test.ts @@ -19,7 +19,7 @@ import assert from "node:assert/strict"; import { describe, expect, it } from "vitest"; -import { en, withBundleCopy, zh } from "../src/settings-copy.ts"; +import { en, withBundleCopy, zh } from "./support/src.ts"; /** A seat whose dictionary predates every key: it answers with the key itself. */ const staleSeat = (key: string): string => key; diff --git a/test/settings-field-shell.test.tsx b/test/settings-field-shell.test.tsx index 6481851c..20ff57ae 100644 --- a/test/settings-field-shell.test.tsx +++ b/test/settings-field-shell.test.tsx @@ -12,10 +12,7 @@ import assert from "node:assert/strict"; import { describe, expect, it, vi } from "vitest"; -import { - type FieldShellProps, - SettingsFieldShell, -} from "../src/settings-field-shell.tsx"; +import { type FieldShellProps, SettingsFieldShell } from "./support/src.ts"; import { collectText, findAll, diff --git a/test/settings-remote.test.ts b/test/settings-remote.test.ts index ab410559..7eac6b90 100644 --- a/test/settings-remote.test.ts +++ b/test/settings-remote.test.ts @@ -14,7 +14,7 @@ */ import { describe, expect, it } from "vitest"; -import { remoteNamespace, remoteNamespaceOf } from "../src/settings-remote.ts"; +import { remoteNamespace, remoteNamespaceOf } from "./support/src.ts"; describe("settings-remote · remoteNamespaceOf", () => { it("answers the namespace's calls when the remote serves them", () => { diff --git a/test/strip-comments.test.ts b/test/strip-comments.test.ts new file mode 100644 index 00000000..a5f0d68b --- /dev/null +++ b/test/strip-comments.test.ts @@ -0,0 +1,89 @@ +/** + * Known answers for the comment blanker the source-reading gates share. + * + * @module test/strip-comments.test + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { blankCommentLines } from "./support/strip-comments.ts"; + +describe("blankCommentLines", () => { + it("blanks line comments and line-opening blocks, keeping the line count", () => { + const source = [ + "/**", + " * doc", + " */", + "const a = 1;", + " // note", + "/* one */ const b = 2;", + "const c = 3; // trailing stays", + ].join("\n"); + expect(blankCommentLines(source).split("\n")).toEqual([ + "", + "", + "", + "const a = 1;", + "", + " const b = 2;", + "const c = 3; // trailing stays", + ]); + }); + + it("opens no block at a `/*` inside a string, so the code after it stays", () => { + const source = 'x("a/*b");\nimport "../src/x.ts";\n// c */\n'; + expect(blankCommentLines(source)).toBe( + 'x("a/*b");\nimport "../src/x.ts";\n\n' + ); + }); + + it("sees a widening between a `//` comment holding `/*` and a later block", () => { + const source = + '// internals live in src/* and stay there\nexport type * from "./config.ts";\n/** The host entry. */\n'; + expect(blankCommentLines(source)).toBe( + '\nexport type * from "./config.ts";\n\n' + ); + }); + + it("carries a block reopened on a closing line on to the lines it spans", () => { + expect(blankCommentLines("/* a */ /* b\nc\n*/\nd")).toBe("\n\n\nd"); + expect(blankCommentLines("/* a */ /*\n * process.env\n */\ncode();")).toBe( + "\n\n\ncode();" + ); + }); + + it("reads a closing line's tail as code, a line comment, or a closed block", () => { + expect(blankCommentLines("/* a */ // b\ncode();")).toBe("\ncode();"); + expect(blankCommentLines("/* a */ /* b */ code();")).toBe(" code();"); + }); + + it("throws on a block that never closes, naming the line it opened on", () => { + // A template-literal line that begins with `/*` and is never closed would + // otherwise blank every line after it, the real import included. + const source = + 'const s = `\n/* glob\nimport "../src/x.ts";\n`;\nimport "../src/y.ts";\n'; + expect(() => blankCommentLines(source)).toThrow(/line 2/u); + }); + + it("hides no code line of the test file that once lost seventy", () => { + // test/package-checks.test.ts holds `"locale/*.json"` long before a `*/` + // in a comment; the old whole-file regex blanked the code between them. + const raw = readFileSync( + join(import.meta.dirname, "package-checks.test.ts"), + "utf8" + ); + expect(raw).toContain("locale/*.json"); + const blanked = blankCommentLines(raw).split("\n"); + const hidden = raw + .split("\n") + .filter( + (line, index) => + blanked[index] === "" && + line.trim() !== "" && + !/^\s*(?:\/\/|\/\*|\*)/u.test(line) + ); + expect(hidden).toEqual([]); + }); +}); diff --git a/test/support/src.ts b/test/support/src.ts index a8fe8132..a8ea1fa2 100644 --- a/test/support/src.ts +++ b/test/support/src.ts @@ -1,19 +1,27 @@ /** * The single indirection through which tests reach `src`. * - * P2 shrinks the public barrel to `apply`, `Config`, `inject`, `name` and the - * config types, and P10a moves modules into subject folders. A test that names - * a `src` path breaks on every such move, so this file is the one place that - * knows where things live: each name is re-exported from the module that - * DEFINES it, never through the public barrel, so shrinking the barrel does not - * touch a test. + * The package's public barrel is the host's contract — `apply`, `Config`, + * `inject`, `name` and the config types — and nothing else, and P10a + * moves modules into subject folders. A test that names a `src` path breaks on + * every such move, so this file is the one place that knows where things live: + * each name is re-exported from the module that DEFINES it, never through the + * public barrel. `test/architecture.test.ts` enforces both halves. * - * Three kinds of import keep their direct paths: a `vi.mock("../src/x.ts")` - * target (vitest mocks by module id), and the architecture tests, which are - * about the `src` layout itself — for both, the path is their subject; and the - * jsdom (client) tests, which import the defining module directly, because this - * file re-exports the host graph (`node:sqlite`, pi, `lifecycle.ts`) and that - * must stay out of a client test. + * What keeps its direct path, because the path is its subject or the door + * would load too much: + * + * - a `vi.mock("../src/x.ts")` target and its `importOriginal` type (vitest mocks by module id); + * - a fresh-module re-import after `vi.resetModules()` (`purge-service.test.ts`); + * - the architecture and public-API tests, which are about the `src` layout + * and the barrel themselves; + * - the jsdom (client) tests, because this file re-exports the host graph + * (`node:sqlite`, pi, `lifecycle.ts`) and that must stay out of a client + * test; + * - the setup file `test/support/reset-process-state.ts`, which runs before + * every test, jsdom ones included, for the same reason; the other setup + * file, `offline.ts`, touches no `src` module and is walked like any test. * * Importing this file loads the whole `src` graph, host and client, so a module * that fails to load fails every test that comes through here, not only its own. @@ -97,7 +105,7 @@ export { resolveGoApiKey, resolveGoBaseURL, resolveGoKeyForRef, - resolveRoutedKey, + resolveOpencodeStoreKey, resolveZenCreditInfo, toGoBaseURL, } from "../../src/go-discovery.ts"; diff --git a/test/support/strip-comments.ts b/test/support/strip-comments.ts new file mode 100644 index 00000000..835f062f --- /dev/null +++ b/test/support/strip-comments.ts @@ -0,0 +1,70 @@ +/** + * Comment blanking for the source-reading gates, one line at a time. + * + * The gates used to strip block comments with a regex over the whole file, + * `/\/\*[\s\S]*?\*\//`, before the line comments. That regex cannot see + * string literals or `//` comments, so a glob such as `"locale/*.json"` opened + * a fake block that ran to the next `*\/` and blanked the code in between — + * seventy lines of one test file, silently. A gate that misses code is worse + * than one that flags a comment, so this reader is built to err loudly: + * + * - a line whose first non-blank text is `//` is blanked; + * - a block comment is recognised only where it OPENS a line, and runs to the + * first `*\/`; whatever follows that `*\/` on its closing line is read the + * same way, so it may be code, a `//` comment, or another block — one that + * may itself run on to later lines; + * - a `/*` anywhere else — in a string, after code, in a JSX `{/* … *\/}` — is + * left as text, so at worst a comment's words are read as code; + * - a block still open at the end of the source throws: an unterminated block + * comment is a syntax error in real TypeScript, so its `/*` was not a + * comment, and the gate fails rather than passing on a blanked file. + * + * One blind spot remains, and it is narrow: a line INSIDE a multi-line string + * or template literal that begins with `/*` and is closed by a later real + * `*\/` is blanked as a comment, along with the lines between. The CSS + * comments inside `src/usage-ui.ts`'s template literal are that shape, and are + * harmless because they are comments. + * + * Line breaks are kept, so a `file:line` computed on the result is true. + * + * @module test/support/strip-comments + */ + +/** `source` with its comment lines blanked and its line count unchanged. */ +export const blankCommentLines = (source: string): string => { + let inBlock = false; + let openedAt = 0; + // One line, or the tail of one after a `*/`, read with the block state the + // whole source shares, so a block that opens in a tail runs on to the next + // lines. + const blankLine = (text: string, lineNumber: number): string => { + let rest = text; + if (!inBlock) { + const trimmed = rest.trimStart(); + if (trimmed.startsWith("//")) { + return ""; + } + if (!trimmed.startsWith("/*")) { + return rest; + } + inBlock = true; + openedAt = lineNumber; + rest = trimmed.slice(2); + } + const close = rest.indexOf("*/"); + if (close === -1) { + return ""; + } + inBlock = false; + return blankLine(rest.slice(close + 2), lineNumber); + }; + const lines = source + .split("\n") + .map((line, index) => blankLine(line, index + 1)); + if (inBlock) { + throw new Error( + `blankCommentLines: the block comment opened on line ${openedAt} never closes, so its \`/*\` is not a comment (a string or template literal?)` + ); + } + return lines.join("\n"); +}; diff --git a/test/turn-store.test.ts b/test/turn-store.test.ts index e9c6590b..bdd45853 100644 --- a/test/turn-store.test.ts +++ b/test/turn-store.test.ts @@ -20,7 +20,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { describe, expect, it } from "vitest"; -import { type ActiveTurnState, withStore } from "../src/index.ts"; +import { type ActiveTurnState, withStore } from "./support/src.ts"; import { collectUnknown, createMockStoreStream } from "./test-helpers.ts"; /** A fresh store per case: the module has no shared state, and neither should a test. */ diff --git a/test/usage-contract.test.ts b/test/usage-contract.test.ts index 08d8dd87..8d064b7d 100644 --- a/test/usage-contract.test.ts +++ b/test/usage-contract.test.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from "vitest"; -import { parseGoUsage, parseUsageQuery } from "../src/index.ts"; +import { parseGoUsage, parseUsageQuery } from "./support/src.ts"; /** One well-formed quota window, with a single field overridden. */ const windowRow = ( diff --git a/test/usage-panel.test.tsx b/test/usage-panel.test.tsx index dc2ec501..ecf77cab 100644 --- a/test/usage-panel.test.tsx +++ b/test/usage-panel.test.tsx @@ -12,23 +12,22 @@ import assert from "node:assert/strict"; import { MenuSurface } from "@deepseek-ai/dsh-client-ui-primitives"; import { describe, expect, it, vi } from "vitest"; -import { GO_MODEL_LIMITS } from "../src/go-limits-data.ts"; -import type { SessionUsageSnapshot } from "../src/session-cost.ts"; -import { en as copyEn, zh as copyZh } from "../src/settings-copy.ts"; -import type { GoUsage } from "../src/usage-contract.ts"; import { + CONSOLE_URL, + GO_MODEL_LIMITS, + GO_PLAN_URL, + type GoUsage, + type RelativeReset, + STYLES, + type SessionUsageSnapshot, UsagePanel, type UsagePanelProps, UsageTrigger, type UsageTriggerProps, -} from "../src/usage-panel.tsx"; -import { - CONSOLE_URL, - GO_PLAN_URL, - type RelativeReset, + en as copyEn, resetLabel, - STYLES, -} from "../src/usage-ui.ts"; + zh as copyZh, +} from "./support/src.ts"; import { childrenOf, collectText, diff --git a/test/usage-pill.test.tsx b/test/usage-pill.test.tsx index e643b3de..fb9c20fc 100644 --- a/test/usage-pill.test.tsx +++ b/test/usage-pill.test.tsx @@ -11,14 +11,12 @@ vi.mock("react", async (importOriginal) => { }; }); -import type { GoUsage } from "../src/usage-contract.ts"; import { + CIRCUMFERENCE, + type GoUsage, type ModelDirectoryState, type SnapshotStore, UsagePill, -} from "../src/usage-pill.tsx"; -import { - CIRCUMFERENCE, describeUsage, formatRelativeReset, getAffectingWindow, @@ -27,7 +25,7 @@ import { matchesAny, parseFailure, ringGeometry, -} from "../src/usage-ui.ts"; +} from "./support/src.ts"; const createMockUsage = (overrides?: Partial): GoUsage => ({ monthly: { diff --git a/test/usage-service.test.ts b/test/usage-service.test.ts index 1c5fe122..8ef33fc6 100644 --- a/test/usage-service.test.ts +++ b/test/usage-service.test.ts @@ -22,7 +22,7 @@ import { clearSessionUsageStore, recordCapturedApiKey, recordTurnUsage, -} from "../src/index.ts"; +} from "./support/src.ts"; import { createMockContext, headerOf, diff --git a/test/usage.test.ts b/test/usage.test.ts index be81b861..9b00ac62 100644 --- a/test/usage.test.ts +++ b/test/usage.test.ts @@ -10,22 +10,21 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { GoUsageService, + KEY_SOURCE_POLICIES, clearCapturedApiKeys, clearSessionUsageStore, discoverGoConfig, extractApiKeyFromHeaders, getCapturedApiKey, isPlaceholderApiKey, - KEY_SOURCE_POLICIES, parseGoUsage, parseUsageQuery, recordCapturedApiKey, recordTurnUsage, resolveGoApiKey, - resolveRoutedKey, resolveZenCreditInfo, tierForRequest, -} from "../src/index.ts"; +} from "./support/src.ts"; import { createMockContext } from "./test-helpers.ts"; afterEach(() => { @@ -623,28 +622,5 @@ describe("OpenCode Go Usage", () => { ).toBe("sk-captured-key"); } }); - - it("resolveRoutedKey honours the same order", async () => { - process.env.OPENCODE_GO_API_KEY = "sk-env-key"; - captureGoKey(); - const automatic = await resolveRoutedKey( - withoutDeclarations, - "opencode-go", - "auto" - ); - expect(automatic.key).toBe("sk-captured-key"); - const requested = await resolveRoutedKey( - withoutDeclarations, - "opencode-go", - "request" - ); - const declared = await resolveRoutedKey( - withoutDeclarations, - "opencode-go", - "configured" - ); - expect(requested.key).toBe("sk-captured-key"); - expect(declared.key).toBe("sk-env-key"); - }); }); }); diff --git a/test/wrapper-forwarder.test.ts b/test/wrapper-forwarder.test.ts deleted file mode 100644 index 7c9890dd..00000000 --- a/test/wrapper-forwarder.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * The deprecated wrapper's entry, linked the way a host links it. - * - * `@viztor/dsh-opencode` is a forwarder: its `lib/index.mjs` re-exports the - * canonical package. A re-export of a name the barrel does not have is not a - * missing value but a LINK error, raised before a line of either module runs — - * so the only honest test is a real Node process importing the real built - * barrel. An in-process `import()` would go through vite-node's transform, - * which turns a missing named re-export into `undefined` and stays green. - * - * The entries are written by `writeWrapperEntries`, the function - * `publish-scoped.ts` calls, so the bytes linked here are the bytes it ships. - * A second case links the forwarder that shipped broken, so every run shows the - * probe is sensitive to the defect rather than trusting that it would be. - * - * Reads `lib/index.mjs`, so `pnpm run build` must have run. - * - * @module test/wrapper-forwarder.test - */ -import { spawnSync } from "node:child_process"; -import type { SpawnSyncReturns } from "node:child_process"; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - symlinkSync, - unlinkSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { describe, expect, it } from "vitest"; - -import { - WRAPPER_FORWARDER, - writeWrapperEntries, -} from "../scripts/wrapper-forwarder.ts"; - -const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); - -if (!existsSync(join(ROOT, "lib/index.mjs"))) { - throw new Error( - "lib/index.mjs is missing — run `pnpm run build` before the tests" - ); -} - -/** Import both modules and report their surfaces, as one line of JSON. */ -const PROBE = [ - 'import * as w from "./lib/index.mjs";', - 'import * as b from "dsh-opencode-patch";', - "console.log(JSON.stringify({", - " wrapper: Object.keys(w).sort(),", - " barrel: Object.keys(b).sort(),", - ' hasDefault: Object.hasOwn(w, "default"),', - "}));", -].join("\n"); - -/** The forwarder every release up to this fix published. */ -const SHIPPED_BROKEN = [ - 'export * from "dsh-opencode-patch";', - 'export { default } from "dsh-opencode-patch";', - "", -].join("\n"); - -interface ProbeAnswer { - readonly barrel: string[]; - readonly hasDefault: boolean; - readonly wrapper: string[]; -} - -/** - * Lay out a scratch install of the wrapper and run the probe in it. - * - * The scratch directory stands in for an install of the wrapper: its dependency - * resolves to this checkout, whose `exports` name the built barrel. - * - * @param write - writes the wrapper's entries into the scratch `lib/`. - * @returns the probe's process result and the bytes of the typings entry. - */ -const probeWith = ( - write: (libDir: string) => void -): { run: SpawnSyncReturns; typings: string | undefined } => { - const scratch = mkdtempSync(join(tmpdir(), "dsh-opencode-wrapper-")); - const link = join(scratch, "node_modules", "dsh-opencode-patch"); - try { - mkdirSync(join(scratch, "node_modules")); - mkdirSync(join(scratch, "lib")); - symlinkSync(ROOT, link, "dir"); - write(join(scratch, "lib")); - const typingsPath = join(scratch, "lib", "index.d.mts"); - const typings = existsSync(typingsPath) - ? readFileSync(typingsPath, "utf8") - : undefined; - const run = spawnSync( - process.execPath, - ["--input-type=module", "-e", PROBE], - { cwd: scratch, encoding: "utf8", timeout: 60_000 } - ); - return { run, typings }; - } finally { - if (existsSync(link)) { - unlinkSync(link); - } - rmSync(scratch, { force: true, recursive: true }); - } -}; - -describe("the deprecated wrapper's forwarder", () => { - it("the deprecated wrapper's entry links against the built barrel and re-exports exactly its surface", () => { - const { run, typings } = probeWith(writeWrapperEntries); - - expect(run.stderr).not.toContain("does not provide an export named"); - expect(run.status, run.stderr).toBe(0); - const answer = JSON.parse(run.stdout) as ProbeAnswer; - expect(answer.barrel).toContain("apply"); - expect(answer.barrel).toContain("Config"); - // `export *` never forwards `default`: if the barrel ever gained one, the - // wrapper would silently drop it, and this is where that shows. - expect(answer.wrapper).toEqual(answer.barrel); - expect(answer.hasDefault).toBe(false); - expect(typings).toBe(WRAPPER_FORWARDER); - }, 60_000); - - it("the probe fails on the forwarder that shipped broken", () => { - const { run } = probeWith((libDir) => { - writeFileSync(join(libDir, "index.mjs"), SHIPPED_BROKEN); - }); - - expect(run.status).not.toBe(0); - expect(run.stderr).toContain("does not provide an export named 'default'"); - }, 60_000); -}); diff --git a/vite.config.ts b/vite.config.ts index 97ea0cb1..5f2fff6e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -148,8 +148,8 @@ export default defineConfig({ " return module.exports;", " };", // The two names this bundle is published as, plus the pre-rename - // component id an old cordis row resolves through. Not the deprecated - // `@viztor/dsh-opencode` wrapper — it ships no client half (AGENTS.md). + // component id an old cordis row resolves through. Not + // `@viztor/dsh-opencode`, which is no longer published. ' window.__ModuleLoader__.load({ id: "dsh-opencode-patch", factory: factory });', ' try { window.__ModuleLoader__.load({ id: "@viztor/dsh-opencode-patch", factory: factory }); } catch (e) {}', ' try { window.__ModuleLoader__.load({ id: "dsh-opencode", factory: factory }); } catch (e) {}',