From ba7aa0624cd1ce052d1d36840f0bf1771bbce9bb Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 13 Aug 2026 07:37:38 +0200 Subject: [PATCH 1/2] fix(provider): stop the npm build turning new.target into import.meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every ProviderError logged by the published package reads err=undefined: openai request failed: Provider request failed with status 400 The literal "undefined" is the error's `name`. DNT rewrites `import.meta` into its ESM ponyfill by visiting meta-property AST nodes, and `new.target` is also a meta-property — the transform does not tell them apart. In veryfront@0.1.1232, `esm/src/provider/runtime-loader/provider-http.js:44`: this.name = globalThis[Symbol.for("import-meta-ponyfill-esmodule")](import.meta).name; from a source line that reads `this.name = new.target.name`. The ponyfill returns an ImportMeta, which has no `name`, so every provider error in the shipped build loses the class name that says which failure bucket it is. The same rewrite hits the four filesystem adapters, where `new.target === SomeClass` becomes `ponyfill(import.meta) === SomeClass` — always false, so `markNativeFileSystemAdapter` never ran in the published package either. `this.constructor` is not a meta-property and survives the transform. The two differ only under `Reflect.construct` with a third argument, which this repo does not use. scripts/build/dnt-meta-property-safety.ts keeps it fixed: the damage is invisible to the Deno test suite (the sources are correct, the emitted package is not), so the guard has to live at the source level. --- deno.json | 8 +- .../build/dnt-meta-property-safety.test.ts | 87 ++++++++ scripts/build/dnt-meta-property-safety.ts | 207 ++++++++++++++++++ .../runtime/bun/filesystem-adapter.ts | 2 +- .../runtime/deno/filesystem-adapter.ts | 2 +- .../runtime/node/filesystem-adapter.ts | 2 +- .../runtime/shared/node-filesystem-adapter.ts | 2 +- src/provider/runtime-loader/provider-http.ts | 6 +- 8 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 scripts/build/dnt-meta-property-safety.test.ts create mode 100644 scripts/build/dnt-meta-property-safety.ts diff --git a/deno.json b/deno.json index ee15eca27b..ccc3a33423 100644 --- a/deno.json +++ b/deno.json @@ -494,10 +494,10 @@ "storybook": "npm --prefix storybook run storybook", "build:storybook": "npm --prefix storybook run build-storybook", "storybook:check": "deno test --no-lock --config=scripts/test.deno.json --no-check --allow-read scripts/storybook/storybook-workbench.test.ts", - "lint": "DENO_NO_PACKAGE_JSON=1 deno lint && deno lint --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/prepare-framework-sources.test.ts && deno lint --config=scripts/codemods/deno.json scripts/codemods/", + "lint": "DENO_NO_PACKAGE_JSON=1 deno lint && deno lint --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-meta-property-safety.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/prepare-framework-sources.test.ts && deno lint --config=scripts/codemods/deno.json scripts/codemods/", "lint:ci": "deno task lint && deno task lint:core-deps && deno task lint:cross-runtime-jsr && deno task lint:dependency-boundaries && deno task lint:module-boundaries && deno task lint:extension-contracts && deno task lint:extension-capabilities && deno task lint:ban-test-only && deno task lint:sanitizer-baseline && deno task lint:skipped-tests && deno task lint:chat-ratchets && deno task lint:chat-composability && deno task lint:rfc-status && deno task lint:esm-sh-codemod && deno task lint:test-typecheck && deno task lint:cwd-relative-test-reads && deno task storybook:check && deno task docs:api-reference:check && deno task docs:errors:check && deno task docs:public:check && deno test --frozen --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run=bash scripts/ci/setup-deno-workflow.test.ts scripts/ci/prepare-rc-build.test.ts scripts/build/generated-artifact-checks.test.ts", - "fmt": "deno fmt src/ cli/ react/ templates/ && deno fmt --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/prepare-framework-sources.test.ts && deno fmt --config=scripts/codemods/deno.json scripts/codemods/", - "fmt:check": "deno fmt --check src/ cli/ react/ templates/ && deno fmt --check --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/prepare-framework-sources.test.ts && deno fmt --check --config=scripts/codemods/deno.json scripts/codemods/", + "fmt": "deno fmt src/ cli/ react/ templates/ && deno fmt --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-meta-property-safety.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/prepare-framework-sources.test.ts && deno fmt --config=scripts/codemods/deno.json scripts/codemods/", + "fmt:check": "deno fmt --check src/ cli/ react/ templates/ && deno fmt --check --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-meta-property-safety.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/prepare-framework-sources.test.ts && deno fmt --check --config=scripts/codemods/deno.json scripts/codemods/", "typecheck": "deno task generate:manifests:check && deno check src/index.ts cli/main.ts src/server/index.ts src/routing/api/index.ts src/rendering/index.ts src/platform/index.ts src/platform/adapters/index.ts src/build/index.ts src/build/production-build/index.ts src/transforms/index.ts src/config/index.ts src/utils/index.ts src/data/index.ts src/security/index.ts src/middleware/index.ts src/server/handlers/dev/index.ts src/server/handlers/request/api/index.ts src/rendering/cache/index.ts src/rendering/cache/stores/index.ts src/rendering/rsc/actions/index.ts src/html/index.ts src/html/hydration-script-builder/runtime/main.ts src/modules/index.ts src/proxy/main.ts src/react/components/ui/index.ts src/chat/index.ts src/markdown/index.ts src/mdx/index.ts src/fs/index.ts src/oauth/index.ts src/agent/index.ts src/agent/service/route-export.check.ts src/eval/index.ts src/tool/index.ts src/workflow/index.ts src/prompt/index.ts src/resource/index.ts src/runs/index.ts src/mcp/index.ts src/provider/index.ts", "verify": "deno task generate:manifests:check && deno task fmt:check && deno task lint && deno task lint:style && deno task lint:chat-composability && deno task lint:rfc-status && deno task lint:chat-ratchets && deno task lint:esm-sh-codemod && deno task lint:cli-boundary && deno task lint:wildcard-exports && deno task lint:barrel-jsdoc && deno task lint:ban-test-only && deno task lint:sanitizer-baseline && deno task lint:skipped-tests && deno task lint:ban-zod && deno task lint:cwd-relative-test-reads && deno task lint:core-deps && deno task lint:cross-runtime-jsr && deno task lint:dependency-boundaries && deno task lint:module-boundaries && deno task lint:extension-contracts && deno task lint:extension-capabilities && deno task docs:api-reference:check && deno task docs:errors:check && deno task docs:validate && deno task typecheck && deno task typecheck:consumer && deno task test && deno task test:scripts && deno task test:e2e:binary", "verify:quick": "deno task generate:manifests:check && deno task fmt:check && deno task lint && deno task lint:style && deno task lint:chat-composability && deno task lint:rfc-status && deno task lint:chat-ratchets && deno task lint:esm-sh-codemod && deno task lint:cli-boundary && deno task lint:wildcard-exports && deno task lint:barrel-jsdoc && deno task lint:ban-test-only && deno task lint:sanitizer-baseline && deno task lint:skipped-tests && deno task lint:ban-zod && deno task lint:cwd-relative-test-reads && deno task lint:core-deps && deno task lint:cross-runtime-jsr && deno task lint:dependency-boundaries && deno task lint:module-boundaries && deno task lint:extension-contracts && deno task lint:extension-capabilities && deno task docs:api-reference:check && deno task docs:errors:check && deno task docs:validate && deno task typecheck", @@ -543,7 +543,7 @@ "lint:sanitizer-baseline": "deno run --allow-read scripts/lint/check-sanitizer-baseline.ts", "lint:skipped-tests": "deno run --allow-read scripts/lint/check-skipped-tests-baseline.ts", "lint:cwd-relative-test-reads": "deno run --allow-read scripts/lint/audit-cwd-relative-test-reads.ts", - "test:scripts": "deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/ci/prepare-rc-build.test.ts scripts/ci/publish-npm-packages.test.ts scripts/ci/setup-deno-workflow.test.ts scripts/build/compile-binary.test.ts scripts/build/dnt-polyfill.test.ts scripts/build/generate-sbom.test.ts scripts/build/generated-artifact-checks.test.ts scripts/build/npm-dependency-sources.test.ts scripts/build/npm-extension-package-metadata.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/npm-react-shims.test.ts scripts/build/npm-runtime-helper-contract.test.ts scripts/build/prepare-framework-sources.test.ts scripts/docs/docs-coverage.test.ts scripts/docs/generate-api-reference.test.ts scripts/docs/guide-validation.test.ts scripts/lint/audit-chat-composability.test.ts scripts/lint/audit-rfc-status.test.ts scripts/lint/audit-core-deps.test.ts scripts/lint/audit-cwd-relative-test-reads.test.ts scripts/lint/audit-cross-runtime-jsr.test.ts scripts/lint/audit-dependency-boundaries.test.ts scripts/lint/audit-extension-capabilities.test.ts scripts/lint/audit-extension-contracts.test.ts scripts/lint/audit-deps.test.ts scripts/lint/check-module-boundaries.test.ts scripts/lint/lint-config.test.ts scripts/lint/ban-test-only.test.ts scripts/lint/check-sanitizer-baseline.test.ts scripts/lint/check-skipped-tests-baseline.test.ts scripts/lint/check-test-typecheck-baseline.test.ts scripts/lint/check-coverage.test.ts scripts/security/audit-npm.test.ts scripts/security/submit-dependency-snapshot.test.ts scripts/test/template-runtime-e2e.test.ts && deno task test:tool-search-live", + "test:scripts": "deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/ci/prepare-rc-build.test.ts scripts/ci/publish-npm-packages.test.ts scripts/ci/setup-deno-workflow.test.ts scripts/build/compile-binary.test.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.test.ts scripts/build/generate-sbom.test.ts scripts/build/generated-artifact-checks.test.ts scripts/build/npm-dependency-sources.test.ts scripts/build/npm-extension-package-metadata.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/npm-react-shims.test.ts scripts/build/npm-runtime-helper-contract.test.ts scripts/build/prepare-framework-sources.test.ts scripts/docs/docs-coverage.test.ts scripts/docs/generate-api-reference.test.ts scripts/docs/guide-validation.test.ts scripts/lint/audit-chat-composability.test.ts scripts/lint/audit-rfc-status.test.ts scripts/lint/audit-core-deps.test.ts scripts/lint/audit-cwd-relative-test-reads.test.ts scripts/lint/audit-cross-runtime-jsr.test.ts scripts/lint/audit-dependency-boundaries.test.ts scripts/lint/audit-extension-capabilities.test.ts scripts/lint/audit-extension-contracts.test.ts scripts/lint/audit-deps.test.ts scripts/lint/check-module-boundaries.test.ts scripts/lint/lint-config.test.ts scripts/lint/ban-test-only.test.ts scripts/lint/check-sanitizer-baseline.test.ts scripts/lint/check-skipped-tests-baseline.test.ts scripts/lint/check-test-typecheck-baseline.test.ts scripts/lint/check-coverage.test.ts scripts/security/audit-npm.test.ts scripts/security/submit-dependency-snapshot.test.ts scripts/test/template-runtime-e2e.test.ts && deno task test:tool-search-live", "test:sentry-runtime-packages": "deno test --config=scripts/test.deno.json --no-check --no-lock --allow-read --allow-write --allow-run --allow-env=DENO_DIR,HOME,XDG_CACHE_HOME,LOCALAPPDATA,USERPROFILE scripts/build/sentry-runtime-packages.test.ts", "test:tool-search-live": "VF_DISABLE_LRU_INTERVAL=1 deno test --no-check -A tests/agent/verify-tool-search-live.test.ts", "test:cross-runtime": "deno run --allow-all src/platform/compat/cross-runtime.test.ts", diff --git a/scripts/build/dnt-meta-property-safety.test.ts b/scripts/build/dnt-meta-property-safety.test.ts new file mode 100644 index 0000000000..ec9d722ebc --- /dev/null +++ b/scripts/build/dnt-meta-property-safety.test.ts @@ -0,0 +1,87 @@ +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { fromFileUrl } from "#std/path"; +import { + auditRepoMetaProperties, + findBuildUnsafeMetaProperties, + ParseFailure, +} from "./dnt-meta-property-safety.ts"; + +describe("DNT meta-property safety", () => { + describe("findBuildUnsafeMetaProperties", () => { + it("reports new.target, which DNT rewrites into the import.meta ponyfill", () => { + const uses = findBuildUnsafeMetaProperties( + [ + "class Base extends Error {", + " constructor() {", + " super();", + " this.name = new.target.name;", + " }", + "}", + ].join("\n"), + "example.ts", + ); + + assertEquals(uses.length, 1); + assertEquals(uses[0]?.line, 4); + assertEquals(uses[0]?.expression, "new.target"); + }); + + it("accepts this.constructor, which survives the transform", () => { + const uses = findBuildUnsafeMetaProperties( + [ + "class Base extends Error {", + " constructor() {", + " super();", + " this.name = this.constructor.name;", + " }", + "}", + ].join("\n"), + "example.ts", + ); + + assertEquals(uses, []); + }); + + it("ignores new.target spelled inside comments and strings", () => { + const uses = findBuildUnsafeMetaProperties( + [ + "// never use new.target here", + 'const hint = "new.target is rewritten by DNT";', + ].join("\n"), + "example.ts", + ); + + assertEquals(uses, []); + }); + + it("leaves import.meta alone — the ponyfill is correct for it", () => { + const uses = findBuildUnsafeMetaProperties( + "export const here = import.meta.url;", + "example.ts", + ); + + assertEquals(uses, []); + }); + + it("fails closed when a file cannot be parsed", () => { + assertThrows( + () => findBuildUnsafeMetaProperties("class {{{", "broken.ts"), + ParseFailure, + ); + }); + }); + + it("finds no new.target anywhere in the shipped sources", async () => { + const repoRoot = fromFileUrl(new URL("../../", import.meta.url)); + const { uses, parseFailures } = await auditRepoMetaProperties(repoRoot); + + assertEquals(parseFailures, []); + assertEquals( + uses.map((use) => `${use.file}:${use.line}`), + [], + "DNT rewrites new.target into the import.meta ponyfill, so these are " + + "silently broken in the published npm package. Use this.constructor.", + ); + }); +}); diff --git a/scripts/build/dnt-meta-property-safety.ts b/scripts/build/dnt-meta-property-safety.ts new file mode 100644 index 0000000000..e8847960b5 --- /dev/null +++ b/scripts/build/dnt-meta-property-safety.ts @@ -0,0 +1,207 @@ +#!/usr/bin/env -S deno run --allow-read +/** + * Bans `new.target` in sources that ship through the npm (DNT) build. + * + * DNT rewrites `import.meta` into its ESM ponyfill by visiting **meta-property** + * AST nodes. `new.target` is also a meta-property, and the transform does not + * distinguish the two: every `new.target` in the emitted package comes out as + * the `import.meta` ponyfill call. From the published veryfront@0.1.1232 + * tarball, `esm/src/provider/runtime-loader/provider-http.js:44`: + * + * this.name = globalThis[Symbol.for("import-meta-ponyfill-esmodule")](import.meta).name; + * + * The source that produced that line is `this.name = new.target.name`. + * + * The damage is silent and runtime-only, so nothing in the Deno test suite can + * see it — the sources are correct, the *emitted package* is not: + * + * - `new.target.name` yields `undefined`, because the ponyfill returns an + * `ImportMeta` (which has `url`/`resolve`, never `name`). Every ProviderError + * in the published build therefore logs as + * `err=undefined: openai request failed: ...` — the class name that should + * have told a reader which failure bucket they hit is gone. + * - `new.target === SomeClass` yields `false` unconditionally, because an + * `ImportMeta` is never a class. Abstract-instantiation guards written that + * way (the filesystem adapters) are dead code in the published build. + * - When the ponyfill global is not installed on the path that reached the + * module, the rewritten expression throws + * `TypeError: globalThis[Symbol.for(...)] is not a function` instead. + * + * The fix at every call site is `this.constructor`, which is not a + * meta-property and survives the transform: + * + * this.name = new.target.name -> this.name = this.constructor.name + * if (new.target === Base) throw ... -> if (this.constructor === Base) throw ... + * + * The two differ only for `Reflect.construct(Base, args, Other)`, which this + * repo does not do, and only `new.target` can observe. + * + * @module + */ + +import { parse } from "#babel/parser"; +import { fromFileUrl } from "#std/path"; + +/** A `new.target` occurrence, which the npm build corrupts. */ +export interface MetaPropertyUse { + file: string; + line: number; + /** Source spelling, e.g. `new.target`. */ + expression: string; +} + +interface Node { + type: string; + loc?: { start: { line: number } }; + [key: string]: unknown; +} + +/** + * Attached comments carry a `type`, so the walk would descend into them and + * report a `new.target` that only appears in prose — including this file's own + * header. + */ +const COMMENT_KEYS = new Set([ + "leadingComments", + "trailingComments", + "innerComments", + "comments", +]); + +function isNode(value: unknown): value is Node { + return typeof value === "object" && value !== null && + typeof (value as { type?: unknown }).type === "string"; +} + +/** Raised when a scanned file cannot be parsed, so the audit fails closed. */ +export class ParseFailure extends Error {} + +/** + * Report every `new.target` in `source`. + * + * Matched on the `MetaProperty` AST node rather than by text, so a mention + * inside a comment or a string literal is not reported and a line-wrapped + * `new\n .target` still is. + */ +export function findBuildUnsafeMetaProperties( + source: string, + file: string, +): MetaPropertyUse[] { + let ast; + try { + ast = parse(source, { + sourceType: "module", + allowAwaitOutsideFunction: true, + allowReturnOutsideFunction: true, + errorRecovery: false, + plugins: ["typescript", "jsx", "decorators-legacy", "importAttributes"], + }); + } catch (error) { + throw new ParseFailure( + `${file}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const uses: MetaPropertyUse[] = []; + + const visit = (node: Node): void => { + if ( + node.type === "MetaProperty" && isNode(node.meta) && + node.meta.name === "new" + ) { + uses.push({ + file, + line: node.loc?.start.line ?? 0, + expression: `new.${isNode(node.property) ? node.property.name : "target"}`, + }); + } + + for (const key of Object.keys(node)) { + if (key === "loc" || COMMENT_KEYS.has(key)) continue; + const value = node[key]; + if (Array.isArray(value)) { + for (const item of value) if (isNode(item)) visit(item); + } else if (isNode(value)) { + visit(value); + } + } + }; + + visit(ast.program as unknown as Node); + return uses.sort((a, b) => a.line - b.line); +} + +/** Source roots copied into the npm package by the DNT build. */ +export const SHIPPED_SOURCE_ROOTS = ["src", "cli", "react"] as const; + +const SOURCE_EXTENSIONS = [".ts", ".tsx"]; + +/** Every shipped source file under `root`, tests included. */ +export async function collectShippedSources(root: string): Promise { + const files: string[] = []; + let entries: AsyncIterable; + try { + entries = Deno.readDir(root); + } catch { + return files; // expected: a scan root may not exist in every checkout + } + for await (const entry of entries) { + const path = `${root}/${entry.name}`; + if (entry.isDirectory) { + if (entry.name === "node_modules" || entry.name.startsWith(".")) continue; + files.push(...await collectShippedSources(path)); + } else if (SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { + files.push(path); + } + } + return files; +} + +/** Scan the shipped source roots of `repoRoot` for build-unsafe meta-properties. */ +export async function auditRepoMetaProperties( + repoRoot: string, +): Promise<{ uses: MetaPropertyUse[]; parseFailures: string[] }> { + const uses: MetaPropertyUse[] = []; + const parseFailures: string[] = []; + + for (const root of SHIPPED_SOURCE_ROOTS) { + for (const file of await collectShippedSources(`${repoRoot}${root}`)) { + const relative = file.slice(repoRoot.length).replaceAll("\\", "/"); + const source = await Deno.readTextFile(file); + try { + uses.push(...findBuildUnsafeMetaProperties(source, relative)); + } catch (error) { + parseFailures.push( + error instanceof Error ? error.message : String(error), + ); + } + } + } + + return { uses, parseFailures }; +} + +async function main(): Promise { + const repoRoot = fromFileUrl(new URL("../../", import.meta.url)); + const { uses, parseFailures } = await auditRepoMetaProperties(repoRoot); + + for (const failure of parseFailures) console.error(` ${failure}`); + for (const use of uses) { + console.error(` ${use.file}:${use.line} ${use.expression}`); + } + + if (uses.length > 0 || parseFailures.length > 0) { + console.error( + "\nDNT rewrites every meta-property into its import.meta ponyfill, so the " + + "published npm build silently turns these into `import.meta`. " + + "Use `this.constructor` instead — see the header of this script.", + ); + Deno.exit(1); + } + + console.log("DNT meta-property safety ok: no new.target in shipped sources."); +} + +if (import.meta.main) { + await main(); +} diff --git a/src/platform/adapters/runtime/bun/filesystem-adapter.ts b/src/platform/adapters/runtime/bun/filesystem-adapter.ts index 74f0eb8672..38f808b771 100644 --- a/src/platform/adapters/runtime/bun/filesystem-adapter.ts +++ b/src/platform/adapters/runtime/bun/filesystem-adapter.ts @@ -21,7 +21,7 @@ export class BunFileSystemAdapter extends NodeCompatibleFileSystemAdapter { options: NodeFileSystemCapabilityOptions = {}, ) { super(serverLogger, options); - if (new.target === BunFileSystemAdapter) { + if (this.constructor === BunFileSystemAdapter) { markNativeFileSystemAdapter(this); } } diff --git a/src/platform/adapters/runtime/deno/filesystem-adapter.ts b/src/platform/adapters/runtime/deno/filesystem-adapter.ts index e45c09587e..d3c7f1731f 100644 --- a/src/platform/adapters/runtime/deno/filesystem-adapter.ts +++ b/src/platform/adapters/runtime/deno/filesystem-adapter.ts @@ -156,7 +156,7 @@ export class DenoFileSystemAdapter implements FileSystemAdapter { enumerable: true, }); } - if (new.target === DenoFileSystemAdapter) { + if (this.constructor === DenoFileSystemAdapter) { markNativeFileSystemAdapter(this); } } diff --git a/src/platform/adapters/runtime/node/filesystem-adapter.ts b/src/platform/adapters/runtime/node/filesystem-adapter.ts index a5aca2c9d6..2f916f9226 100644 --- a/src/platform/adapters/runtime/node/filesystem-adapter.ts +++ b/src/platform/adapters/runtime/node/filesystem-adapter.ts @@ -9,7 +9,7 @@ import { serverLogger } from "#veryfront/utils"; export class NodeFileSystemAdapter extends NodeCompatibleFileSystemAdapter { constructor(options: NodeFileSystemCapabilityOptions = {}) { super(serverLogger, options); - if (new.target === NodeFileSystemAdapter) { + if (this.constructor === NodeFileSystemAdapter) { markNativeFileSystemAdapter(this); } } diff --git a/src/platform/adapters/runtime/shared/node-filesystem-adapter.ts b/src/platform/adapters/runtime/shared/node-filesystem-adapter.ts index 0379fe55b8..2dc969ee82 100644 --- a/src/platform/adapters/runtime/shared/node-filesystem-adapter.ts +++ b/src/platform/adapters/runtime/shared/node-filesystem-adapter.ts @@ -432,7 +432,7 @@ export class NodeCompatibleFileSystemAdapter implements FileSystemAdapter { enumerable: true, }); } - if (new.target === NodeCompatibleFileSystemAdapter) { + if (this.constructor === NodeCompatibleFileSystemAdapter) { markNativeFileSystemAdapter(this); } } diff --git a/src/provider/runtime-loader/provider-http.ts b/src/provider/runtime-loader/provider-http.ts index 8ac9bb3f37..de16f375fa 100644 --- a/src/provider/runtime-loader/provider-http.ts +++ b/src/provider/runtime-loader/provider-http.ts @@ -63,7 +63,11 @@ export class ProviderError extends Error { retryAfterMs?: number; }) { super(options.message); - this.name = new.target.name; + // `this.constructor`, not `new.target`: DNT rewrites every meta-property + // into its `import.meta` ponyfill when it emits the npm package, which + // turned this line into `ponyfill(import.meta).name` — always `undefined`. + // See scripts/build/dnt-meta-property-safety.ts. + this.name = this.constructor.name; this.provider = options.provider; this.status = options.status; this.retryable = options.retryable; From 29a3be29dfd30448ca293a787d6515a3c95db7cf Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 13 Aug 2026 08:41:56 +0200 Subject: [PATCH 2/2] fix(adapters): make the DNT-safe direct-construction check unforgeable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the new.target removal. `this.constructor === X` survives DNT's meta-property rewrite but is an ordinary inherited property: a subclass that deletes or overwrites its own `prototype.constructor` inherits the base's and would be registered as a directly constructed built-in adapter. Replace the four identity checks with `isDirectConstruction`, which compares prototype identity — a class's `prototype` is non-writable and non-configurable and `[[Construct]]` takes the new object's prototype from `new.target.prototype`, so a subclass instance can never answer as the base. Regressions cover the deleted and the overwritten `prototype.constructor` for all four adapters. Reading the class name off `this.constructor` (ProviderError) is unchanged; only identity tests were forgeable. Also from review: - Derive the audited roots from `deno.json` instead of a hard-coded list, so `templates/` (the `./scaffold` entry point) and every first-party `extensions/*` package — each of which gets its own DNT build — are scanned. - Run the audit in `build:npm` and `lint:ci`. It previously only ran through `test:scripts`, which no CI job invokes, while both publish jobs call `build:npm` directly. - `Deno.readDir` is lazy, so a missing scan root rejected during iteration and escaped the guard around the call. Catch the iteration, ignore only `NotFound`, and let every other failure propagate instead of silently shrinking the audited set. - Regenerate docs/api-reference for the shifted provider-http.ts line pins. --- deno.json | 5 +- docs/api-reference/veryfront/provider.md | 16 +-- .../build/dnt-meta-property-safety.test.ts | 98 ++++++++++++++++++- scripts/build/dnt-meta-property-safety.ts | 97 +++++++++++++----- .../adapters/native-file-system-provenance.ts | 29 ++++++ .../runtime/bun/filesystem-adapter.test.ts | 27 +++++ .../runtime/bun/filesystem-adapter.ts | 7 +- .../runtime/deno/filesystem-adapter.test.ts | 20 ++++ .../runtime/deno/filesystem-adapter.ts | 7 +- .../runtime/node/filesystem-adapter.test.ts | 28 ++++++ .../runtime/node/filesystem-adapter.ts | 7 +- .../shared/node-filesystem-adapter.test.ts | 20 ++++ .../runtime/shared/node-filesystem-adapter.ts | 7 +- 13 files changed, 326 insertions(+), 42 deletions(-) diff --git a/deno.json b/deno.json index ccc3a33423..d658458cf8 100644 --- a/deno.json +++ b/deno.json @@ -469,7 +469,7 @@ "build:prepare": "deno run -A scripts/build/generate-integrations-module.ts && deno task generate && deno run -A scripts/build/prepare-framework-sources.ts", "build": "deno task build:prepare && deno run -A scripts/build/compile-binary.ts --output ./bin/veryfront", "build:proxy-lock": "deno cache --node-modules-dir=none --lock scripts/build/proxy-deno.lock cli/proxy-main.ts", - "build:npm": "deno run -A scripts/build/generate-integrations-module.ts && deno task generate && deno run --config=scripts/test.deno.json --frozen -A scripts/build/build-npm-dnt.ts", + "build:npm": "deno run -A scripts/build/generate-integrations-module.ts && deno task generate && deno task lint:dnt-meta-properties && deno run --config=scripts/test.deno.json --frozen -A scripts/build/build-npm-dnt.ts", "verify:dist": "deno task build && deno task build:npm", "release": "deno run -A scripts/release.ts", "test": "deno task generate && DENO_TESTING=1 VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/testing/preload.ts --no-check --parallel --allow-all '--ignore=tests/e2e,tests/integration/compiled-binary-e2e.test.ts,scripts' --unstable-worker-options --unstable-net", @@ -495,7 +495,7 @@ "build:storybook": "npm --prefix storybook run build-storybook", "storybook:check": "deno test --no-lock --config=scripts/test.deno.json --no-check --allow-read scripts/storybook/storybook-workbench.test.ts", "lint": "DENO_NO_PACKAGE_JSON=1 deno lint && deno lint --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-meta-property-safety.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/prepare-framework-sources.test.ts && deno lint --config=scripts/codemods/deno.json scripts/codemods/", - "lint:ci": "deno task lint && deno task lint:core-deps && deno task lint:cross-runtime-jsr && deno task lint:dependency-boundaries && deno task lint:module-boundaries && deno task lint:extension-contracts && deno task lint:extension-capabilities && deno task lint:ban-test-only && deno task lint:sanitizer-baseline && deno task lint:skipped-tests && deno task lint:chat-ratchets && deno task lint:chat-composability && deno task lint:rfc-status && deno task lint:esm-sh-codemod && deno task lint:test-typecheck && deno task lint:cwd-relative-test-reads && deno task storybook:check && deno task docs:api-reference:check && deno task docs:errors:check && deno task docs:public:check && deno test --frozen --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run=bash scripts/ci/setup-deno-workflow.test.ts scripts/ci/prepare-rc-build.test.ts scripts/build/generated-artifact-checks.test.ts", + "lint:ci": "deno task lint && deno task lint:core-deps && deno task lint:cross-runtime-jsr && deno task lint:dependency-boundaries && deno task lint:module-boundaries && deno task lint:extension-contracts && deno task lint:extension-capabilities && deno task lint:ban-test-only && deno task lint:sanitizer-baseline && deno task lint:skipped-tests && deno task lint:chat-ratchets && deno task lint:chat-composability && deno task lint:rfc-status && deno task lint:esm-sh-codemod && deno task lint:test-typecheck && deno task lint:cwd-relative-test-reads && deno task lint:dnt-meta-properties && deno task storybook:check && deno task docs:api-reference:check && deno task docs:errors:check && deno task docs:public:check && deno test --frozen --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run=bash scripts/ci/setup-deno-workflow.test.ts scripts/ci/prepare-rc-build.test.ts scripts/build/generated-artifact-checks.test.ts", "fmt": "deno fmt src/ cli/ react/ templates/ && deno fmt --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-meta-property-safety.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/prepare-framework-sources.test.ts && deno fmt --config=scripts/codemods/deno.json scripts/codemods/", "fmt:check": "deno fmt --check src/ cli/ react/ templates/ && deno fmt --check --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-meta-property-safety.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/prepare-framework-sources.test.ts && deno fmt --check --config=scripts/codemods/deno.json scripts/codemods/", "typecheck": "deno task generate:manifests:check && deno check src/index.ts cli/main.ts src/server/index.ts src/routing/api/index.ts src/rendering/index.ts src/platform/index.ts src/platform/adapters/index.ts src/build/index.ts src/build/production-build/index.ts src/transforms/index.ts src/config/index.ts src/utils/index.ts src/data/index.ts src/security/index.ts src/middleware/index.ts src/server/handlers/dev/index.ts src/server/handlers/request/api/index.ts src/rendering/cache/index.ts src/rendering/cache/stores/index.ts src/rendering/rsc/actions/index.ts src/html/index.ts src/html/hydration-script-builder/runtime/main.ts src/modules/index.ts src/proxy/main.ts src/react/components/ui/index.ts src/chat/index.ts src/markdown/index.ts src/mdx/index.ts src/fs/index.ts src/oauth/index.ts src/agent/index.ts src/agent/service/route-export.check.ts src/eval/index.ts src/tool/index.ts src/workflow/index.ts src/prompt/index.ts src/resource/index.ts src/runs/index.ts src/mcp/index.ts src/provider/index.ts", @@ -543,6 +543,7 @@ "lint:sanitizer-baseline": "deno run --allow-read scripts/lint/check-sanitizer-baseline.ts", "lint:skipped-tests": "deno run --allow-read scripts/lint/check-skipped-tests-baseline.ts", "lint:cwd-relative-test-reads": "deno run --allow-read scripts/lint/audit-cwd-relative-test-reads.ts", + "lint:dnt-meta-properties": "deno run --config=scripts/test.deno.json --frozen --allow-read scripts/build/dnt-meta-property-safety.ts", "test:scripts": "deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/ci/prepare-rc-build.test.ts scripts/ci/publish-npm-packages.test.ts scripts/ci/setup-deno-workflow.test.ts scripts/build/compile-binary.test.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.test.ts scripts/build/generate-sbom.test.ts scripts/build/generated-artifact-checks.test.ts scripts/build/npm-dependency-sources.test.ts scripts/build/npm-extension-package-metadata.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/npm-react-shims.test.ts scripts/build/npm-runtime-helper-contract.test.ts scripts/build/prepare-framework-sources.test.ts scripts/docs/docs-coverage.test.ts scripts/docs/generate-api-reference.test.ts scripts/docs/guide-validation.test.ts scripts/lint/audit-chat-composability.test.ts scripts/lint/audit-rfc-status.test.ts scripts/lint/audit-core-deps.test.ts scripts/lint/audit-cwd-relative-test-reads.test.ts scripts/lint/audit-cross-runtime-jsr.test.ts scripts/lint/audit-dependency-boundaries.test.ts scripts/lint/audit-extension-capabilities.test.ts scripts/lint/audit-extension-contracts.test.ts scripts/lint/audit-deps.test.ts scripts/lint/check-module-boundaries.test.ts scripts/lint/lint-config.test.ts scripts/lint/ban-test-only.test.ts scripts/lint/check-sanitizer-baseline.test.ts scripts/lint/check-skipped-tests-baseline.test.ts scripts/lint/check-test-typecheck-baseline.test.ts scripts/lint/check-coverage.test.ts scripts/security/audit-npm.test.ts scripts/security/submit-dependency-snapshot.test.ts scripts/test/template-runtime-e2e.test.ts && deno task test:tool-search-live", "test:sentry-runtime-packages": "deno test --config=scripts/test.deno.json --no-check --no-lock --allow-read --allow-write --allow-run --allow-env=DENO_DIR,HOME,XDG_CACHE_HOME,LOCALAPPDATA,USERPROFILE scripts/build/sentry-runtime-packages.test.ts", "test:tool-search-live": "VF_DISABLE_LRU_INTERVAL=1 deno test --no-check -A tests/agent/verify-tool-search-live.test.ts", diff --git a/docs/api-reference/veryfront/provider.md b/docs/api-reference/veryfront/provider.md index 762f2f864d..a9d96e4188 100644 --- a/docs/api-reference/veryfront/provider.md +++ b/docs/api-reference/veryfront/provider.md @@ -142,7 +142,7 @@ import { | Name | Description | Source | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | -| `buildProviderError` | Inspect a non-2xx response and build the most specific ProviderError subclass we can. Reads the response body as text (it's already dead on the wire by this point). Body classification handles the cases where HTTP status alone is ambiguous - notably OpenAI `insufficient_quota` vs `rate_limit_exceeded` both arriving as 429. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L163) | +| `buildProviderError` | Inspect a non-2xx response and build the most specific ProviderError subclass we can. Reads the response body as text (it's already dead on the wire by this point). Body classification handles the cases where HTTP status alone is ambiguous - notably OpenAI `insufficient_quota` vs `rate_limit_exceeded` both arriving as 429. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L167) | | `createAnthropicRequestInit` | Create Anthropic request init. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-request-init.ts#L131) | | `createGoogleRequestInit` | Create Google request init. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-request-init.ts#L155) | | `createOpenAIRequestInit` | Create request init options for OpenAI-compatible providers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-request-init.ts#L111) | @@ -158,14 +158,14 @@ import { | `jsonValuesEqual` | Compare JSON-compatible values independently of object key order. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/json-snapshot.ts#L711) | | `mergeUsage` | Merge provider usage counters. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-usage.ts#L120) | | `parseFinalSseChunk` | Parse the final unterminated SSE block without making the synthetic frame delimiter count against the caller-visible retention boundary. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-sse.ts#L38) | -| `parseRetryAfterMs` | Parses retry after ms. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L102) | +| `parseRetryAfterMs` | Parses retry after ms. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L106) | | `parseSseChunk` | Parse a bounded provider SSE buffer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-sse.ts#L27) | | `readGatewayBillingMode` | Read a trusted gateway billing mode from provider metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-usage.ts#L107) | | `readProviderOptions` | Options accepted by read provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader.ts#L325) | | `readRecord` | Record shape for read. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-records.ts#L2) | | `readTextParts` | Read text content parts from provider messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader.ts#L184) | -| `requestJson` | Request and parse a bounded JSON response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L662) | -| `requestStream` | Request a streaming response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L732) | +| `requestJson` | Request and parse a bounded JSON response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L666) | +| `requestStream` | Request a streaming response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L736) | | `snapshotJsonValue` | Create a bounded, deeply owned, accessor-free snapshot of a JSON value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/json-snapshot.ts#L650) | | `snapshotProviderJsonValue` | Create the provider-boundary snapshot used by request builders. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/json-snapshot.ts#L675) | | `stringifyJsonValue` | Serialize a JSON-compatible value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader.ts#L151) | @@ -181,10 +181,10 @@ import { | Name | Description | Source | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `ProviderError` | Base class for typed provider errors. The `retryable` flag is the primary signal for callers (or a retry wrapper) to decide whether to re-issue the request. `retryAfterMs` is set when the provider gave an explicit delay hint (Retry-After header, Retry-Info trailer). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L47) | -| `ProviderOverloadedError` | Provider reports it is overloaded (Anthropic 529, OpenAI/Google 503). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L77) | -| `ProviderQuotaError` | Provider account quota is exhausted - non-retryable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L83) | -| `ProviderRateLimitError` | Provider is rate limiting this API key (OpenAI/Google 429 with Retry-After). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L80) | -| `ProviderRequestError` | Non-retryable 4xx/5xx that doesn't fit another bucket. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L86) | +| `ProviderOverloadedError` | Provider reports it is overloaded (Anthropic 529, OpenAI/Google 503). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L81) | +| `ProviderQuotaError` | Provider account quota is exhausted - non-retryable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L87) | +| `ProviderRateLimitError` | Provider is rate limiting this API key (OpenAI/Google 429 with Retry-After). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L84) | +| `ProviderRequestError` | Non-retryable 4xx/5xx that doesn't fit another bucket. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts#L90) | #### Types diff --git a/scripts/build/dnt-meta-property-safety.test.ts b/scripts/build/dnt-meta-property-safety.test.ts index ec9d722ebc..1dc1482ff2 100644 --- a/scripts/build/dnt-meta-property-safety.test.ts +++ b/scripts/build/dnt-meta-property-safety.test.ts @@ -1,12 +1,27 @@ -import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { + assertEquals, + assertRejects, + assertThrows, +} from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { fromFileUrl } from "#std/path"; import { auditRepoMetaProperties, + collectShippedSources, findBuildUnsafeMetaProperties, ParseFailure, + type ShippedSourceConfig, + shippedSourceRoots, } from "./dnt-meta-property-safety.ts"; +const repoRoot = fromFileUrl(new URL("../../", import.meta.url)); + +async function readRepoConfig(): Promise { + return JSON.parse( + await Deno.readTextFile(`${repoRoot}deno.json`), + ) as ShippedSourceConfig; +} + describe("DNT meta-property safety", () => { describe("findBuildUnsafeMetaProperties", () => { it("reports new.target, which DNT rewrites into the import.meta ponyfill", () => { @@ -72,8 +87,87 @@ describe("DNT meta-property safety", () => { }); }); + describe("shippedSourceRoots", () => { + it("covers every entry point DNT compiles", async () => { + const config = await readRepoConfig(); + const roots = shippedSourceRoots(config); + + for (const path of Object.values(config.exports ?? {})) { + const relative = path.replace(/^\.\//, ""); + assertEquals( + roots.some((root) => relative.startsWith(`${root}/`)), + true, + `${path} is a DNT entry point but sits outside the audited roots`, + ); + } + + for (const member of config.workspace ?? []) { + if (!member.startsWith("./extensions/")) continue; + assertEquals( + roots.includes(member.replace(/^\.\//, "")), + true, + `${member} gets its own DNT build but sits outside the audited roots`, + ); + } + }); + + it("keeps templates/ in scope, which the ./scaffold export ships", async () => { + assertEquals( + shippedSourceRoots(await readRepoConfig()).includes("templates"), + true, + ); + }); + + it("derives roots from the export map rather than a hard-coded list", () => { + assertEquals( + shippedSourceRoots({ + exports: { "./future": "./somewhere-new/entry.ts" }, + }), + ["react", "somewhere-new"], + ); + }); + }); + + describe("collectShippedSources", () => { + it("treats an absent scan root as empty", async () => { + const root = await Deno.makeTempDir({ prefix: "vf-dnt-meta-absent-" }); + try { + assertEquals(await collectShippedSources(`${root}/never-created`), []); + } finally { + await Deno.remove(root, { recursive: true }); + } + }); + + it("propagates a scan-root failure that is not a missing directory", async () => { + const root = await Deno.makeTempDir({ prefix: "vf-dnt-meta-not-dir-" }); + try { + const file = `${root}/not-a-directory.ts`; + await Deno.writeTextFile(file, "export const ok = true;\n"); + await assertRejects(() => collectShippedSources(file)); + } finally { + await Deno.remove(root, { recursive: true }); + } + }); + }); + + it("audits a checkout whose scan roots are absent", async () => { + const root = await Deno.makeTempDir({ prefix: "vf-dnt-meta-empty-" }); + try { + await Deno.writeTextFile( + `${root}/deno.json`, + JSON.stringify({ exports: { ".": "./src/index.ts" } }), + ); + + const { uses, parseFailures } = await auditRepoMetaProperties(`${root}/`); + + assertEquals(uses, []); + assertEquals(parseFailures, []); + } finally { + await Deno.remove(root, { recursive: true }); + } + }); + it("finds no new.target anywhere in the shipped sources", async () => { - const repoRoot = fromFileUrl(new URL("../../", import.meta.url)); const { uses, parseFailures } = await auditRepoMetaProperties(repoRoot); assertEquals(parseFailures, []); diff --git a/scripts/build/dnt-meta-property-safety.ts b/scripts/build/dnt-meta-property-safety.ts index e8847960b5..86f8cdf7e8 100644 --- a/scripts/build/dnt-meta-property-safety.ts +++ b/scripts/build/dnt-meta-property-safety.ts @@ -27,14 +27,22 @@ * module, the rewritten expression throws * `TypeError: globalThis[Symbol.for(...)] is not a function` instead. * - * The fix at every call site is `this.constructor`, which is not a - * meta-property and survives the transform: + * The replacements are ordinary expressions, not meta-properties, so they + * survive the transform: * - * this.name = new.target.name -> this.name = this.constructor.name - * if (new.target === Base) throw ... -> if (this.constructor === Base) throw ... + * this.name = new.target.name + * -> this.name = this.constructor.name * - * The two differ only for `Reflect.construct(Base, args, Other)`, which this - * repo does not do, and only `new.target` can observe. + * if (new.target === Base) ... + * -> if (Object.getPrototypeOf(this) === Base.prototype) ... + * + * Reading a name off `this.constructor` is enough; an identity test is not, + * because `constructor` is an ordinary inherited property a subclass can delete + * or overwrite, which would make the subclass answer as the base. Prototype + * identity is the unforgeable spelling — see `isDirectConstruction` in + * `src/platform/adapters/native-file-system-provenance.ts`. Both differ from + * `new.target` only under `Reflect.construct(Base, args, Other)`, which this + * repo does not do. * * @module */ @@ -112,7 +120,9 @@ export function findBuildUnsafeMetaProperties( uses.push({ file, line: node.loc?.start.line ?? 0, - expression: `new.${isNode(node.property) ? node.property.name : "target"}`, + expression: `new.${ + isNode(node.property) ? node.property.name : "target" + }`, }); } @@ -131,28 +141,68 @@ export function findBuildUnsafeMetaProperties( return uses.sort((a, b) => a.line - b.line); } -/** Source roots copied into the npm package by the DNT build. */ -export const SHIPPED_SOURCE_ROOTS = ["src", "cli", "react"] as const; +/** The parts of `deno.json` that decide what DNT compiles. */ +export interface ShippedSourceConfig { + /** The root package's export map — DNT's entry points are derived from it. */ + exports?: Record; + /** Workspace members; the `./extensions/*` ones get their own DNT build. */ + workspace?: string[]; +} + +/** + * Source roots copied into a published npm package by a DNT build. + * + * Derived rather than listed so a new export or a new extension package cannot + * quietly fall outside the audit: + * + * - `scripts/build/build-npm-dnt.ts` builds the root package with + * `entryPoints` taken straight from `deno.json`'s export map, which today + * reaches `src/`, `cli/` and `templates/` (`./scaffold`). + * - `scripts/build/build-npm-extension-packages.ts` runs one further DNT build + * per first-party `./extensions/*` workspace member. + * - `react/` holds the shim modules the root build maps onto the bare + * `react`/`react-dom` specifiers; they are part of that module graph. + */ +export function shippedSourceRoots(config: ShippedSourceConfig): string[] { + const roots = new Set(["react"]); + + for (const path of Object.values(config.exports ?? {})) { + const root = path.replace(/^\.\//, "").split("/")[0]; + if (root) roots.add(root); + } + + for (const member of config.workspace ?? []) { + if (member.startsWith("./extensions/")) { + roots.add(member.replace(/^\.\//, "")); + } + } + + return [...roots].toSorted(); +} const SOURCE_EXTENSIONS = [".ts", ".tsx"]; /** Every shipped source file under `root`, tests included. */ export async function collectShippedSources(root: string): Promise { const files: string[] = []; - let entries: AsyncIterable; try { - entries = Deno.readDir(root); - } catch { - return files; // expected: a scan root may not exist in every checkout - } - for await (const entry of entries) { - const path = `${root}/${entry.name}`; - if (entry.isDirectory) { - if (entry.name === "node_modules" || entry.name.startsWith(".")) continue; - files.push(...await collectShippedSources(path)); - } else if (SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { - files.push(path); + // `Deno.readDir` is lazy: a missing root rejects here, during iteration, + // not at the call. Only that case is expected — a scan root can be absent + // in a partial checkout — so every other failure has to keep propagating + // rather than silently shrink the audited set. + for await (const entry of Deno.readDir(root)) { + const path = `${root}/${entry.name}`; + if (entry.isDirectory) { + if (entry.name === "node_modules" || entry.name.startsWith(".")) { + continue; + } + files.push(...await collectShippedSources(path)); + } else if (SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { + files.push(path); + } } + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; } return files; } @@ -163,8 +213,11 @@ export async function auditRepoMetaProperties( ): Promise<{ uses: MetaPropertyUse[]; parseFailures: string[] }> { const uses: MetaPropertyUse[] = []; const parseFailures: string[] = []; + const config = JSON.parse( + await Deno.readTextFile(`${repoRoot}deno.json`), + ) as ShippedSourceConfig; - for (const root of SHIPPED_SOURCE_ROOTS) { + for (const root of shippedSourceRoots(config)) { for (const file of await collectShippedSources(`${repoRoot}${root}`)) { const relative = file.slice(repoRoot.length).replaceAll("\\", "/"); const source = await Deno.readTextFile(file); diff --git a/src/platform/adapters/native-file-system-provenance.ts b/src/platform/adapters/native-file-system-provenance.ts index 8ef2f0aa1d..a6917b6dd3 100644 --- a/src/platform/adapters/native-file-system-provenance.ts +++ b/src/platform/adapters/native-file-system-provenance.ts @@ -2,6 +2,7 @@ import type { FileSystemAdapter } from "./base.ts"; const IntrinsicWeakSet = WeakSet; const ReflectApply = Reflect.apply; +const ObjectGetPrototypeOf = Object.getPrototypeOf; const WeakSetPrototypeAdd = IntrinsicWeakSet.prototype.add; const WeakSetPrototypeHas = IntrinsicWeakSet.prototype.has; @@ -9,6 +10,34 @@ const WeakSetPrototypeHas = IntrinsicWeakSet.prototype.has; // classification because they may translate paths into a non-native namespace. const nativeFileSystemAdapters = new IntrinsicWeakSet(); +/** + * Whether `instance` was constructed directly as `constructorRef` rather than as + * a subclass of it. + * + * `new.target === constructorRef` is the natural spelling and cannot be used + * here: DNT rewrites every meta-property into its `import.meta` ponyfill when it + * emits the npm package, so in the published build `new.target` is an + * `ImportMeta`, the comparison is unconditionally false, and no adapter is ever + * registered (see `scripts/build/dnt-meta-property-safety.ts`). + * + * `this.constructor === constructorRef` survives that transform but is + * forgeable: a subclass that deletes or overwrites its own + * `prototype.constructor` inherits the base's, so a subclass instance would be + * classified as a directly constructed built-in. + * + * Prototype identity survives the transform and is not forgeable that way. A + * class's `prototype` is a non-writable, non-configurable own property, and + * `[[Construct]]` takes the new object's prototype from `new.target.prototype`, + * so `new Subclass()` always arrives here carrying `Subclass.prototype`. + */ +export function isDirectConstruction( + instance: object, + constructorRef: { readonly prototype: object }, +): boolean { + return ReflectApply(ObjectGetPrototypeOf, undefined, [instance]) === + constructorRef.prototype; +} + /** Register a directly constructed built-in adapter backed by the host filesystem. */ export function markNativeFileSystemAdapter(adapter: FileSystemAdapter): void { ReflectApply(WeakSetPrototypeAdd, nativeFileSystemAdapters, [adapter]); diff --git a/src/platform/adapters/runtime/bun/filesystem-adapter.test.ts b/src/platform/adapters/runtime/bun/filesystem-adapter.test.ts index 1b4391607b..08e8969dab 100644 --- a/src/platform/adapters/runtime/bun/filesystem-adapter.test.ts +++ b/src/platform/adapters/runtime/bun/filesystem-adapter.test.ts @@ -115,6 +115,33 @@ describe("BunFileSystemAdapter", () => { ); }); + it("refuses a subclass that hides its own prototype.constructor", () => { + class ConstructorDeletingAdapter extends BunFileSystemAdapter {} + Reflect.deleteProperty(ConstructorDeletingAdapter.prototype, "constructor"); + + class ConstructorSpoofingAdapter extends BunFileSystemAdapter {} + Object.defineProperty(ConstructorSpoofingAdapter.prototype, "constructor", { + configurable: true, + value: BunFileSystemAdapter, + }); + + const fake = runtimeFor({ + size: 0, + exists: () => Promise.resolve(true), + text: () => Promise.resolve(""), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }); + + assertEquals( + isNativeFileSystemAdapter(new ConstructorDeletingAdapter(fake.runtime)), + false, + ); + assertEquals( + isNativeFileSystemAdapter(new ConstructorSpoofingAdapter(fake.runtime)), + false, + ); + }); + it("uses Bun-native text and byte reads plus writes", async () => { let bytesCalls = 0; const fake = runtimeFor({ diff --git a/src/platform/adapters/runtime/bun/filesystem-adapter.ts b/src/platform/adapters/runtime/bun/filesystem-adapter.ts index 38f808b771..299d4c7e34 100644 --- a/src/platform/adapters/runtime/bun/filesystem-adapter.ts +++ b/src/platform/adapters/runtime/bun/filesystem-adapter.ts @@ -2,7 +2,10 @@ import { NodeCompatibleFileSystemAdapter, type NodeFileSystemCapabilityOptions, } from "../shared/node-filesystem-adapter.ts"; -import { markNativeFileSystemAdapter } from "../../native-file-system-provenance.ts"; +import { + isDirectConstruction, + markNativeFileSystemAdapter, +} from "../../native-file-system-provenance.ts"; import type { BunNamespace } from "./types.ts"; import { getBunRuntime } from "./types.ts"; import { NOT_SUPPORTED } from "#veryfront/errors/error-registry/general.ts"; @@ -21,7 +24,7 @@ export class BunFileSystemAdapter extends NodeCompatibleFileSystemAdapter { options: NodeFileSystemCapabilityOptions = {}, ) { super(serverLogger, options); - if (this.constructor === BunFileSystemAdapter) { + if (isDirectConstruction(this, BunFileSystemAdapter)) { markNativeFileSystemAdapter(this); } } diff --git a/src/platform/adapters/runtime/deno/filesystem-adapter.test.ts b/src/platform/adapters/runtime/deno/filesystem-adapter.test.ts index e32f44366a..5307723576 100644 --- a/src/platform/adapters/runtime/deno/filesystem-adapter.test.ts +++ b/src/platform/adapters/runtime/deno/filesystem-adapter.test.ts @@ -169,6 +169,26 @@ if (isDeno) { assertEquals(isNativeFileSystemAdapter(new DerivedAdapter()), false); }); + it("refuses a subclass that hides its own prototype.constructor", () => { + class ConstructorDeletingAdapter extends DenoFileSystemAdapter {} + Reflect.deleteProperty(ConstructorDeletingAdapter.prototype, "constructor"); + + class ConstructorSpoofingAdapter extends DenoFileSystemAdapter {} + Object.defineProperty(ConstructorSpoofingAdapter.prototype, "constructor", { + configurable: true, + value: DenoFileSystemAdapter, + }); + + assertEquals( + isNativeFileSystemAdapter(new ConstructorDeletingAdapter()), + false, + ); + assertEquals( + isNativeFileSystemAdapter(new ConstructorSpoofingAdapter()), + false, + ); + }); + it("reads a genuinely bounded byte prefix", async () => { const root = await Deno.makeTempDir({ prefix: "vf-deno-bounded-read-" }); const path = join(root, "file.txt"); diff --git a/src/platform/adapters/runtime/deno/filesystem-adapter.ts b/src/platform/adapters/runtime/deno/filesystem-adapter.ts index d3c7f1731f..dd8b2829cd 100644 --- a/src/platform/adapters/runtime/deno/filesystem-adapter.ts +++ b/src/platform/adapters/runtime/deno/filesystem-adapter.ts @@ -16,7 +16,10 @@ import { readFileWithinLimit, withFileHandle, } from "../../bounded-file-read.ts"; -import { markNativeFileSystemAdapter } from "../../native-file-system-provenance.ts"; +import { + isDirectConstruction, + markNativeFileSystemAdapter, +} from "../../native-file-system-provenance.ts"; import { NodeCompatibleFileSystemAdapter, type NodeFileSystemCapabilityOptions, @@ -156,7 +159,7 @@ export class DenoFileSystemAdapter implements FileSystemAdapter { enumerable: true, }); } - if (this.constructor === DenoFileSystemAdapter) { + if (isDirectConstruction(this, DenoFileSystemAdapter)) { markNativeFileSystemAdapter(this); } } diff --git a/src/platform/adapters/runtime/node/filesystem-adapter.test.ts b/src/platform/adapters/runtime/node/filesystem-adapter.test.ts index 1f0c18a0d8..d459d291aa 100644 --- a/src/platform/adapters/runtime/node/filesystem-adapter.test.ts +++ b/src/platform/adapters/runtime/node/filesystem-adapter.test.ts @@ -8,6 +8,7 @@ import { __resetLogRecordEmitterForTests, type LogEntry, } from "#veryfront/utils/logger/logger.ts"; +import { isNativeFileSystemAdapter } from "../../native-file-system-provenance.ts"; import { NodeFileSystemAdapter } from "./filesystem-adapter.ts"; function captureDebugLogs(): { entries: LogEntry[]; restore: () => void } { @@ -135,4 +136,31 @@ describe("NodeFileSystemAdapter", () => { logs.restore(); } }); + + it("marks only direct built-in instances as native", () => { + class DerivedAdapter extends NodeFileSystemAdapter {} + + assertEquals(isNativeFileSystemAdapter(new NodeFileSystemAdapter()), true); + assertEquals(isNativeFileSystemAdapter(new DerivedAdapter()), false); + }); + + it("refuses a subclass that hides its own prototype.constructor", () => { + class ConstructorDeletingAdapter extends NodeFileSystemAdapter {} + Reflect.deleteProperty(ConstructorDeletingAdapter.prototype, "constructor"); + + class ConstructorSpoofingAdapter extends NodeFileSystemAdapter {} + Object.defineProperty(ConstructorSpoofingAdapter.prototype, "constructor", { + configurable: true, + value: NodeFileSystemAdapter, + }); + + assertEquals( + isNativeFileSystemAdapter(new ConstructorDeletingAdapter()), + false, + ); + assertEquals( + isNativeFileSystemAdapter(new ConstructorSpoofingAdapter()), + false, + ); + }); }); diff --git a/src/platform/adapters/runtime/node/filesystem-adapter.ts b/src/platform/adapters/runtime/node/filesystem-adapter.ts index 2f916f9226..eb6bbc7924 100644 --- a/src/platform/adapters/runtime/node/filesystem-adapter.ts +++ b/src/platform/adapters/runtime/node/filesystem-adapter.ts @@ -2,14 +2,17 @@ import { NodeCompatibleFileSystemAdapter, type NodeFileSystemCapabilityOptions, } from "../shared/node-filesystem-adapter.ts"; -import { markNativeFileSystemAdapter } from "../../native-file-system-provenance.ts"; +import { + isDirectConstruction, + markNativeFileSystemAdapter, +} from "../../native-file-system-provenance.ts"; import { serverLogger } from "#veryfront/utils"; /** Node.js filesystem adapter. */ export class NodeFileSystemAdapter extends NodeCompatibleFileSystemAdapter { constructor(options: NodeFileSystemCapabilityOptions = {}) { super(serverLogger, options); - if (this.constructor === NodeFileSystemAdapter) { + if (isDirectConstruction(this, NodeFileSystemAdapter)) { markNativeFileSystemAdapter(this); } } diff --git a/src/platform/adapters/runtime/shared/node-filesystem-adapter.test.ts b/src/platform/adapters/runtime/shared/node-filesystem-adapter.test.ts index 5cc6a5bf0c..80cb16b27c 100644 --- a/src/platform/adapters/runtime/shared/node-filesystem-adapter.test.ts +++ b/src/platform/adapters/runtime/shared/node-filesystem-adapter.test.ts @@ -987,6 +987,26 @@ describe("NodeCompatibleFileSystemAdapter", () => { assertEquals(isNativeFileSystemAdapter(new DerivedAdapter()), false); }); + it("refuses a subclass that hides its own prototype.constructor", () => { + class ConstructorDeletingAdapter extends NodeCompatibleFileSystemAdapter {} + Reflect.deleteProperty(ConstructorDeletingAdapter.prototype, "constructor"); + + class ConstructorSpoofingAdapter extends NodeCompatibleFileSystemAdapter {} + Object.defineProperty(ConstructorSpoofingAdapter.prototype, "constructor", { + configurable: true, + value: NodeCompatibleFileSystemAdapter, + }); + + assertEquals( + isNativeFileSystemAdapter(new ConstructorDeletingAdapter()), + false, + ); + assertEquals( + isNativeFileSystemAdapter(new ConstructorSpoofingAdapter()), + false, + ); + }); + it("does not disguise invalid paths as missing", async () => { const adapter = new NodeCompatibleFileSystemAdapter(); await assertRejects(() => adapter.exists("\0"), TypeError); diff --git a/src/platform/adapters/runtime/shared/node-filesystem-adapter.ts b/src/platform/adapters/runtime/shared/node-filesystem-adapter.ts index 2dc969ee82..508e8fda51 100644 --- a/src/platform/adapters/runtime/shared/node-filesystem-adapter.ts +++ b/src/platform/adapters/runtime/shared/node-filesystem-adapter.ts @@ -15,7 +15,10 @@ import { readFileWithinLimit, withFileHandle, } from "../../bounded-file-read.ts"; -import { markNativeFileSystemAdapter } from "../../native-file-system-provenance.ts"; +import { + isDirectConstruction, + markNativeFileSystemAdapter, +} from "../../native-file-system-provenance.ts"; import { constants as nodeFsConstants } from "node:fs"; import { resolve } from "../../../compat/path/index.ts"; import { runtimeUsesWindowsPaths } from "../../../compat/path/portable.ts"; @@ -432,7 +435,7 @@ export class NodeCompatibleFileSystemAdapter implements FileSystemAdapter { enumerable: true, }); } - if (this.constructor === NodeCompatibleFileSystemAdapter) { + if (isDirectConstruction(this, NodeCompatibleFileSystemAdapter)) { markNativeFileSystemAdapter(this); } }