diff --git a/AGENTS.md b/AGENTS.md index 8bb8e386b8..3febc96722 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ The project structure is: * The trust boundary is `tools.ts`, and nothing outside it reads a tool's annotations: a tool the server declares `readOnlyHint: true` runs as an observation, everything else is queued for approval, and auto-*applying* a write additionally requires a `vetted` endpoint — which only the portal can produce, via `MCP_PORTAL_TRUST_ANNOTATIONS`. * OAuth uses the official `@modelcontextprotocol/client`; always give SDK OAuth operations `sdkFetch(...)` so every request and redirect retains endpoint and SSRF checks. * packages/gatekeeper-context: The Context Library — a gatekeeper whose account provides a singleton read session + a management UI, for authoring collections of context documents that agents read as observations. Collections have one of two visibilities: **private** (owned by a single account, readable/writable only by that account) and **public** (created/edited only by deployment admins, readable by everyone and auto-enabled for all users). It owns its state in three Durable Objects (`ContextCollectionDurableObject` for content, `UserLibraryDurableObject` for each account's own private collections, `LibraryRegistryDurableObject` for the domain's public set) plus a KV namespace. All data is namespaced by a `sharingDomain` (from the binding's props, see `domain.ts`) so multiple workshops sharing one gatekeeper instance stay isolated. - * Its `GatekeeperVendor` entrypoint (bound as `GATEKEEPER_CONTEXT`) declares `autoProvisionsAccount` and mints a `ContextAccount` via `createAccount()` (no user identity is passed in; the account keys its private data by its own generated `accountId`). The account exposes the agent read session (`getSession()`), collection discovery metadata (`getAgentCatalog()`), and a management UI (`startAppUi({ isAdmin })`). The UI is a single-file React SPA in `app/` (Vite + Tailwind + Kumo) bundled by `build-app.mjs` into `src/generated/app.txt`. + * Its `GatekeeperVendor` entrypoint (bound as `GATEKEEPER_CONTEXT`) declares `autoProvisionsAccount` and mints a `ContextAccount` via `createAccount()` (no user identity is passed in; the account keys its private data by its own generated `accountId`). The account exposes the agent read session (`getSession()`), collection discovery metadata (`getAgentCatalog()`), and a management UI (`startAppUi({ isAdmin })`). The UI is a single-file React SPA in `app/` (Vite + Tailwind + Kumo) bundled by `build-app.ts` into `src/generated/app.txt`. * packages/gatekeeper-scheduler: Scheduled Tasks — an auto-provisioned gatekeeper whose account provides an ambient singleton for registering persistent workspace callbacks plus a read-only management UI. One account-scoped `ScheduleDriver` Durable Object stores enabled schedules and delivers them from a shared alarm; hook enablement remains in the Workshop Connections UI. * packages/gatekeeper-cloudflare: Cloudflare OAuth, serving three unrelated purposes from one connected account — sign-in (`AUTH_GATEKEEPERS`), AI Gateway billing, and **Workers Observability** read-only telemetry resources. Two resource granularities (whole account, or one Worker) both map to the single indivisible `workers-observability.read` scope, so the capability boundary is the *binding*, not the grant. * Layering: `observability-api.ts` is the only place that talks HTTP; `observability-parse.ts` validates every response (no blind casts); `observability-session.ts` is the agent-facing session, where every read funnels through one `#observe` so no path returns data unaudited; `observability-discovery.ts` derives field names/values from a sampled events query. @@ -40,7 +40,7 @@ The project structure is: * Worker-scoped bindings prepend an immutable `$metadata.service` filter *and* re-filter the response, since a filter the provider silently ignored would leak another Worker's telemetry. A dropped event proves the filter was not applied, so the provider's own `count` is then withheld rather than reported (it would count the whole account) and the drop is logged at `error`; `statistics` is kept, since it describes what the query cost rather than how much matched. Pagination cursors come from the provider's raw events rather than the surviving ones, so a fully-foreign page can't stall pagination and hide the caller's own older data. Trace *summaries* are account-only (their shape describes the whole cross-service trace); a Worker binding can still fetch its own events for a known trace id. `calculate()` is the one read with no second line of defence — an aggregate can't be un-mixed — so it rests solely on the injected filter; that is accepted and documented on the method, with the group-by fix left as a follow-up. * A provider error message can quote a caller-supplied filter value back, so only its numeric `codes` are logged — filter *values* stay out of the audit trail (`summarizeFilter`), and the message travels to the caller who caused it. * Tests are two vitest projects: `vitest.config.ts` (Node, pure logic) and `vitest.worker.config.ts` (workerd, for `RpcTarget`/`RpcStub`/Durable Objects). The workerd suite reaches the gatekeeper through a `TestHooks` Durable Object because a `DurableObjectClass` carrying `ctx.props` is only reachable via `ctx.facets` — the way the overseer instantiates it. - * `src/configurator/*.tsx` duplicate the resource-URL grammar from `resources.ts` and **must**: `build-gatekeeper-configurator.mjs` transpiles each per-file, stripping only `@gadgets/configurator-ui` and type-only imports, so they cannot import runtime helpers. `__tests__/configurator-url.test.ts` keeps the copies in step, and `configurator-fields.test.ts` drives `render` against a mocked runtime — the runtime's `clearFields` only drops an autocomplete's typed query, so a dependent field must *also* be nulled through `setValues` or the stale value silently survives into the resource URL. + * `src/configurator/*.tsx` duplicate the resource-URL grammar from `resources.ts` and **must**: `build-gatekeeper-configurator.ts` transpiles each per-file, stripping only `@gadgets/configurator-ui` and type-only imports, so they cannot import runtime helpers. `__tests__/configurator-url.test.ts` keeps the copies in step, and `configurator-fields.test.ts` drives `render` against a mocked runtime — the runtime's `clearFields` only drops an autocomplete's typed query, so a dependent field must *also* be nulled through `setValues` or the stale value silently survives into the resource URL. * packages/router: The public origin of a deployed gadgets instance. Serves the workshop-frontend assets and routes by path prefix: `/api/*` and `/blueprint-screenshot/*` to the workshop backend, `/gatekeeper//*` to whichever gatekeepers are bound (discovered by scanning its own `GATEKEEPER_*` service bindings, so installing a gatekeeper is purely a binding change). The same worker doubles as the dev router (`pnpm dev-server`): with no `ASSETS` binding it proxies frontend requests to the Vite dev server instead. Frontend conventions (Workshop, gatekeeper management apps, and shared UI): @@ -98,9 +98,9 @@ To test changes: - The generated dev configs spawn each worker's `build.command` binary directly (`node `, resolved through the package's own `node_modules`) instead of through `pnpm exec`, which costs ~0.33s of process startup per call — paid for every worker, and again on each rebuild, all of it on the startup critical path. Commands that don't resolve are left as written. `wrangler dev` is reached the same way. - The app watchers are deferred until Wrangler is listening (TCP poll, 60s backstop): `vite build --watch` can't skip its initial build and these are the largest builds in the repo. Hence `spawn` rather than `execFileSync` at the tail of `run-dev-server.ts` — but shutdown is still driven by Wrangler's exit, because Ctrl-C reaches the whole process group and exiting out from under Wrangler would orphan its workerd children. - `build:app` is a Vite+ task in each gatekeeper's `vite.config.ts` rather than a package.json script so its `input` can be stated explicitly: `{auto: true}` minus `**/dist-app/**`, `**/src/generated/**` and `**/.wrangler/**` at `base: "workspace"`, plus an explicit `output`. Automatic tracking alone never cached it, because the build writes into the same package tracking hashes as its input. Two traps: the exclusions have to be workspace-wide or the gatekeepers invalidate each other, and only directory *contents* can be excluded, not the directories themselves, so `pnpm clean` still costs one cold build. -- `build:app:dev` is the same build with `minify: false`, run by the `pnpm dev-server` pre-flight so its `app.txt` matches what the watcher's un-skippable initial build will write — otherwise `emitAppText` rewrites the file and Wrangler restarts the worker mid-startup. It captures only `app.txt`, since `dist-app/` has no reader outside `vite.app.config.ts`. `build` and `deploy` still use `build:app`, so nothing unminified ships, and `build-app.mjs` always sets `GATEKEEPER_APP_UNMINIFIED` explicitly — an inherited value would otherwise make a production build unminified and get it cached that way. +- `build:app:dev` is the same build with `minify: false`, run by the `pnpm dev-server` pre-flight so its `app.txt` matches what the watcher's un-skippable initial build will write — otherwise `emitAppText` rewrites the file and Wrangler restarts the worker mid-startup. It captures only `app.txt`, since `dist-app/` has no reader outside `vite.app.config.ts`. `build` and `deploy` still use `build:app`, so nothing unminified ships, and `build-app.ts` always sets `GATEKEEPER_APP_UNMINIFIED` explicitly — an inherited value would otherwise make a production build unminified and get it cached that way. - Two structural constraints explain the file layout. Vite+ reads per-package settings only from `vite.config.*`, which the SPA's own build config occupied, so that moved to `vite.app.config.ts` (referenced by `build-app.mjs -c`, `tsconfig.vite.json` and gatekeeper-context's `__tests__/vite-config.test.ts`). And a task may not share a name with a package.json script, so the `build:app` script is gone and `build` calls `vp run --cache build:app` instead, `deploy` the same with `--no-cache`. Don't define the task in the workspace-root config: it gets created for *every* package, including the root, which then fails. + Two structural constraints explain the file layout. Vite+ reads per-package settings only from `vite.config.*`, which the SPA's own build config occupied, so that moved to `vite.app.config.ts` (referenced by `build-app.ts -c`, `tsconfig.vite.json` and gatekeeper-context's `__tests__/vite-config.test.ts`). And a task may not share a name with a package.json script, so the `build:app` script is gone and `build` calls `vp run --cache build:app` instead, `deploy` the same with `--no-cache`. Don't define the task in the workspace-root config: it gets created for *every* package, including the root, which then fails. - The packages whose tests run in workerd (`router`, `typed-storage`, `backend-utils`, `workshop-backend`, `gatekeeper-scheduler`, `gatekeeper-cloudflare`, `gatekeeper-kit`) load `scripts/assert-workerd.ts` as a `setupFiles` entry. It throws unless `navigator.userAgent` is `Cloudflare-Workers`, so a `@cloudflare/vitest-pool-workers` pool that fails to start fails the suite instead of silently falling back to Node — which otherwise looks like a pass in the packages that import no `cloudflare:*` module. Don't remove it to make a suite green. Linting (oxlint, via Vite+): diff --git a/packages/gatekeeper-cloudflare/__tests__/configurator-fields.test.ts b/packages/gatekeeper-cloudflare/__tests__/configurator-fields.test.ts index 73fdc4738c..8c9cd94fd1 100644 --- a/packages/gatekeeper-cloudflare/__tests__/configurator-fields.test.ts +++ b/packages/gatekeeper-cloudflare/__tests__/configurator-fields.test.ts @@ -1,5 +1,5 @@ // `clearFields` in the sandbox runtime only deletes the autocomplete's typed query -// (`build-gatekeeper-configurator.mjs`: `delete queryByName[name]`) -- it does not touch `values`. +// (`build-gatekeeper-configurator.ts`: `delete queryByName[name]`) -- it does not touch `values`. // So a dependent field has to be nulled through `setValues` as well, and forgetting the second half // is invisible: `isReady` still passes and `resourceUrl` still builds, just pairing the new account // with the old account's Worker. This test drives the real `render` against a recording JSX runtime diff --git a/packages/gatekeeper-cloudflare/__tests__/configurator-url.test.ts b/packages/gatekeeper-cloudflare/__tests__/configurator-url.test.ts index 1257bd86e4..7e3fd7e444 100644 --- a/packages/gatekeeper-cloudflare/__tests__/configurator-url.test.ts +++ b/packages/gatekeeper-cloudflare/__tests__/configurator-url.test.ts @@ -1,6 +1,6 @@ // The two configurator UIs build the resource URL that `parseObservabilityResourceUrl` then has to // accept, but they cannot share the builders in `resources.ts`: each configurator module is -// transpiled on its own by `scripts/build-gatekeeper-configurator.mjs`, which only strips +// transpiled on its own by `scripts/build-gatekeeper-configurator.ts`, which only strips // `@gadgets/configurator-ui` and type-only imports, so a runtime import would not resolve inside the // sandboxed frame. The duplication is therefore deliberate, and this test is what keeps the copies // honest: it runs each configurator's real `resourceUrl` and requires the result to round-trip through diff --git a/packages/gatekeeper-context/build-app.mjs b/packages/gatekeeper-context/build-app.ts similarity index 100% rename from packages/gatekeeper-context/build-app.mjs rename to packages/gatekeeper-context/build-app.ts diff --git a/packages/gatekeeper-context/src/library-gatekeeper.ts b/packages/gatekeeper-context/src/library-gatekeeper.ts index 3c09a1b9e1..2e9e7a1cc1 100644 --- a/packages/gatekeeper-context/src/library-gatekeeper.ts +++ b/packages/gatekeeper-context/src/library-gatekeeper.ts @@ -149,7 +149,7 @@ export class ContextAccount let ui = new RpcStub(new ContextApiImpl( this.env, this.ctx.props.sharingDomain, this.ctx.props.accountId, context.isAdmin, this.#collections(), this.#userLibraries(), this.#registries())); - // Bundled file-manager SPA (generated by build-app.mjs). + // Bundled file-manager SPA (generated by build-app.ts). return { iframeHtml: APP_HTML, ui }; } diff --git a/packages/gatekeeper-context/vite.app.config.ts b/packages/gatekeeper-context/vite.app.config.ts index b021357c9a..4989d9b8ce 100644 --- a/packages/gatekeeper-context/vite.app.config.ts +++ b/packages/gatekeeper-context/vite.app.config.ts @@ -9,7 +9,7 @@ import { viteSingleFile } from 'vite-plugin-singlefile' const pkgDir = dirname(fileURLToPath(import.meta.url)) -// `--watch` drives the `pnpm dev-server` hot loop (via build-app.mjs); one-shot build otherwise. +// `--watch` drives the `pnpm dev-server` hot loop (via build-app.ts); one-shot build otherwise. const isWatch = process.argv.includes('--watch') // Minification is the only thing that differs between the watch build and the one-shot build, so @@ -39,7 +39,7 @@ function emitAppText(frontendErrorReporting: boolean): Plugin { } const outFile = resolve(pkgDir, 'src', 'generated', 'app.txt') const contents = - `\n` + html + `\n` + html if (existsSync(outFile) && readFileSync(outFile, 'utf8') === contents) { console.log(`app.txt unchanged (${(html.length / 1024).toFixed(0)} KiB), skipping write`) return diff --git a/packages/gatekeeper-context/vite.config.ts b/packages/gatekeeper-context/vite.config.ts index a0f835623b..a0d13bff2d 100644 --- a/packages/gatekeeper-context/vite.config.ts +++ b/packages/gatekeeper-context/vite.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ // the gatekeepers would invalidate each other. Only directory *contents* can be excluded, not // the directories, so a deleted output tree still costs one cold build. 'build:app': { - command: 'node build-app.mjs', + command: 'node build-app.ts', dependsOn: ['clean:error-reporting-artifacts'], input: [ { auto: true }, @@ -52,7 +52,7 @@ export default defineConfig({ // rebuilds it moments later (`emptyOutDir`), so restoring it would be megabytes of writes on // the startup critical path. 'build:app:dev': { - command: 'node build-app.mjs --dev', + command: 'node build-app.ts --dev', dependsOn: ['clean:error-reporting-artifacts'], input: [ { auto: true }, diff --git a/packages/gatekeeper-scheduler/build-app.mjs b/packages/gatekeeper-scheduler/build-app.ts similarity index 100% rename from packages/gatekeeper-scheduler/build-app.mjs rename to packages/gatekeeper-scheduler/build-app.ts diff --git a/packages/gatekeeper-scheduler/vite.config.ts b/packages/gatekeeper-scheduler/vite.config.ts index aeda8b1ef8..a696c3a613 100644 --- a/packages/gatekeeper-scheduler/vite.config.ts +++ b/packages/gatekeeper-scheduler/vite.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ // the gatekeepers would invalidate each other. Only directory *contents* can be excluded, not // the directories, so a deleted output tree still costs one cold build. "build:app": { - command: "node build-app.mjs", + command: "node build-app.ts", dependsOn: ["clean:error-reporting-artifacts"], input: [ { auto: true }, @@ -53,7 +53,7 @@ export default defineConfig({ // rebuilds it moments later (`emptyOutDir`), so restoring it would be megabytes of writes on // the startup critical path. "build:app:dev": { - command: "node build-app.mjs --dev", + command: "node build-app.ts --dev", dependsOn: ["clean:error-reporting-artifacts"], input: [ { auto: true }, diff --git a/packages/workshop-backend/package.json b/packages/workshop-backend/package.json index 1efbe06c43..b8ab8e9d7d 100644 --- a/packages/workshop-backend/package.json +++ b/packages/workshop-backend/package.json @@ -6,11 +6,11 @@ "scripts": { "dev": "echo \"run 'pnpm dev-server' in the root directory instead\" >&2 && exit 1", "deploy": "wrangler deploy", - "build:worker": "node build-browser-runtime.mjs && pnpm exec capnweb-validate build --out .wrangler/validate", + "build:worker": "node scripts/build-browser-runtime.ts && pnpm exec capnweb-validate build --out .wrangler/validate", "clean": "rm -rf dist", - "test:integration": "node build-browser-runtime.mjs && node scripts/build-bundled-blueprints.ts && vitest run --config vitest.integration.config.ts", - "test:run": "node build-browser-runtime.mjs && node scripts/build-bundled-blueprints.ts && vitest run && vitest run --config vitest.integration.config.ts", - "test:watch": "node build-browser-runtime.mjs && node scripts/build-bundled-blueprints.ts && vitest", + "test:integration": "node scripts/build-browser-runtime.ts && node scripts/build-bundled-blueprints.ts && vitest run --config vitest.integration.config.ts", + "test:run": "node scripts/build-browser-runtime.ts && node scripts/build-bundled-blueprints.ts && vitest run && vitest run --config vitest.integration.config.ts", + "test:watch": "node scripts/build-browser-runtime.ts && node scripts/build-bundled-blueprints.ts && vitest", "import:bundled-blueprint": "node scripts/import-bundled-blueprint.ts" }, "dependencies": { diff --git a/packages/workshop-backend/build-browser-runtime.mjs b/packages/workshop-backend/scripts/build-browser-runtime.ts similarity index 91% rename from packages/workshop-backend/build-browser-runtime.mjs rename to packages/workshop-backend/scripts/build-browser-runtime.ts index 14a25586a2..f7d6971c75 100644 --- a/packages/workshop-backend/build-browser-runtime.mjs +++ b/packages/workshop-backend/scripts/build-browser-runtime.ts @@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; -const packageDir = dirname(fileURLToPath(import.meta.url)); +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const runtimeOutputFile = resolve(packageDir, "src/generated/browser-export-runtime.txt"); const sanitizerOutputFile = resolve(packageDir, "src/generated/html-sanitizer-runtime.txt"); const pageOutputFile = resolve(packageDir, "src/generated/browser-export-page.js"); @@ -39,7 +39,7 @@ writeIfChanged(runtimeOutputFile, runtimeResult.outputFiles[0].contents); writeIfChanged(sanitizerOutputFile, sanitizerResult.outputFiles[0].contents); writeIfChanged(pageOutputFile, pageResult.outputFiles[0].contents); -function writeIfChanged(outputFile, bytes) { +function writeIfChanged(outputFile: string, bytes: Uint8Array) { const contents = new TextDecoder().decode(bytes); if (!existsSync(outputFile) || readFileSync(outputFile, "utf8") !== contents) { mkdirSync(dirname(outputFile), { recursive: true }); diff --git a/packages/workshop-backend/scripts/build-bundled-blueprints.ts b/packages/workshop-backend/scripts/build-bundled-blueprints.ts index f949795080..66025c9ebd 100644 --- a/packages/workshop-backend/scripts/build-bundled-blueprints.ts +++ b/packages/workshop-backend/scripts/build-bundled-blueprints.ts @@ -32,7 +32,7 @@ const { text: generated, count, totalBytes } = await generateBundledBlueprintsMo // Skip the write when nothing changed. This script runs as a prerequisite of `build` and `test`, // and rewriting an identical module would give it a fresh mtime, invalidating tsc's incremental -// cache for the whole package on every invocation. Same reason build-browser-runtime.mjs and the +// cache for the whole package on every invocation. Same reason build-browser-runtime.ts and the // two SPA builds compare before writing. let unchanged = false; try { diff --git a/packages/workshop-backend/vite.config.ts b/packages/workshop-backend/vite.config.ts index 190f80fb51..198cc58eb6 100644 --- a/packages/workshop-backend/vite.config.ts +++ b/packages/workshop-backend/vite.config.ts @@ -28,7 +28,7 @@ export default { cache: false, }, 'build:browser-runtime': { - command: withTestTimeout('node build-browser-runtime.mjs'), + command: withTestTimeout('node scripts/build-browser-runtime.ts'), cache: false, }, /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09861a6252..9e4d69bfcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1278,6 +1278,9 @@ importers: specifier: 7.3.6 version: 7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0) devDependencies: + '@oxlint/plugins': + specifier: 1.73.0 + version: 1.73.0 '@types/node': specifier: 26.1.0 version: 26.1.0 diff --git a/scripts/deploy-scripts.test.ts b/scripts/deploy-scripts.test.ts index 4d1663185e..85aea9e0df 100644 --- a/scripts/deploy-scripts.test.ts +++ b/scripts/deploy-scripts.test.ts @@ -53,7 +53,7 @@ describe("deploy scripts", () => { // shadows the declaration, and under vp the variable is stripped and the wrong value ships. it("reaches codegen through its task rather than invoking the builder", () => { for (const { name, path, command } of deployScripts) { - for (const builder of ["build-gatekeeper-configurator.ts", "build-app.mjs"]) { + for (const builder of ["build-gatekeeper-configurator.ts", "build-app.ts"]) { assert.ok( !command.includes(builder), `${name} (${path}) invokes ${builder} directly while deploying: ${command}\n` + diff --git a/scripts/env-passthrough.test.ts b/scripts/env-passthrough.test.ts index bc586bb124..bb1e7a939a 100644 --- a/scripts/env-passthrough.test.ts +++ b/scripts/env-passthrough.test.ts @@ -29,7 +29,7 @@ import { describe, it } from "node:test"; * script run directly, with the full ambient environment. Nothing about the artifact * a cached run produces depends on it. * injected — set explicitly by the build setup, never inherited. Stripping is correct here: - * `build-app.mjs` always passes `GATEKEEPER_APP_UNMINIFIED`, and the frontend build + * `build-app.ts` always passes `GATEKEEPER_APP_UNMINIFIED`, and the frontend build * task sets `NODE_ENV` before importing Vite. * external — read outside any vp task (release/dev tooling invoked directly), so vp never * filters it. diff --git a/scripts/kill-process-tree.ts b/scripts/kill-process-tree.ts index 470950d582..84150ba106 100644 --- a/scripts/kill-process-tree.ts +++ b/scripts/kill-process-tree.ts @@ -8,7 +8,7 @@ // shell parses an interpolated pid, and a guard on the child-lister producing no pids (see below). // // `node:child_process` `kill()` signals one process, which is not enough for anything here that -// spawns through a wrapper: `pnpm exec vp run ...` and `node build-app.mjs --watch` (which itself +// spawns through a wrapper: `pnpm exec vp run ...` and `node build-app.ts --watch` (which itself // runs `pnpm exec vite build --watch`) both leave the real worker running when only the wrapper is // signalled. diff --git a/scripts/oxlint-plugin.test.ts b/scripts/oxlint-plugin.test.ts index d339e57910..587371153f 100644 --- a/scripts/oxlint-plugin.test.ts +++ b/scripts/oxlint-plugin.test.ts @@ -1,7 +1,8 @@ import { describe, it } from "node:test"; +import assert from "node:assert/strict"; import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; -import plugin from "./oxlint-plugin.mjs"; +import plugin from "./oxlint-plugin.ts"; const require = createRequire(import.meta.url); const vitePlusRequire = createRequire(require.resolve("vite-plus/package.json")); @@ -10,6 +11,14 @@ const { RuleTester } = await import( pathToFileURL(vitePlusRequire.resolve("oxlint/plugins-dev")).href, ); +// `@oxlint/plugins` is a direct dependency only for its types, so it has to follow Vite+'s pin. +it("types the plugin against the @oxlint/plugins version Vite+ pins", () => { + const ours = require("./package.json").devDependencies["@oxlint/plugins"]; + const vitePlus = require("vite-plus/package.json").dependencies["@oxlint/plugins"]; + assert.equal(ours, vitePlus.replace(/^=/, ""), + "Update @gadgets/scripts' @oxlint/plugins devDependency to match vite-plus's."); +}); + RuleTester.describe = describe; RuleTester.it = it; diff --git a/scripts/oxlint-plugin.mjs b/scripts/oxlint-plugin.ts similarity index 85% rename from scripts/oxlint-plugin.mjs rename to scripts/oxlint-plugin.ts index a84be3fae7..d9d4bf9270 100644 --- a/scripts/oxlint-plugin.mjs +++ b/scripts/oxlint-plugin.ts @@ -1,4 +1,6 @@ -const preferJsdoc = { +import type { Comment, Diagnostic, ESTree, Plugin, Rule } from "@oxlint/plugins"; + +const preferJsdoc: Rule = { meta: { type: "layout", docs: { @@ -32,12 +34,12 @@ const preferJsdoc = { "TSParameterProperty", ]); - function startsOnOwnLine(comment) { + function startsOnOwnLine(comment: Comment) { const lineStart = sourceCode.text.lastIndexOf("\n", comment.range[0] - 1) + 1; return sourceCode.text.slice(lineStart, comment.range[0]).trim() === ""; } - function checkComments(node) { + function checkComments(node: ESTree.Node) { const comments = sourceCode.getCommentsBefore(node); const lastComment = comments.at(-1); if (!lastComment || lastComment.loc.end.line + 1 !== node.loc.start.line || @@ -85,7 +87,7 @@ const preferJsdoc = { : `/**\n${docComments.map((comment) => `${indent} *${comment.value.trimEnd()}`).join("\n")}\n${indent} */`; - const report = { + const report: Diagnostic = { node, loc: { start: firstComment.loc.start, @@ -102,19 +104,21 @@ const preferJsdoc = { context.report(report); } - function checkExport(node) { + function checkExport(node: ESTree.ExportNamedDeclaration | ESTree.ExportDefaultDeclaration) { if (node.declaration) checkComments(node); } - function isExportedApiMember(node) { - if (node.accessibility === "private" || node.key?.type === "PrivateIdentifier") return false; + function isPrivateMember(node: ESTree.Node) { + return ("accessibility" in node && node.accessibility === "private") || + ("key" in node && node.key?.type === "PrivateIdentifier"); + } + + function isExportedApiMember(node: ESTree.Node) { + if (isPrivateMember(node)) return false; - let root = node.parent; + let root: ESTree.Node | null = node.parent; while (root) { - if (classMemberTypes.has(root.type) && - (root.accessibility === "private" || root.key?.type === "PrivateIdentifier")) { - return false; - } + if (classMemberTypes.has(root.type) && isPrivateMember(root)) return false; if ((root.type === "FunctionDeclaration" || root.type === "FunctionExpression" || root.type === "ArrowFunctionExpression") && root.body && node.range[0] >= root.body.range[0] && node.range[1] <= root.body.range[1]) { @@ -130,7 +134,7 @@ const preferJsdoc = { } if (!root) return false; - let parent = root.parent; + let parent: ESTree.Node | null = root.parent; while (parent?.type === "VariableDeclarator" || parent?.type === "VariableDeclaration") { parent = parent.parent; } @@ -138,7 +142,7 @@ const preferJsdoc = { parent?.type === "ExportNamedDeclaration") && parent.declaration !== null; } - function checkApiMember(node) { + function checkApiMember(node: ESTree.Node) { if (isExportedApiMember(node)) checkComments(node); } @@ -162,4 +166,4 @@ export default { rules: { "prefer-jsdoc": preferJsdoc, }, -}; +} satisfies Plugin; diff --git a/scripts/package.json b/scripts/package.json index d5357b8f88..1ddc52f572 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -37,7 +37,7 @@ "types": "./release/manifest-lib.ts", "import": "./release/manifest-lib.ts" }, - "./oxlint-plugin": "./oxlint-plugin.mjs" + "./oxlint-plugin": "./oxlint-plugin.ts" }, "dependencies": { "aws4fetch": "^1.0.20", @@ -46,6 +46,7 @@ "vite": "catalog:" }, "devDependencies": { + "@oxlint/plugins": "1.73.0", "@types/node": "26.1.0", "jsdom": "^26.1.0" } diff --git a/scripts/preview/preview.ts b/scripts/preview/preview.ts index cb91f9b24f..02396e472b 100644 --- a/scripts/preview/preview.ts +++ b/scripts/preview/preview.ts @@ -214,7 +214,7 @@ function describe(error: unknown): string { // per task, so this is cheap on a warm tree. // // Whether the UI signs in through Cloudflare Access or with a password is a build-time flag -// (workshop-frontend/src/useAuth.ts), so a preview needs the same one build-release.mjs sets: +// (workshop-frontend/src/useAuth.ts), so a preview needs the same one build-release.ts sets: // otherwise it serves a password form the backend rejects every password from. The frontend's // `build` task already declares `env: ['VITE_*']`. function buildWorkspace(): Promise { diff --git a/scripts/run-dev-server.ts b/scripts/run-dev-server.ts index 68108c13e7..c02d808bd6 100644 --- a/scripts/run-dev-server.ts +++ b/scripts/run-dev-server.ts @@ -163,7 +163,7 @@ function stopDevWatchers(): void { process.on("exit", stopDevWatchers); // Reaches each app watcher's `pnpm exec vite build --watch` grandchild, which a bare kill() on the -// `node build-app.mjs --watch` wrapper leaves holding CPU and file watches after we are gone. Must +// `node build-app.ts --watch` wrapper leaves holding CPU and file watches after we are gone. Must // not call stopDevWatchers() first: killing a wrapper reparents its children away from it, and the // tree walk can no longer find them. async function stopDevWatchersDeep(): Promise { @@ -363,18 +363,18 @@ for (const gk of gatekeepers) { ); } - // Single-file app UI (Vite bundle written to src/generated/app.txt by build-app.mjs). + // Single-file app UI (Vite bundle written to src/generated/app.txt by build-app.ts). // // Deferred until Wrangler is listening: unlike the configurator watcher, `vite build --watch` // cannot skip its initial build, and these are the largest builds in the repo, so running them now // takes cores from the worker bundles Wrangler is building concurrently. Nothing needs them sooner // -- the pre-flight already wrote the `app.txt` they will produce -- and Vite reads the disk when // it finally starts, so an edit made while the server was coming up is still picked up. - if (existsSync(join(gk.dir, "build-app.mjs"))) { + if (existsSync(join(gk.dir, "build-app.ts"))) { deferredWatchers.push(() => spawnDevWatcher( `app UI watcher for ${gk.name}`, process.execPath, - [join(gk.dir, "build-app.mjs"), "--watch"], + [join(gk.dir, "build-app.ts"), "--watch"], )); } } diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 6fe9e5085a..e1960f7808 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -5,8 +5,6 @@ // consumers (vite, and `node` for the task graph) both handle TS. // // Checked by `pnpm types:scripts` in CI's lint job, deliberately NOT by `pnpm build` (hot-path and no need) -// `scripts/oxlint-plugin.mjs` stays JS because oxlint's plugin runtime loads it, not -// Node's module loader. { "extends": "../tsconfig.json", "compilerOptions": { @@ -17,11 +15,13 @@ "allowImportingTsExtensions": true, "types": ["node"], "erasableSyntaxOnly": true, - "verbatimModuleSyntax": true, - // The root sets allowJs; oxlint-plugin.mjs stays untyped. - "checkJs": false + "verbatimModuleSyntax": true }, - // The backend's Node-only build/import tools are excluded from its Worker tsconfig and need the + // The packages' Node-only build/import tools are excluded from their Worker tsconfigs and need the // same Node runtime and erasable-syntax checks as the repo-level scripts. - "include": ["**/*.ts", "../packages/workshop-backend/scripts/*.ts"] + "include": [ + "**/*.ts", + "../packages/workshop-backend/scripts/*.ts", + "../packages/gatekeeper-*/build-app.ts" + ] } diff --git a/scripts/vitest-task-vite-config.ts b/scripts/vitest-task-vite-config.ts index 55d901fd2a..5e56e6493e 100644 --- a/scripts/vitest-task-vite-config.ts +++ b/scripts/vitest-task-vite-config.ts @@ -5,8 +5,8 @@ * `gatekeeper-configurator-vite-config.ts` takes that shape. The task types below are structural * copies of Vite+'s rather than imports of them, which is what keeps that true. * - * TypeScript, unlike the `.mjs` beside it in this directory, because being TS means a malformed task - * or a mistyped `base` is a compile error rather than a glob that silently never matches. + * Being TypeScript means a malformed task or a mistyped `base` is a compile error rather than a glob + * that silently never matches. * * Reached as `@gadgets/scripts/vitest-task`, an `exports` subpath of this directory's package, not * as a relative path. A relative specifier only resolves for a consumer at `packages//` of @@ -181,7 +181,7 @@ export const TESTS_WITH_TIMEOUT_ENV: string[] = ['TESTS_WITH_TIMEOUT_DISABLE'] * needs any: nothing else here writes a build artifact into a directory its own tests track. * * Every command is wrapped in the watchdog above, including the codegen steps some packages bundle - * into this task (`workshop-backend`'s `node build-browser-runtime.mjs`) -- those are equally + * into this task (`workshop-backend`'s `node scripts/build-browser-runtime.ts`) -- those are equally * unbounded. */ export function vitestTask( diff --git a/vite.config.ts b/vite.config.ts index eca6adf68c..1e9e61f396 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ suspicious: 'error', }, plugins: ['typescript', 'unicorn', 'oxc', 'import'], - jsPlugins: ['./scripts/oxlint-plugin.mjs'], + jsPlugins: ['./scripts/oxlint-plugin.ts'], options: { // Note: type-aware linting is intentionally not enabled yet. // Enabling them is its own change: triage the first run's findings, decide a `no-floating-promises` policy (RPC promise @@ -140,7 +140,7 @@ export default defineConfig({ }, }, { - files: ['scripts/**/*.ts', 'scripts/**/*.mjs'], + files: ['scripts/**/*.ts'], env: { node: true, es2024: true,