From a51e4b008ec843398be902c7c3dce382be8ef8e5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 10 Aug 2026 09:19:55 +0200 Subject: [PATCH 1/7] fix(cross-runtime): make YAML, workspace imports and std/testing resolve off Deno `deno task test:node` failed on 284 test files. Three of the four causes are fixed here, taking it to 101 on the same machine and command. **YAML (94 files).** `jsr:@std/yaml` resolves under Deno alone. Putting `npm:yaml` in the root import map fixes resolution and breaks a product invariant: `audit-core-deps.ts:25` exempts `jsr:@std/*` and nothing else, so core may depend on Deno stdlib but never on a third party. The sanctioned split is used instead -- `npm:yaml` lives in `extensions/ext-yaml`, a `YamlParserProvider` contract sits in core, and the compat shim resolves the contract and delegates, as `platform/compat/opaque-deps.ts` already does for `DocumentExtractor`. All five call sites move onto it. The 1.2 core schema drops YAML 1.1 timestamps, `<<` merge keys and `1_000` underscore separators; each difference is pinned by a test and documented in the extension README. **Workspace member imports (72 files, partial).** Deno applies a member's own `imports` to modules inside that member's directory; the Node resolver only read the root map, so `react/`'s aliases escaped to a real package lookup. The resolver now derives scopes from the member configs it already parses, deepest match wins. This does not close the class on its own -- see below. **`#std/testing/time` and unexported subpaths (52 files).** The specifier had no local shim, so Node reported the `#` import undefined. Adds a cross-runtime `FakeTime` and the missing export paths. **A ratchet so the class cannot return.** `lint:cross-runtime-jsr` fails on a `jsr:` mapping that neither alternate harness can substitute a local file for, and on new dependents of the ones already baselined. Wired into `lint:ci`. Deno is unaffected: 3781 passed, 0 failed. `lint:core-deps`, `lint:dependency-boundaries`, `lint:ci`, `typecheck` and `fmt --check` all exit 0 -- core still carries no third-party runtime dependency. Not fixed here, and the reason `test:node` still cannot pass from a clean checkout: the `@veryfront/react-*-upstream` packages are dnt build artifacts (`scripts/build/npm-react-shims.ts`), so they exist only after `deno task build:npm`. `tests/ensure-npm-links.mjs:50` returns silently when `npm/node_modules` is absent, so the dependency is undeclared and fails open. That has to be settled before either runtime is worth gating in CI. --- deno.json | 15 +- deno.lock | 9 +- docs/api-reference/veryfront/extensions.md | 35 +- extensions/ext-yaml/README.md | 44 +- extensions/ext-yaml/THIRD_PARTY_NOTICES.md | 37 +- extensions/ext-yaml/deno.json | 4 +- extensions/ext-yaml/src/adapter.test.ts | 84 +- extensions/ext-yaml/src/adapter.ts | 123 ++- extensions/ext-yaml/src/index.test.ts | 20 +- extensions/ext-yaml/src/index.ts | 31 +- scripts/build/npm-package-metadata.ts | 1 + scripts/lint/audit-cross-runtime-jsr.test.ts | 321 ++++++++ scripts/lint/audit-cross-runtime-jsr.ts | 720 ++++++++++++++++++ .../mdx-compiler/frontmatter-parser.ts | 2 +- src/build/compiler/mdx-to-js.ts | 2 +- src/config/tsconfig-paths-parity.test.ts | 113 +++ src/extensions/parser/index.ts | 12 +- src/extensions/parser/yaml-defaults.ts | 84 ++ src/extensions/parser/yaml-parser.test.ts | 83 ++ src/extensions/parser/yaml-parser.ts | 105 +++ src/extensions/recommendations.ts | 4 + src/platform/compat/shims/std-front-matter.ts | 2 +- src/platform/compat/std/front-matter-yaml.ts | 2 +- src/platform/compat/std/testing/time.test.ts | 157 ++++ src/platform/compat/std/testing/time.ts | 229 ++++++ src/platform/compat/std/yaml.test.ts | 62 ++ src/platform/compat/std/yaml.ts | 47 ++ src/react/compat/ssr-adapter/_test-setup.ts | 31 + .../rsc/server-renderer/rsc-renderer.test.ts | 1 + .../server-renderer/tree-processor.test.ts | 1 + tests/bun/preload.ts | 4 + tests/node-resolver-workspace-imports.test.ts | 126 +++ tests/node/resolver-hooks.mjs | 118 ++- tsconfig.json | 40 +- 34 files changed, 2563 insertions(+), 106 deletions(-) create mode 100644 scripts/lint/audit-cross-runtime-jsr.test.ts create mode 100644 scripts/lint/audit-cross-runtime-jsr.ts create mode 100644 src/config/tsconfig-paths-parity.test.ts create mode 100644 src/extensions/parser/yaml-defaults.ts create mode 100644 src/extensions/parser/yaml-parser.test.ts create mode 100644 src/extensions/parser/yaml-parser.ts create mode 100644 src/platform/compat/std/testing/time.test.ts create mode 100644 src/platform/compat/std/testing/time.ts create mode 100644 src/platform/compat/std/yaml.test.ts create mode 100644 src/platform/compat/std/yaml.ts create mode 100644 src/react/compat/ssr-adapter/_test-setup.ts create mode 100644 tests/node-resolver-workspace-imports.test.ts diff --git a/deno.json b/deno.json index bc030d3026..b6f35003d3 100644 --- a/deno.json +++ b/deno.json @@ -413,6 +413,10 @@ "#std/async.ts": "jsr:@std/async@1.2.0", "#std/front-matter/yaml": "./src/platform/compat/std/front-matter-yaml.ts", "#std/front-matter/yaml.ts": "./src/platform/compat/std/front-matter-yaml.ts", + "#std/yaml": "./src/platform/compat/std/yaml.ts", + "#std/yaml.ts": "./src/platform/compat/std/yaml.ts", + "#std/yaml/parse": "./src/platform/compat/std/yaml.ts", + "#std/yaml/parse.ts": "./src/platform/compat/std/yaml.ts", "@std/path": "jsr:@std/path@1.1.4", "@std/assert": "jsr:@std/assert@1.0.19", "@std/dotenv": "jsr:@std/dotenv@0.225.6", @@ -420,7 +424,7 @@ "@std/fmt/colors": "jsr:@std/fmt@1.0.9/colors", "@std/testing": "jsr:@std/testing@1.0.17", "@std/testing/bdd": "jsr:@std/testing@1.0.17/bdd", - "@std/yaml/parse": "jsr:@std/yaml@1.1.0/parse", + "@std/yaml/parse": "./src/platform/compat/std/yaml.ts", "@std/expect": "jsr:@std/expect@1.0.18", "@std/fs": "jsr:@std/fs@1.0.23", "@std/async": "jsr:@std/async@1.2.0", @@ -485,12 +489,12 @@ "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:ci": "deno task lint && deno task lint:core-deps && 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:esm-sh-codemod && deno task lint:test-typecheck && deno task storybook:check && deno task docs:api-reference:check && deno test --frozen --config=scripts/test.deno.json --no-check --allow-read --allow-write scripts/ci/setup-deno-workflow.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:esm-sh-codemod && deno task lint:test-typecheck && deno task storybook:check && deno task docs:api-reference:check && deno test --frozen --config=scripts/test.deno.json --no-check --allow-read --allow-write scripts/ci/setup-deno-workflow.test.ts scripts/build/generated-artifact-checks.test.ts", "fmt": "deno fmt src/ cli/ react/ && 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/ && 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/", "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: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:core-deps && 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: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: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:core-deps && 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:validate && deno task typecheck", + "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: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: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: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: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: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:validate && deno task typecheck", "typecheck:consumer": "deno run --allow-read --allow-run --allow-env --allow-write scripts/typecheck/run-consumer-typecheck.ts", "codemod:chat": "deno run --frozen --config=scripts/codemods/deno.json --allow-read --allow-write --allow-env=BABEL_TYPES_8_BREAKING scripts/codemods/migrate-chat-composition.ts", "codemod:esm-sh": "deno run --frozen --config=scripts/codemods/deno.json --allow-read --allow-write --allow-env=BABEL_TYPES_8_BREAKING scripts/codemods/migrate-esm-sh-imports.ts", @@ -506,6 +510,7 @@ "docs:check-links": "deno run -A scripts/lint/check-doc-links.ts", "lint:ban-zod": "deno run --allow-read scripts/lint/ban-zod-imports.ts", "lint:core-deps": "deno run --allow-read scripts/lint/audit-core-deps.ts", + "lint:cross-runtime-jsr": "deno run --allow-read scripts/lint/audit-cross-runtime-jsr.ts", "lint:dependency-boundaries": "deno run --allow-read scripts/lint/audit-dependency-boundaries.ts", "lint:module-boundaries": "DENO_NO_PACKAGE_JSON=1 deno run --frozen --allow-read scripts/lint/check-module-boundaries.ts", "lint:extension-contracts": "deno run --config=scripts/test.deno.json --frozen --allow-read scripts/lint/audit-extension-contracts.ts", @@ -527,7 +532,7 @@ "lint:ban-test-only": "deno run --allow-read scripts/lint/ban-test-only.ts", "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", - "test:scripts": "deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run 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-core-deps.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 && 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/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-core-deps.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 && 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/deno.lock b/deno.lock index 9848c78b5f..dbb66b04ad 100644 --- a/deno.lock +++ b/deno.lock @@ -102,6 +102,7 @@ "npm:unist-util-visit@5.1.0": "5.1.0", "npm:vfile@6.0.3": "6.0.3", "npm:ws@8.21.1": "8.21.1", + "npm:yaml@2.9.0": "2.9.0", "npm:zod@4.3.6": "4.3.6" }, "jsr": { @@ -389,7 +390,8 @@ "@smithy/types", "bowser", "tslib@2.8.1" - ] + ], + "deprecated": true }, "@aws-sdk/credential-provider-env@3.972.64": { "integrity": "sha512-14kkR5aj1c7D+cYCrEcG2W3xw87wMYAEUeB/tMiQ2j0RVKzzdewd4mJw1+Q0JVLr4IFU1JHGDCyj0WqLrtIixg==", @@ -6332,8 +6334,7 @@ "jsr:@std/fmt@1.0.9", "jsr:@std/fs@1.0.23", "jsr:@std/path@1.1.4", - "jsr:@std/testing@1.0.17", - "jsr:@std/yaml@1.1.0" + "jsr:@std/testing@1.0.17" ], "members": { "extensions/ext-auth-jwt": { @@ -6565,7 +6566,7 @@ "dependencies": [ "jsr:@std/assert@1.0.19", "jsr:@std/testing@1.0.17", - "jsr:@std/yaml@1.1.0" + "npm:yaml@2.9.0" ] } } diff --git a/docs/api-reference/veryfront/extensions.md b/docs/api-reference/veryfront/extensions.md index 0265cafff6..384321fbae 100644 --- a/docs/api-reference/veryfront/extensions.md +++ b/docs/api-reference/veryfront/extensions.md @@ -62,7 +62,7 @@ await loader.teardownAll(); | `discoverPackageExtensions` | Discover auto-activated package extensions without exposing identity internals. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/discovery.ts#L396) | | `discoverProjectExtensions` | Discover project extension paths without exposing identity internals. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/discovery.ts#L530) | | `formatCapabilities` | Format capabilities as human-readable strings for logging. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/capabilities.ts#L36) | -| `getRecommendation` | Return recommendation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/recommendations.ts#L34) | +| `getRecommendation` | Return recommendation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/recommendations.ts#L38) | | `isSupportedDenoSystemReadApi` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/capabilities.ts#L110) | | `loadExtensionFactory` | Dynamically import an extension factory from `path` and resolve it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/factory-loader.ts#L100) | | `mapToDenoPermissions` | Map capabilities to Deno CLI permission flags. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/capabilities.ts#L795) | @@ -801,12 +801,12 @@ import { ### `veryfront/extensions/parser` -Parser category barrel - CodeParser (AST traversal) contract. +Parser category barrel - CodeParser (AST traversal), SkillDocumentParser (Skill frontmatter decoding), and YamlParser (general YAML decoding) contracts. ```ts import { createSkillDocumentParserProvider, - SkillDocumentParserProviderName, + createYamlParserProvider, snapshotSkillDocumentParserProvider, } from "veryfront/extensions/parser"; ``` @@ -816,28 +816,33 @@ import { | Name | Description | Source | | --------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `SkillDocumentParserProviderName` | Stable runtime identifier for the Skill document parser contract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/skill-document-parser.ts#L158) | +| `YamlParserProviderName` | Stable runtime identifier for the general YAML parser contract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/yaml-parser.ts#L22) | #### Functions | Name | Description | Source | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `createSkillDocumentParserProvider` | Create immutable provider registration metadata from a standalone parser. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/skill-document-parser.ts#L261) | +| `createYamlParserProvider` | Create immutable provider registration metadata from a standalone parser. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/yaml-parser.ts#L101) | | `snapshotSkillDocumentParserProvider` | Capture one immutable provider generation without retaining its mutable registration object or invoking extension-owned accessors or Proxy traps. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/skill-document-parser.ts#L183) | +| `snapshotYamlParserProvider` | Capture one immutable provider generation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/yaml-parser.ts#L68) | #### Types -| Name | Description | Source | -| ------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `ASTNode` | A single node in an abstract syntax tree. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L10) | -| `CodeParser` | Public API contract for code parser. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L90) | -| `FunctionDirectiveOptions` | Options for a parser-owned function directive check. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L54) | -| `GenerateOptions` | Options passed to `CodeParser.generate`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L60) | -| `GenerateResult` | Result returned from `CodeParser.generate`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L70) | -| `InjectJsxNodePositionsOptions` | Options for `CodeParser.injectJsxNodePositions`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L84) | -| `NodePath` | Wrapper providing traversal context for a visited node. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L22) | -| `ParseOptions` | Options passed to `CodeParser.parse`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L44) | -| `SkillDocumentParserProvider` | Dependency-free contract implemented by Skill YAML parser extensions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/skill-document-parser.ts#L161) | -| `TraverseVisitor` | Visitor callbacks keyed by node type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L34) | +| Name | Description | Source | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `ASTNode` | A single node in an abstract syntax tree. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L10) | +| `CodeParser` | Public API contract for code parser. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L90) | +| `FunctionDirectiveOptions` | Options for a parser-owned function directive check. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L54) | +| `GenerateOptions` | Options passed to `CodeParser.generate`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L60) | +| `GenerateResult` | Result returned from `CodeParser.generate`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L70) | +| `InjectJsxNodePositionsOptions` | Options for `CodeParser.injectJsxNodePositions`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L84) | +| `NodePath` | Wrapper providing traversal context for a visited node. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L22) | +| `ParseOptions` | Options passed to `CodeParser.parse`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L44) | +| `SkillDocumentParserProvider` | Dependency-free contract implemented by Skill YAML parser extensions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/skill-document-parser.ts#L161) | +| `TraverseVisitor` | Visitor callbacks keyed by node type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/code-parser.ts#L34) | +| `YamlParseOptions` | Decoding options, named after the `@std/yaml` options the framework's call sites already pass so that repointing a call site is a specifier change. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/yaml-parser.ts#L28) | +| `YamlParserProvider` | Dependency-free contract implemented by YAML parser extensions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/parser/yaml-parser.ts#L43) | ### `veryfront/extensions/rendering` diff --git a/extensions/ext-yaml/README.md b/extensions/ext-yaml/README.md index aaa67cc219..97a5e28075 100644 --- a/extensions/ext-yaml/README.md +++ b/extensions/ext-yaml/README.md @@ -1,23 +1,44 @@ # @veryfront/ext-yaml -> **Category:** Parser | **Contract:** `SkillDocumentParserProvider` +> **Category:** Parser | **Contracts:** `SkillDocumentParserProvider`, +> `YamlParserProvider` -Provides synchronous YAML decoding for Veryfront Skill document frontmatter, -backed by the pinned `@std/yaml` implementation. +Provides synchronous YAML decoding for Veryfront, backed by the pinned `yaml` +(eemeli) implementation. It satisfies two contracts: the narrow +`SkillDocumentParserProvider` used at the Skill document trust boundary, and the +general `YamlParserProvider` that core's `#std/yaml/parse` compatibility shim +resolves for front matter and MDX. Veryfront core owns the `SKILL.md` frontmatter envelope, document limits, mapping-root validation, immutable snapshots, and Skill metadata policy. This extension receives only the YAML source between the delimiters and returns the decoded, untrusted value. Keeping that boundary narrow prevents YAML parser -details and third-party dependencies from entering core. +details and third-party dependencies from entering core — core may depend on +the Deno standard library and nothing else, so the parser has to live here. + +## YAML version + +`yaml` implements YAML 1.2. `jsr:@std/yaml`, which this extension previously +wrapped, implements YAML 1.1. Three resolution differences are deliberate and +covered by tests: + +| Source | YAML 1.1 (`@std/yaml`) | YAML 1.2 (this extension) | +| --------------------- | ---------------------- | ------------------------- | +| `a: 1_000` | `1000` | `"1_000"` | +| `created: 2024-01-02` | `Date` | `"2024-01-02"` | +| `<<: *anchor` | merged into the map | a literal `"<<"` key | + +`schema: "json"` additionally rejects every explicit tag outside the JSON core +set, so `!!binary`, `!!timestamp`, `!!set` and unknown tags raise `SyntaxError` +rather than decoding into implementation-specific values. ## Activation The package declares automatic activation. Once `@veryfront/ext-yaml` is installed, the standard Veryfront server bootstrap discovers it and registers -`SkillDocumentParserProvider` before project capability discovery. Core fails -closed when no parser extension is installed; it does not reinterpret malformed -YAML with a partial built-in grammar. +both contracts before project capability discovery. Core fails closed when no +parser extension is installed; it does not reinterpret malformed YAML with a +partial built-in grammar. Composition roots that do not run standard extension discovery can register the factory explicitly: @@ -37,10 +58,13 @@ Composition roots that manage contracts directly can create the immutable provider without running the extension lifecycle: ```ts -import { createStdYamlSkillDocumentParserProvider } from "@veryfront/ext-yaml"; +import { createStdYamlSkillDocumentParserProvider, createYamlParser } from "@veryfront/ext-yaml"; + +const skillParser = createStdYamlSkillDocumentParserProvider(); +const decoded = skillParser.parseFrontmatter("name: example"); -const parser = createStdYamlSkillDocumentParserProvider(); -const decoded = parser.parseFrontmatter("name: example"); +const yamlParser = createYamlParser(); +const value = yamlParser.parseYaml("name: example", { schema: "json" }); ``` The return value remains `unknown`; callers must enforce their own mapping and diff --git a/extensions/ext-yaml/THIRD_PARTY_NOTICES.md b/extensions/ext-yaml/THIRD_PARTY_NOTICES.md index b52916f657..b2a55781a1 100644 --- a/extensions/ext-yaml/THIRD_PARTY_NOTICES.md +++ b/extensions/ext-yaml/THIRD_PARTY_NOTICES.md @@ -1,29 +1,22 @@ # Third-party notices -This extension uses `@std/yaml` 1.1.0. The YAML implementation is distributed -under the MIT License and is derived from js-yaml v3.13.1. +This extension uses `yaml` 2.9.0 (eemeli/yaml), a dependency-free YAML 1.2 +parser distributed under the ISC License. -## Deno Standard Library YAML +## yaml -MIT License +ISC License -Copyright 2011-2015 by Vitaly Puzrin. All rights reserved. +Copyright Eemeli Aro -Copyright 2018-2022 the Deno authors. +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/extensions/ext-yaml/deno.json b/extensions/ext-yaml/deno.json index 2e3fce157e..2f45b60597 100644 --- a/extensions/ext-yaml/deno.json +++ b/extensions/ext-yaml/deno.json @@ -6,7 +6,7 @@ "extension": true, "activation": "auto", "contracts": { - "provides": ["SkillDocumentParserProvider"] + "provides": ["SkillDocumentParserProvider", "YamlParserProvider"] }, "capabilities": [], "npm": { @@ -14,7 +14,7 @@ } }, "imports": { - "@std/yaml/parse": "jsr:@std/yaml@1.1.0/parse", + "yaml": "npm:yaml@2.9.0", "@std/assert": "jsr:@std/assert@1.0.19", "@std/testing/bdd": "jsr:@std/testing@1.0.17/bdd", "veryfront/extensions": "../../src/extensions/types.ts", diff --git a/extensions/ext-yaml/src/adapter.test.ts b/extensions/ext-yaml/src/adapter.test.ts index 4ed95f94e4..311c1b7d92 100644 --- a/extensions/ext-yaml/src/adapter.test.ts +++ b/extensions/ext-yaml/src/adapter.test.ts @@ -1,8 +1,12 @@ import { assertEquals, assertThrows } from "@std/assert"; import { describe, it } from "@std/testing/bdd"; -import { createStdYamlSkillDocumentParserProvider } from "./adapter.ts"; +import { + createStdYamlSkillDocumentParserProvider, + createYamlParser, + parseYamlSource, +} from "./adapter.ts"; -describe("@std/yaml Skill document parser", () => { +describe("yaml Skill document parser", () => { it("decodes the YAML source without owning the Skill document envelope", () => { const parser = createStdYamlSkillDocumentParserProvider(); @@ -63,3 +67,79 @@ describe("@std/yaml Skill document parser", () => { ]); }); }); + +describe("yaml general parser", () => { + it("decodes without the Skill boundary's JSON restriction", () => { + const parser = createYamlParser(); + + assertEquals(parser.parseYaml("tags:\n - one\n - two\nnested:\n key: value"), { + tags: ["one", "two"], + nested: { key: "value" }, + }); + assertEquals(parser.parseYaml(""), null); + }); + + it("rejects duplicate keys unless the caller opts in", () => { + assertThrows(() => parseYamlSource("name: a\nname: b"), SyntaxError); + assertEquals(parseYamlSource("name: a\nname: b", { allowDuplicateKeys: true }), { + name: "b", + }); + }); + + it("rejects a multi-document stream", () => { + assertThrows( + () => parseYamlSource("a: 1\n---\nb: 2"), + SyntaxError, + "more than 1 document", + ); + }); + + it("rejects a tag it cannot resolve instead of guessing a value", () => { + assertThrows(() => parseYamlSource("x: !custom value"), SyntaxError); + }); + + it("still decodes when Object.prototype carries a read-only value property", () => { + // The framework's Skill and agent trust boundaries assert this across + // roughly twenty test files: a poisoned Object.prototype must not stop a + // well-formed document from decoding, or the hardening under test is never + // reached. `yaml` assigns `this.value` while building its AST. + Object.defineProperty(Object.prototype, "value", { + configurable: true, + value: "poisoned", + }); + try { + assertEquals(parseYamlSource("name: research", { schema: "json" }), { + name: "research", + }); + } finally { + delete (Object.prototype as Record).value; + } + + // The poison is restored for whoever installed it. + Object.defineProperty(Object.prototype, "value", { + configurable: true, + value: "poisoned", + }); + try { + parseYamlSource("name: research"); + assertEquals( + (Object.getOwnPropertyDescriptor(Object.prototype, "value") ?? {}).value, + "poisoned", + ); + } finally { + delete (Object.prototype as Record).value; + } + }); + + it("resolves the YAML 1.2 core schema, not YAML 1.1", () => { + // These three are the deliberate, documented differences from + // jsr:@std/yaml. Underscore digit separators, timestamps, and `<<` merge + // keys are YAML 1.1 types that the 1.2 core schema does not resolve. + assertEquals(parseYamlSource("a: 1_000"), { a: "1_000" }); + assertEquals(parseYamlSource("created: 2024-01-02"), { created: "2024-01-02" }); + assertEquals(parseYamlSource("base: &b\n x: 1\nchild:\n <<: *b\n y: 2"), { + base: { x: 1 }, + child: { "<<": { x: 1 }, y: 2 }, + }); + }); +}); diff --git a/extensions/ext-yaml/src/adapter.ts b/extensions/ext-yaml/src/adapter.ts index c0776f54a0..87d7ba7ee6 100644 --- a/extensions/ext-yaml/src/adapter.ts +++ b/extensions/ext-yaml/src/adapter.ts @@ -1,15 +1,132 @@ -import { parse } from "@std/yaml/parse"; +import { isNode, parseAllDocuments, visit } from "yaml"; import { createSkillDocumentParserProvider, + createYamlParserProvider, type SkillDocumentParserProvider, + type YamlParseOptions, + type YamlParserProvider, } from "veryfront/extensions/parser"; -/** Create the official @std/yaml-backed Skill frontmatter parser. */ +/** + * The tags a JSON-representable document may carry explicitly. `yaml`'s + * `Schema.knownTags` fallback resolves YAML 1.1 tags such as `!!binary`, + * `!!timestamp`, `!!set` and `!!omap` even under the 1.2 core schema, and does + * so without raising a warning — so the parser options alone cannot express + * `@std/yaml`'s JSON schema. Rejecting every other explicit tag does. + */ +const JSON_SCHEMA_TAGS: ReadonlySet = new Set([ + "tag:yaml.org,2002:map", + "tag:yaml.org,2002:seq", + "tag:yaml.org,2002:str", + "tag:yaml.org,2002:null", + "tag:yaml.org,2002:bool", + "tag:yaml.org,2002:int", + "tag:yaml.org,2002:float", +]); + +function assertJsonRepresentableTags(document: unknown): void { + visit(document as Parameters[0], (_key, node) => { + if (!isNode(node)) return; + const tag = node.tag; + if (typeof tag === "string" && !JSON_SCHEMA_TAGS.has(tag)) { + throw new SyntaxError(`Cannot resolve unknown tag !<${tag}>`); + } + }); +} + +/** + * Run a synchronous parse with any own `value` property lifted off + * `Object.prototype`. + * + * `yaml` builds its AST by assigning `this.value = …` on class instances. A + * poisoned non-writable `Object.prototype.value` makes strict mode reject + * every one of those assignments, so no document parses at all. Surviving a + * poisoned `Object.prototype` is an invariant the framework asserts across its + * Skill and agent trust boundaries, and `@std/yaml` happened to satisfy it, so + * the property is lifted for the duration of the parse and restored + * afterwards. + * + * Nothing can observe the gap: the parse is synchronous, never yields, and + * invokes no caller-supplied code (no custom tags, no reviver). + */ +function withoutPollutedValuePrototype(run: () => T): T { + const polluted = Object.getOwnPropertyDescriptor(Object.prototype, "value"); + if (polluted === undefined || polluted.configurable !== true) return run(); + + Reflect.deleteProperty(Object.prototype, "value"); + try { + return run(); + } finally { + Object.defineProperty(Object.prototype, "value", polluted); + } +} + +/** + * Decode one YAML document with `@std/yaml`-compatible failure behaviour. + * + * `yaml` reports most problems on the returned document instead of throwing, + * and raises `YAMLParseError` rather than `SyntaxError` when it does throw. + * Both are normalised here so call sites keep the single `SyntaxError` + * contract they were written against. + */ +export function parseYamlSource( + source: string, + options: YamlParseOptions = {}, +): unknown { + return withoutPollutedValuePrototype(() => decodeDocument(source, options)); +} + +function decodeDocument(source: string, options: YamlParseOptions): unknown { + const jsonSchema = options.schema === "json"; + + let documents; + try { + documents = parseAllDocuments(source, { + uniqueKeys: options.allowDuplicateKeys !== true, + // Warnings are inspected below; left to the library they would be + // written straight to the host process's stderr. + logLevel: "silent", + }); + } catch (cause) { + throw new SyntaxError(cause instanceof Error ? cause.message : String(cause), { cause }); + } + + if (documents.length === 0) return null; + if (documents.length > 1) { + throw new SyntaxError( + "Found more than 1 document in the stream: expected a single document", + ); + } + + const document = documents[0]!; + // An unresolved tag is a warning in `yaml` and an error in `@std/yaml`. + // Treat it as an error: a tag the parser did not understand means the + // decoded value is not the one the document asked for. + const problem = document.errors[0] ?? document.warnings[0]; + if (problem) throw new SyntaxError(problem.message, { cause: problem }); + + if (jsonSchema) assertJsonRepresentableTags(document); + + return document.toJS(); +} + +/** Create the official `yaml`-backed general YAML parser. */ +export function createYamlParser(): Readonly { + return createYamlParserProvider(parseYamlSource); +} + +/** + * Create the official `yaml`-backed Skill frontmatter parser. + * + * The name predates the move off `@std/yaml`; it is the export name + * `src/extensions/parser/skill-defaults.ts` looks up in the published package, + * so it stays put. + */ export function createStdYamlSkillDocumentParserProvider(): Readonly< SkillDocumentParserProvider > { return createSkillDocumentParserProvider((source) => - parse(source, { + parseYamlSource(source, { allowDuplicateKeys: false, schema: "json", }) diff --git a/extensions/ext-yaml/src/index.test.ts b/extensions/ext-yaml/src/index.test.ts index d99cd2238e..a11bc5298c 100644 --- a/extensions/ext-yaml/src/index.test.ts +++ b/extensions/ext-yaml/src/index.test.ts @@ -4,6 +4,8 @@ import type { ExtensionContext } from "veryfront/extensions"; import { type SkillDocumentParserProvider, SkillDocumentParserProviderName, + type YamlParserProvider, + YamlParserProviderName, } from "veryfront/extensions/parser"; import manifest from "../deno.json" with { type: "json" }; import extYaml from "./index.ts"; @@ -16,21 +18,24 @@ describe("ext-yaml", () => { assertEquals(extension.version, manifest.version); assertEquals(extension.contracts?.provides, [ SkillDocumentParserProviderName, + YamlParserProviderName, ]); assertEquals(extension.capabilities, []); assertEquals(manifest.veryfront.contracts.provides, [ SkillDocumentParserProviderName, + YamlParserProviderName, ]); assertEquals(manifest.veryfront.capabilities, []); assertEquals(manifest.veryfront.activation, "auto"); - assertEquals( - manifest.imports["@std/yaml/parse"], - "jsr:@std/yaml@1.1.0/parse", - ); + // The parser is the extension's whole reason to exist and the only place + // in the repository allowed to depend on it; pin it here so a version + // bump is a deliberate edit to this test. + assertEquals(manifest.imports["yaml"], "npm:yaml@2.9.0"); }); it("provides a working synchronous parser through the extension context", async () => { let providedParser: SkillDocumentParserProvider | undefined; + let providedYamlParser: YamlParserProvider | undefined; const ctx: ExtensionContext = { config: {}, logger: { @@ -47,6 +52,9 @@ describe("ext-yaml", () => { if (contract === SkillDocumentParserProviderName) { providedParser = implementation as SkillDocumentParserProvider; } + if (contract === YamlParserProviderName) { + providedYamlParser = implementation as YamlParserProvider; + } }, }; @@ -58,5 +66,9 @@ describe("ext-yaml", () => { name: "demo", }); assertEquals(Object.isFrozen(providedParser), true); + + assert(providedYamlParser); + assertEquals(providedYamlParser.parseYaml("name: demo"), { name: "demo" }); + assertEquals(Object.isFrozen(providedYamlParser), true); }); }); diff --git a/extensions/ext-yaml/src/index.ts b/extensions/ext-yaml/src/index.ts index e52eccd252..b24646a77d 100644 --- a/extensions/ext-yaml/src/index.ts +++ b/extensions/ext-yaml/src/index.ts @@ -1,29 +1,38 @@ /** - * Official @std/yaml implementation of the SkillDocumentParserProvider - * extension contract. + * Official `yaml` (eemeli, YAML 1.2) implementation of the framework's YAML + * extension contracts. + * + * Provides both the narrow `SkillDocumentParserProvider` used at the Skill + * document trust boundary and the general `YamlParserProvider` the framework's + * `#std/yaml/parse` compatibility shim resolves. * * @module extensions/ext-yaml */ import type { ExtensionFactory } from "veryfront/extensions"; -import { SkillDocumentParserProviderName } from "veryfront/extensions/parser"; +import { + SkillDocumentParserProviderName, + YamlParserProviderName, +} from "veryfront/extensions/parser"; import extensionPackage from "../deno.json" with { type: "json" }; -import { createStdYamlSkillDocumentParserProvider } from "./adapter.ts"; +import { createStdYamlSkillDocumentParserProvider, createYamlParser } from "./adapter.ts"; const extYaml: ExtensionFactory = () => { - const provider = createStdYamlSkillDocumentParserProvider(); + const skillParser = createStdYamlSkillDocumentParserProvider(); + const yamlParser = createYamlParser(); return { name: "ext-yaml", version: extensionPackage.version, contracts: { - provides: [SkillDocumentParserProviderName], + provides: [SkillDocumentParserProviderName, YamlParserProviderName], }, capabilities: [], setup(ctx) { - ctx.provide(SkillDocumentParserProviderName, provider); + ctx.provide(SkillDocumentParserProviderName, skillParser); + ctx.provide(YamlParserProviderName, yamlParser); ctx.logger.debug( - "[ext-yaml] SkillDocumentParserProvider registered", + "[ext-yaml] SkillDocumentParserProvider and YamlParserProvider registered", ); }, teardown() { @@ -33,4 +42,8 @@ const extYaml: ExtensionFactory = () => { }; export default extYaml; -export { createStdYamlSkillDocumentParserProvider } from "./adapter.ts"; +export { + createStdYamlSkillDocumentParserProvider, + createYamlParser, + parseYamlSource, +} from "./adapter.ts"; diff --git a/scripts/build/npm-package-metadata.ts b/scripts/build/npm-package-metadata.ts index abe719637a..06d3bc5043 100644 --- a/scripts/build/npm-package-metadata.ts +++ b/scripts/build/npm-package-metadata.ts @@ -98,6 +98,7 @@ export const EXTENSION_OWNED_DEPENDENCIES = [ "unist-util-visit", "vfile", "ws", + "yaml", ] as const; const STALE_DEV_DEPENDENCIES = [ diff --git a/scripts/lint/audit-cross-runtime-jsr.test.ts b/scripts/lint/audit-cross-runtime-jsr.test.ts new file mode 100644 index 0000000000..17f6924455 --- /dev/null +++ b/scripts/lint/audit-cross-runtime-jsr.test.ts @@ -0,0 +1,321 @@ +import { assertEquals } from "#std/assert"; +import { describe, it } from "#std/testing/bdd"; +import { + auditCrossRuntimeImports, + compareAgainstBaseline, + type CrossRuntimeImport, + failingRuntimes, + flattenTsconfigPaths, + hasFailures, + isShimmedEverywhere, + isStdOrJsrSpecifier, + normalizeStdSpecifier, + parseStdShimMap, + resolvesOnBun, + resolvesOnNode, + resolveTsconfigPath, + type RuntimeResolutionContext, +} from "./audit-cross-runtime-jsr.ts"; + +/** + * A miniature repo: `#std/path` is shimmed everywhere, `#std/testing/time` is + * mapped to JSR with no shim, and `#std/fs/walk` has a shim FILE but no + * tsconfig key (the Node-only trap). + */ +function makeContext( + overrides: Partial = {}, +): RuntimeResolutionContext { + const present = new Set([ + "src/platform/compat/std/path.ts", + "src/platform/compat/std/fs/walk.ts", + ]); + return { + imports: { + "#std/path": "jsr:@std/path@1.1.4", + "#std/testing/time": "jsr:@std/testing@1.0.17/time", + "#std/fs/walk": "jsr:@std/fs@1.0.23/walk", + "@std/path": "jsr:@std/path@1.1.4", + }, + nodeStdShims: { "#std/path": "./src/platform/compat/std/path.ts" }, + tsconfigPaths: { + "#std/path": "./src/platform/compat/std/path.ts", + "@std/path": "./src/platform/compat/std/path.ts", + "#veryfront/*": "./src/*", + }, + fileExists: (path) => present.has(path), + ...overrides, + }; +} + +function importOf(file: string, specifier: string): CrossRuntimeImport { + return { file, line: 1, specifier }; +} + +describe("normalizeStdSpecifier", () => { + it("folds both bare spellings into the #std alias namespace", () => { + assertEquals(normalizeStdSpecifier("@std/path"), "#std/path"); + assertEquals(normalizeStdSpecifier("std/path"), "#std/path"); + assertEquals(normalizeStdSpecifier("#std/path"), "#std/path"); + assertEquals(normalizeStdSpecifier("#veryfront/utils"), "#veryfront/utils"); + }); +}); + +describe("isStdOrJsrSpecifier", () => { + it("selects only std aliases and jsr specifiers", () => { + assertEquals(isStdOrJsrSpecifier("jsr:@std/path@1.1.4"), true); + assertEquals(isStdOrJsrSpecifier("jsr:@luca/cases"), true); + assertEquals(isStdOrJsrSpecifier("@std/yaml/parse"), true); + assertEquals(isStdOrJsrSpecifier("#std/fs"), true); + assertEquals(isStdOrJsrSpecifier("std/fs"), true); + assertEquals(isStdOrJsrSpecifier("./relative.ts"), false); + assertEquals(isStdOrJsrSpecifier("#veryfront/utils"), false); + assertEquals(isStdOrJsrSpecifier("npm:zod"), false); + }); +}); + +describe("parseStdShimMap", () => { + it("reads the literal out of a resolver so the model cannot drift", () => { + const source = [ + "const before = 1;", + "const stdImportMap: Record = {", + ' "#std/fs": "./src/platform/compat/std/fs.ts",', + ' "#std/path": "./src/platform/compat/std/path.ts",', + "};", + "const after = 2;", + ].join("\n"); + assertEquals(parseStdShimMap(source), { + "#std/fs": "./src/platform/compat/std/fs.ts", + "#std/path": "./src/platform/compat/std/path.ts", + }); + }); + + it("returns nothing when the literal is missing", () => { + assertEquals(parseStdShimMap("const other = {};"), {}); + }); +}); + +describe("resolveTsconfigPath", () => { + it("prefers an exact key over a wildcard", () => { + const paths = { "#a/b": "./exact.ts", "#a/*": "./wild/*.ts" }; + assertEquals(resolveTsconfigPath(paths, "#a/b"), "./exact.ts"); + }); + + it("substitutes the wildcard and takes the longest matching prefix", () => { + const paths = { "#a/*": "./short/*", "#a/deep/*": "./long/*" }; + assertEquals(resolveTsconfigPath(paths, "#a/deep/x.ts"), "./long/x.ts"); + assertEquals(resolveTsconfigPath(paths, "#a/x.ts"), "./short/x.ts"); + }); + + it("returns null for an unmapped specifier", () => { + assertEquals(resolveTsconfigPath({ "#a/b": "./x.ts" }, "#a/c"), null); + }); +}); + +describe("flattenTsconfigPaths", () => { + it("takes the first candidate of each paths entry", () => { + assertEquals( + flattenTsconfigPaths({ "#a": ["./one.ts", "./two.ts"], "#b": [] }), + { "#a": "./one.ts" }, + ); + }); +}); + +describe("resolvesOnNode", () => { + it("follows a jsr: import-map target to the compat shim", () => { + assertEquals(resolvesOnNode("#std/path", makeContext()), true); + }); + + it("accepts the src/platform/compat/std convention with no shim entry", () => { + // Node's `resolveStdCompatTarget` invents this path. Bun does not. + assertEquals(resolvesOnNode("#std/fs/walk", makeContext()), true); + }); + + it("rejects a specifier with no shim file behind it", () => { + assertEquals(resolvesOnNode("#std/testing/time", makeContext()), false); + }); + + it("resolves a direct jsr:@std specifier — which Bun cannot", () => { + assertEquals(resolvesOnNode("jsr:@std/path@1.1.4", makeContext()), true); + }); + + it("rejects a non-std jsr specifier", () => { + assertEquals(resolvesOnNode("jsr:@luca/cases@1", makeContext()), false); + }); +}); + +describe("resolvesOnBun", () => { + it("resolves only through tsconfig paths", () => { + assertEquals(resolvesOnBun("#std/path", makeContext()), true); + assertEquals(resolvesOnBun("@std/path", makeContext()), true); + }); + + it("rejects a shim file that has no tsconfig paths key", () => { + // The file exists and Node finds it by convention; Bun never will. + assertEquals(resolvesOnBun("#std/fs/walk", makeContext()), false); + }); + + it("does not fold @std into #std the way the runners' maps do", () => { + const context = makeContext({ + tsconfigPaths: { "#std/path": "./src/platform/compat/std/path.ts" }, + }); + assertEquals(resolvesOnBun("#std/path", context), true); + assertEquals(resolvesOnBun("@std/path", context), false); + }); + + it("rejects every jsr: specifier, shimmed alias or not", () => { + // The regression this audit exists for: `#std/path` is fully shimmed, and + // the jsr: spelling of the same module is still unresolvable on Bun. + assertEquals(resolvesOnBun("jsr:@std/path@1.1.4", makeContext()), false); + }); + + it("rejects a deno.json-only local mapping", () => { + const context = makeContext({ + imports: { "#std/only-deno": "./src/platform/compat/std/path.ts" }, + tsconfigPaths: {}, + }); + assertEquals(resolvesOnBun("#std/only-deno", context), false); + }); +}); + +describe("isShimmedEverywhere", () => { + it("takes the stricter of the two runtimes", () => { + const context = makeContext(); + assertEquals(isShimmedEverywhere("#std/path", context), true); + // Node yes, Bun no. + assertEquals(isShimmedEverywhere("#std/fs/walk", context), false); + assertEquals(isShimmedEverywhere("#std/testing/time", context), false); + }); +}); + +describe("failingRuntimes", () => { + it("names the runtime that actually breaks", () => { + const context = makeContext(); + assertEquals(failingRuntimes("#std/fs/walk", context), ["Bun"]); + assertEquals(failingRuntimes("#std/testing/time", context), [ + "Node", + "Bun", + ]); + assertEquals(failingRuntimes("jsr:@std/path@1.1.4", context), ["Bun"]); + assertEquals(failingRuntimes("#std/path", context), []); + }); +}); + +describe("auditCrossRuntimeImports", () => { + it("flags a direct jsr: import even when the #std alias is shimmed", () => { + const audit = auditCrossRuntimeImports( + [importOf("src/a.ts", "jsr:@std/path@1.1.4")], + makeContext(), + ); + assertEquals(audit.directJsrImports.length, 1); + assertEquals(audit.directJsrImports[0].file, "src/a.ts"); + assertEquals(audit.unshimmedDependents.size, 0); + }); + + it("ignores shimmed specifiers and out-of-scope imports", () => { + const audit = auditCrossRuntimeImports( + [ + importOf("src/a.ts", "#std/path"), + importOf("src/a.ts", "./local.ts"), + importOf("src/a.ts", "npm:zod"), + ], + makeContext(), + ); + assertEquals(audit.directJsrImports.length, 0); + assertEquals(audit.unshimmedDependents.size, 0); + }); + + it("collects unique dependent files per unshimmed specifier", () => { + const audit = auditCrossRuntimeImports( + [ + importOf("src/b.ts", "#std/testing/time"), + importOf("src/a.ts", "#std/testing/time"), + importOf("src/a.ts", "#std/testing/time"), + ], + makeContext(), + ); + assertEquals(audit.unshimmedDependents.get("#std/testing/time"), [ + "src/a.ts", + "src/b.ts", + ]); + }); +}); + +describe("compareAgainstBaseline", () => { + const context = makeContext(); + + it("passes when the dependent count matches the baseline", () => { + const audit = auditCrossRuntimeImports( + [importOf("src/a.ts", "#std/testing/time")], + context, + ); + const comparison = compareAgainstBaseline( + audit, + { "#std/testing/time": 1 }, + context, + ); + assertEquals(hasFailures(comparison), false); + }); + + it("fails when a baselined specifier gains a dependent", () => { + // The hole a mapping-count ratchet leaves open: no NEW specifier, one new + // broken Node/Bun test file. + const audit = auditCrossRuntimeImports( + [ + importOf("src/a.ts", "#std/testing/time"), + importOf("src/b.ts", "#std/testing/time"), + ], + context, + ); + const comparison = compareAgainstBaseline( + audit, + { "#std/testing/time": 1 }, + context, + ); + assertEquals(hasFailures(comparison), true); + assertEquals(comparison.grown.length, 1); + assertEquals(comparison.grown[0].current, 2); + assertEquals(comparison.grown[0].baseline, 1); + }); + + it("fails on an unshimmed specifier that has no baseline entry", () => { + const audit = auditCrossRuntimeImports( + [importOf("src/a.ts", "#std/fs/walk")], + context, + ); + const comparison = compareAgainstBaseline(audit, {}, context); + assertEquals(hasFailures(comparison), true); + assertEquals(comparison.newSpecifiers[0].specifier, "#std/fs/walk"); + }); + + it("reports a shrink without failing", () => { + const audit = auditCrossRuntimeImports( + [importOf("src/a.ts", "#std/testing/time")], + context, + ); + const comparison = compareAgainstBaseline( + audit, + { "#std/testing/time": 3 }, + context, + ); + assertEquals(hasFailures(comparison), false); + assertEquals(comparison.shrunk, [{ + specifier: "#std/testing/time", + baseline: 3, + current: 1, + }]); + }); + + it("separates 'a shim landed' from 'nothing imports it any more'", () => { + // #std/path resolves on both runtimes; #std/testing/time does not and is + // simply unused. Collapsing these two into one message sends the reader + // looking for a shim that was never written. + const comparison = compareAgainstBaseline( + auditCrossRuntimeImports([], context), + { "#std/path": 2, "#std/testing/time": 2 }, + context, + ); + assertEquals(comparison.staleShimmed, ["#std/path"]); + assertEquals(comparison.staleUnused, ["#std/testing/time"]); + assertEquals(hasFailures(comparison), false); + }); +}); diff --git a/scripts/lint/audit-cross-runtime-jsr.ts b/scripts/lint/audit-cross-runtime-jsr.ts new file mode 100644 index 0000000000..33220354a3 --- /dev/null +++ b/scripts/lint/audit-cross-runtime-jsr.ts @@ -0,0 +1,720 @@ +#!/usr/bin/env -S deno run --allow-read +/** + * Cross-runtime resolvability audit for `@std` / `jsr:` imports. + * + * Deno is the primary runtime, but `src/` is also executed by the Node and Bun + * test runners (`deno task test:node`, `deno task test:bun`). Neither of those + * runtimes understands JSR. They only work because a std specifier is rewritten + * to a LOCAL compat shim under `src/platform/compat/std/` — and each runtime + * does that rewriting through a DIFFERENT config file: + * + * - Node reads `deno.json` `imports` through `tests/node/resolver-hooks.mjs`, + * which maps a `jsr:@std/...` target onto the compat shim. + * - Bun reads `tsconfig.json` `compilerOptions.paths`. Verified by + * experiment: Bun never consults the `tests/bun/preload.ts` `onResolve` + * plugin for a `#std/...` or `@std/...` specifier (instrumenting the plugin + * logs zero hits for them), and adding a local mapping to `deno.json` + * alone does not make Bun resolve it, while adding the same mapping to + * `tsconfig.json` `paths` does. + * + * So a std specifier is only safe when it is shimmed in BOTH places. Getting + * one of the two right produces a green Deno run, a green lint, a green + * typecheck, and a failing Node or Bun suite much later, reported as an + * unrelated-looking test-file error. This audit moves that failure to the + * moment the import is written. + * + * Two rules: + * + * 1. HARD — a cross-runtime file may never import a `jsr:` specifier directly. + * Bun has no `jsr:` protocol; `tsconfig.json` `paths` has no `jsr:` key; and + * the alias plugin in `tests/bun/preload.ts` filters on + * `/^(#deno-config|@std\/|#std\/|std\/|#veryfront...)/`, which cannot match + * a `jsr:` specifier. Node happens to cope — `resolveJsrStdSpecifier` in + * resolver-hooks.mjs intercepts `jsr:@std/*` — which is exactly why "the + * equivalent `#std/` alias is shimmed" and "it resolves on Node" are both + * worthless as exemptions. Import the `#std/...` alias instead. + * + * 2. RATCHET — a std specifier that is not shimmed on both runtimes is recorded + * in `UNSHIMMED_STD_BASELINE` with the number of cross-runtime files that + * depend on it. Both the set of such specifiers and the per-specifier + * dependent count may only shrink. Gating the dependent count and not just + * the specifier set is the point: adding a new file that imports an + * already-baselined broken specifier adds a new broken Node/Bun test, and a + * set-only ratchet would stay green through it. + * + * Requiring both runtimes is what keeps "shimmed" honest — the two resolvers + * are not equivalent. Node retries the lookup with a `.ts` suffix and falls + * back to a `./src/platform/compat/std/.ts` convention, so dropping a + * shim file in is enough for Node; Bun does neither and needs an exact + * `tsconfig.json` `paths` key. This audit holds to the stricter of the two. + */ + +import { extractImports } from "./check-module-boundaries.ts"; + +/** + * Roots that Node and Bun execute. Mirrors the runner globs: `test:node` runs + * `src/**\/*.test.ts` and `test:bun` runs `src/`. Extensions are Deno-only. + */ +export const CROSS_RUNTIME_ROOTS = ["src"] as const; + +/** + * Std specifiers that still bottom out in JSR, mapped to the number of + * cross-runtime files importing them at runtime. Every entry is a Node/Bun + * breakage waiting to be triggered. + * + * Only ever lower these numbers — by shimming the specifier (a file under + * `src/platform/compat/std/`, a local `deno.json` "imports" entry, AND a + * matching `tsconfig.json` "paths" entry), or by dropping the import. The lint + * prints the exact edit when a number moves. + */ +export const UNSHIMMED_STD_BASELINE: Readonly> = { + "#std/fs/walk": 1, + "#std/testing/mock": 2, + "#std/testing/time": 27, + "@std/fs/walk": 1, + "@std/yaml/parse": 4, +}; + +const NODE_RESOLVER_PATH = "tests/node/resolver-hooks.mjs"; +const BUN_PRELOAD_PATH = "tests/bun/preload.ts"; +const DENO_CONFIG_PATH = "deno.json"; +const TSCONFIG_PATH = "tsconfig.json"; + +/** Node's `findActualFile` candidate list (resolver-hooks.mjs). */ +const NODE_FILE_SUFFIXES = ["", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"]; +const NODE_INDEX_FILES = [ + "index.ts", + "index.tsx", + "index.js", + "index.mjs", + "index.cjs", +]; + +/** Bun resolves `paths` targets to concrete files; no index/extension search. */ +const BUN_FILE_SUFFIXES = ["", ".ts", ".tsx", ".js", ".mjs", ".json"]; +const BUN_INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs"]; + +export interface RuntimeResolutionContext { + /** deno.json `imports`, string values only. Node's alias source. */ + readonly imports: Readonly>; + /** `stdImportMap` literal from `tests/node/resolver-hooks.mjs`. */ + readonly nodeStdShims: Readonly>; + /** tsconfig.json `compilerOptions.paths`, flattened to one target. Bun's source. */ + readonly tsconfigPaths: Readonly>; + /** Does this repo-relative path exist as a file? */ + readonly fileExists: (path: string) => boolean; +} + +export interface CrossRuntimeImport { + readonly file: string; + readonly line: number; + readonly specifier: string; +} + +export interface CrossRuntimeAudit { + /** Direct `jsr:` imports — unresolvable on Bun, no exemption. */ + readonly directJsrImports: readonly CrossRuntimeImport[]; + /** Unshimmed std specifier -> sorted unique files importing it. */ + readonly unshimmedDependents: ReadonlyMap; +} + +export interface GrownEntry { + readonly specifier: string; + readonly baseline: number; + readonly current: number; + readonly files: readonly string[]; +} + +export interface ShrunkEntry { + readonly specifier: string; + readonly baseline: number; + readonly current: number; +} + +export interface BaselineComparison { + /** Unshimmed specifiers with no baseline entry at all. */ + readonly newSpecifiers: readonly GrownEntry[]; + /** Baselined specifiers that gained dependents. */ + readonly grown: readonly GrownEntry[]; + /** Baselined specifiers that lost dependents but still have some. */ + readonly shrunk: readonly ShrunkEntry[]; + /** Baselined specifiers that now resolve on BOTH runtimes — a shim landed. */ + readonly staleShimmed: readonly string[]; + /** Baselined specifiers still unshimmed but no longer imported anywhere. */ + readonly staleUnused: readonly string[]; +} + +/** + * `@std/x` and `std/x` both address the `#std/x` alias namespace. Mirrors + * `normalizeStdSpecifier` in `tests/node/resolver-hooks.mjs`. Bun has no + * equivalent, which is why `resolvesOnBun` does not call this. + */ +export function normalizeStdSpecifier(specifier: string): string { + if (specifier.startsWith("@std/")) { + return `#std/${specifier.slice("@std/".length)}`; + } + if (specifier.startsWith("std/")) { + return `#std/${specifier.slice("std/".length)}`; + } + return specifier; +} + +/** Is this specifier in scope for the audit at all? */ +export function isStdOrJsrSpecifier(specifier: string): boolean { + return specifier.startsWith("jsr:") || + specifier.startsWith("@std/") || + specifier.startsWith("#std/") || + specifier.startsWith("std/"); +} + +function stripLeadingDotSlash(path: string): string { + return path.replace(/^\.\//, ""); +} + +function existsWithCandidates( + target: string, + suffixes: readonly string[], + indexFiles: readonly string[], + fileExists: (path: string) => boolean, +): boolean { + const base = stripLeadingDotSlash(target); + for (const suffix of suffixes) { + if (fileExists(`${base}${suffix}`)) return true; + } + return indexFiles.some((index) => fileExists(`${base}/${index}`)); +} + +/** + * Extract the `stdImportMap = { ... }` object literal from the Node resolver. + * Reading it instead of duplicating it means this audit cannot silently + * disagree with the resolver it is modelling. + */ +export function parseStdShimMap(source: string): Record { + const block = /const stdImportMap[^=]*=\s*\{([\s\S]*?)\n\};/.exec(source); + if (!block) return {}; + const entries: Record = {}; + for (const match of block[1].matchAll(/"([^"]+)"\s*:\s*"([^"]+)"/g)) { + entries[match[1]] = match[2]; + } + return entries; +} + +/** Mirrors `resolveFromImportMap` in `tests/node/resolver-hooks.mjs`. */ +function nodeResolveFromImportMap( + imports: Readonly>, + specifier: string, +): string | null { + const direct = imports[specifier]; + if (direct) return direct; + + for (const [prefix, target] of Object.entries(imports)) { + if (!prefix.endsWith("/*") || !specifier.startsWith(prefix.slice(0, -1))) { + continue; + } + let suffix = specifier.slice(prefix.length - 1); + if (target.endsWith("*.ts") && suffix.endsWith(".ts")) { + suffix = suffix.slice(0, -3); + } + return target.replaceAll("*", suffix); + } + + for (const [prefix, target] of Object.entries(imports)) { + if ( + prefix.endsWith("/") && !prefix.endsWith("/*") && + specifier.startsWith(prefix) + ) { + return target + specifier.slice(prefix.length); + } + } + + return null; +} + +/** + * Mirrors `resolveStdCompatTarget`. Note the two Node-only leniencies: the + * `.ts`-suffixed retry and the `./src/platform/compat/std/.ts` + * convention. Bun has neither. + */ +function nodeStdCompatTarget( + shims: Readonly>, + specifier: string, +): string | null { + const normalized = normalizeStdSpecifier(specifier); + if (shims[normalized]) return shims[normalized]; + if (shims[`${normalized}.ts`]) return shims[`${normalized}.ts`]; + if (normalized.startsWith("#std/")) { + return `./src/platform/compat/std/${normalized.slice("#std/".length)}.ts`; + } + return null; +} + +/** Would `tests/node/resolver-hooks.mjs` resolve this specifier to a file? */ +export function resolvesOnNode( + specifier: string, + context: RuntimeResolutionContext, +): boolean { + const findFile = (target: string) => + existsWithCandidates( + target, + NODE_FILE_SUFFIXES, + NODE_INDEX_FILES, + context.fileExists, + ); + + // `resolveJsrStdSpecifier` runs first and rewrites `jsr:@std/x@1/sub` to the + // compat shim. This is the leniency that made the previous rule unsound. + if (specifier.startsWith("jsr:@std/")) { + const subpath = specifier.slice("jsr:@std/".length).replace(/@[^/]+/, ""); + const target = nodeStdCompatTarget( + context.nodeStdShims, + `#std/${subpath}`, + ); + return target !== null && findFile(target); + } + if (specifier.startsWith("jsr:")) return false; + + const normalized = normalizeStdSpecifier(specifier); + const mapped = nodeResolveFromImportMap(context.imports, specifier) ?? + nodeResolveFromImportMap(context.imports, normalized); + const fallback = context.nodeStdShims[specifier] ?? + context.nodeStdShims[normalized]; + const target = mapped ?? fallback; + if (!target) return false; + + if (target.startsWith("./") || target.startsWith("../")) { + return findFile(target); + } + if (target.startsWith("jsr:@std/")) { + const compat = nodeStdCompatTarget(context.nodeStdShims, specifier); + return compat !== null && findFile(compat); + } + return false; +} + +/** + * Resolve a specifier through tsconfig `paths`: exact key first, then the + * longest matching wildcard key. This is TypeScript's documented algorithm and + * the one Bun implements. + */ +export function resolveTsconfigPath( + paths: Readonly>, + specifier: string, +): string | null { + const exact = paths[specifier]; + if (exact) return exact; + + let bestPrefix: string | null = null; + for (const key of Object.keys(paths)) { + const star = key.indexOf("*"); + if (star === -1) continue; + const prefix = key.slice(0, star); + const suffix = key.slice(star + 1); + if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue; + if (specifier.length < prefix.length + suffix.length) continue; + if (bestPrefix === null || prefix.length > bestPrefix.length) { + bestPrefix = prefix; + } + } + if (bestPrefix === null) return null; + + for (const [key, target] of Object.entries(paths)) { + const star = key.indexOf("*"); + if (star === -1 || key.slice(0, star) !== bestPrefix) continue; + const suffix = key.slice(star + 1); + const matched = specifier.slice( + bestPrefix.length, + specifier.length - suffix.length, + ); + return target.replace("*", matched); + } + return null; +} + +/** Would Bun resolve this specifier to a file? */ +export function resolvesOnBun( + specifier: string, + context: RuntimeResolutionContext, +): boolean { + // Bun has no `jsr:` protocol, tsconfig `paths` cannot hold a `jsr:` key, and + // the alias plugin's `onResolve` filter cannot match a `jsr:` specifier. + // Nothing anywhere in the repo can rescue it. + if (specifier.startsWith("jsr:")) return false; + + // tsconfig `paths` is the only mechanism that reaches Bun for a std + // specifier — the preload plugin is provably never consulted for one, and + // Bun does not read deno.json. No `@std/` -> `#std/` normalisation either: + // tsconfig lists both spellings explicitly, precisely because there is none. + const target = resolveTsconfigPath(context.tsconfigPaths, specifier); + if (!target) return false; + + return existsWithCandidates( + target, + BUN_FILE_SUFFIXES, + BUN_INDEX_FILES, + context.fileExists, + ); +} + +/** Shimmed means BOTH runtimes resolve it — the stricter of the two wins. */ +export function isShimmedEverywhere( + specifier: string, + context: RuntimeResolutionContext, +): boolean { + return resolvesOnNode(specifier, context) && + resolvesOnBun(specifier, context); +} + +/** + * Classify every std/jsr import in `imports` (already collected across the + * cross-runtime roots). + */ +export function auditCrossRuntimeImports( + imports: readonly CrossRuntimeImport[], + context: RuntimeResolutionContext, +): CrossRuntimeAudit { + const directJsrImports: CrossRuntimeImport[] = []; + const dependents = new Map>(); + + for (const entry of imports) { + if (!isStdOrJsrSpecifier(entry.specifier)) continue; + if (entry.specifier.startsWith("jsr:")) { + directJsrImports.push(entry); + continue; + } + if (isShimmedEverywhere(entry.specifier, context)) continue; + const files = dependents.get(entry.specifier) ?? new Set(); + files.add(entry.file); + dependents.set(entry.specifier, files); + } + + const unshimmedDependents = new Map(); + for (const specifier of [...dependents.keys()].sort()) { + unshimmedDependents.set( + specifier, + [...dependents.get(specifier)!].sort(), + ); + } + + return { + directJsrImports: directJsrImports.slice().sort((a, b) => + a.file.localeCompare(b.file) || a.line - b.line + ), + unshimmedDependents, + }; +} + +/** + * Compare the live unshimmed set against the baseline. A baselined specifier + * can leave the set for two very different reasons — a shim landed, or nothing + * imports it any more — and the two need different follow-up edits, so they + * are reported separately rather than collapsed into one "stale" bucket. + */ +export function compareAgainstBaseline( + audit: CrossRuntimeAudit, + baseline: Readonly>, + context: RuntimeResolutionContext, +): BaselineComparison { + const newSpecifiers: GrownEntry[] = []; + const grown: GrownEntry[] = []; + const shrunk: ShrunkEntry[] = []; + const staleShimmed: string[] = []; + const staleUnused: string[] = []; + + for (const [specifier, files] of audit.unshimmedDependents) { + const allowed = baseline[specifier]; + if (allowed === undefined) { + newSpecifiers.push({ + specifier, + baseline: 0, + current: files.length, + files, + }); + continue; + } + if (files.length > allowed) { + grown.push({ + specifier, + baseline: allowed, + current: files.length, + files, + }); + continue; + } + if (files.length < allowed) { + shrunk.push({ specifier, baseline: allowed, current: files.length }); + } + } + + for (const specifier of Object.keys(baseline).sort()) { + if (audit.unshimmedDependents.has(specifier)) continue; + if (isShimmedEverywhere(specifier, context)) { + staleShimmed.push(specifier); + } else { + staleUnused.push(specifier); + } + } + + return { newSpecifiers, grown, shrunk, staleShimmed, staleUnused }; +} + +export function hasFailures(comparison: BaselineComparison): boolean { + return comparison.newSpecifiers.length > 0 || comparison.grown.length > 0; +} + +async function collectCrossRuntimeFiles( + root: string, + out: string[], +): Promise { + let entries: AsyncIterable; + try { + entries = Deno.readDir(root); + } catch (_) { + return; // expected: a scan root may be absent in a partial checkout + } + for await (const entry of entries) { + if (entry.name === "node_modules") continue; + const path = `${root}/${entry.name}`; + if (entry.isDirectory) { + await collectCrossRuntimeFiles(path, out); + } else if ( + entry.isFile && (path.endsWith(".ts") || path.endsWith(".tsx")) && + !path.endsWith(".d.ts") + ) { + out.push(path); + } + } +} + +function fileExistsOnDisk(path: string): boolean { + try { + return Deno.statSync(path).isFile; + } catch (_) { + return false; // a missing candidate is the normal case while probing + } +} + +/** Flatten tsconfig `paths` (arrays of candidates) to the first candidate. */ +export function flattenTsconfigPaths( + raw: Record, +): Record { + const paths: Record = {}; + for (const [key, value] of Object.entries(raw)) { + const first = Array.isArray(value) ? value[0] : value; + if (typeof first === "string") paths[key] = first; + } + return paths; +} + +async function loadContext(): Promise { + const config = JSON.parse(await Deno.readTextFile(DENO_CONFIG_PATH)) as { + imports?: Record; + }; + const imports = Object.fromEntries( + Object.entries(config.imports ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + const tsconfig = JSON.parse(await Deno.readTextFile(TSCONFIG_PATH)) as { + compilerOptions?: { paths?: Record }; + }; + return { + imports, + nodeStdShims: parseStdShimMap( + await Deno.readTextFile(NODE_RESOLVER_PATH), + ), + tsconfigPaths: flattenTsconfigPaths(tsconfig.compilerOptions?.paths ?? {}), + fileExists: fileExistsOnDisk, + }; +} + +async function collectImports(): Promise<{ + imports: CrossRuntimeImport[]; + parseFailures: string[]; +}> { + const files: string[] = []; + for (const root of CROSS_RUNTIME_ROOTS) { + await collectCrossRuntimeFiles(root, files); + } + files.sort(); + + const imports: CrossRuntimeImport[] = []; + const parseFailures: string[] = []; + for (const file of files) { + let references; + try { + references = extractImports(file, await Deno.readTextFile(file)); + } catch (error) { + parseFailures.push( + `${file}: ${error instanceof Error ? error.message : String(error)}`, + ); + continue; + } + for (const reference of references) { + // Type-only imports are erased before Node or Bun ever resolves them, + // so they cannot break either runtime. + if (reference.kind === "type") continue; + if (!isStdOrJsrSpecifier(reference.specifier)) continue; + imports.push({ + file, + line: reference.line, + specifier: reference.specifier, + }); + } + } + return { imports, parseFailures }; +} + +function formatBaselineLiteral( + audit: CrossRuntimeAudit, +): string { + const lines = [...audit.unshimmedDependents].map(([specifier, files]) => + ` "${specifier}": ${files.length},` + ); + return lines.length > 0 ? `{\n${lines.join("\n")}\n}` : "{}"; +} + +function reportDirectJsrImports( + audit: CrossRuntimeAudit, +): void { + if (audit.directJsrImports.length === 0) return; + console.error( + `Direct jsr: imports in cross-runtime code (${audit.directJsrImports.length}):`, + ); + for (const entry of audit.directJsrImports) { + console.error(` ${entry.file}:${entry.line} imports "${entry.specifier}"`); + } + console.error( + ` Bun cannot resolve a jsr: specifier under any circumstances: it has no ` + + `jsr: protocol, ${TSCONFIG_PATH} paths cannot hold a jsr: key, and the ` + + `alias plugin in ${BUN_PRELOAD_PATH} filters on ` + + `#deno-config/@std//#std//std//#veryfront//veryfront//react, which no ` + + `jsr: specifier matches. Node resolving it proves nothing, and neither ` + + `does the equivalent #std/ alias being shimmed. Import that #std/... ` + + `alias instead.`, + ); +} + +/** Name the runtimes that cannot resolve `specifier`, for the failure text. */ +export function failingRuntimes( + specifier: string, + context: RuntimeResolutionContext, +): string[] { + const failing: string[] = []; + if (!resolvesOnNode(specifier, context)) failing.push("Node"); + if (!resolvesOnBun(specifier, context)) failing.push("Bun"); + return failing; +} + +function reportBaselineFailures( + comparison: BaselineComparison, + audit: CrossRuntimeAudit, + context: RuntimeResolutionContext, +): void { + const describe = (specifier: string) => + `fails on ${failingRuntimes(specifier, context).join(" and ")}`; + + for (const entry of comparison.newSpecifiers) { + console.error( + `New unshimmed std specifier "${entry.specifier}" ` + + `(${describe(entry.specifier)}; ${entry.current} dependent file(s)): ` + + `${entry.files.join(", ")}`, + ); + } + for (const entry of comparison.grown) { + const where = describe(entry.specifier); + const files = entry.files.join(", "); + console.error( + `Unshimmed std specifier "${entry.specifier}" (${where}) gained ` + + `dependents: ${entry.current} > baseline ${entry.baseline}. ` + + `Now imported by: ${files}`, + ); + } + if (!hasFailures(comparison)) return; + console.error( + ` These specifiers resolve on Deno but not on every runtime, so each ` + + `dependent file is a broken \`deno task test:node\` / \`test:bun\` run. ` + + `To shim one, all three of these must line up:\n` + + ` 1. a local file under src/platform/compat/std/\n` + + ` 2. a ${DENO_CONFIG_PATH} "imports" entry with a "./" target (Deno + Node)\n` + + ` 3. a ${TSCONFIG_PATH} "paths" entry with the SAME key (Bun — it reads ` + + `neither ${DENO_CONFIG_PATH} nor ${BUN_PRELOAD_PATH} for std specifiers, ` + + `and does no "@std/" -> "#std/" or ".ts" fallback, so map every spelling ` + + `you import)\n` + + ` Otherwise drop the import. Raising UNSHIMMED_STD_BASELINE is not the ` + + `fix.\n Current state:\n${formatBaselineLiteral(audit)}`, + ); +} + +function reportImprovements(comparison: BaselineComparison): void { + for (const specifier of comparison.staleShimmed) { + console.log( + `"${specifier}" now resolves on both Node and Bun — a local shim landed. ` + + `Delete its UNSHIMMED_STD_BASELINE entry in audit-cross-runtime-jsr.ts.`, + ); + } + for (const specifier of comparison.staleUnused) { + console.log( + `"${specifier}" is still unshimmed but no longer imported from ` + + `${CROSS_RUNTIME_ROOTS.join("/")}. Delete its UNSHIMMED_STD_BASELINE ` + + `entry in audit-cross-runtime-jsr.ts so it cannot come back for free.`, + ); + } + for (const entry of comparison.shrunk) { + console.log( + `"${entry.specifier}" dropped to ${entry.current} dependent(s) ` + + `(baseline ${entry.baseline}). Lower its UNSHIMMED_STD_BASELINE entry ` + + `to ${entry.current} to lock it in.`, + ); + } +} + +async function main(): Promise { + const context = await loadContext(); + if ( + Object.keys(context.nodeStdShims).length === 0 || + Object.keys(context.tsconfigPaths).length === 0 + ) { + console.error( + `Could not read the stdImportMap literal from ${NODE_RESOLVER_PATH}, or ` + + `compilerOptions.paths from ${TSCONFIG_PATH}. This audit models those ` + + `two resolvers; refusing to report a pass it cannot justify.`, + ); + Deno.exit(1); + } + + const { imports, parseFailures } = await collectImports(); + if (parseFailures.length > 0) { + console.error(`Failed to parse ${parseFailures.length} file(s):`); + for (const failure of parseFailures) console.error(` ${failure}`); + Deno.exit(1); + } + + const audit = auditCrossRuntimeImports(imports, context); + const comparison = compareAgainstBaseline( + audit, + UNSHIMMED_STD_BASELINE, + context, + ); + + reportDirectJsrImports(audit); + reportBaselineFailures(comparison, audit, context); + if (audit.directJsrImports.length > 0 || hasFailures(comparison)) { + Deno.exit(1); + } + + reportImprovements(comparison); + + const total = [...audit.unshimmedDependents.values()].reduce( + (sum, files) => sum + files.length, + 0, + ); + console.log( + `Cross-runtime jsr audit ok: 0 direct jsr: imports, ` + + `${audit.unshimmedDependents.size} baselined unshimmed specifier(s) ` + + `across ${total} dependent file(s).`, + ); +} + +if (import.meta.main) { + await main(); +} diff --git a/src/build/compiler/mdx-compiler/frontmatter-parser.ts b/src/build/compiler/mdx-compiler/frontmatter-parser.ts index e03dd7901a..020808c64a 100644 --- a/src/build/compiler/mdx-compiler/frontmatter-parser.ts +++ b/src/build/compiler/mdx-compiler/frontmatter-parser.ts @@ -40,7 +40,7 @@ async function parseManually(content: string): Promise { } try { - const { parse } = await import("@std/yaml/parse"); + const { parse } = await import("#std/yaml/parse"); const parsed = parse(frontmatterText); const frontmatter = (parsed && typeof parsed === "object" ? parsed : {}) as MDXFrontmatter; diff --git a/src/build/compiler/mdx-to-js.ts b/src/build/compiler/mdx-to-js.ts index 28e93c2055..ca9b0ebef2 100644 --- a/src/build/compiler/mdx-to-js.ts +++ b/src/build/compiler/mdx-to-js.ts @@ -43,7 +43,7 @@ async function extractFrontmatter( if (!match?.[1]) return { frontmatter: {}, content: mdxContent }; try { - const { parse } = await import("@std/yaml/parse"); + const { parse } = await import("#std/yaml/parse"); const parsed = parse(match[1]); const frontmatter = (parsed && typeof parsed === "object" ? parsed : {}) as MDXFrontmatter; diff --git a/src/config/tsconfig-paths-parity.test.ts b/src/config/tsconfig-paths-parity.test.ts new file mode 100644 index 0000000000..692d7f453f --- /dev/null +++ b/src/config/tsconfig-paths-parity.test.ts @@ -0,0 +1,113 @@ +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +/** + * Bun's runtime resolver never hands a bare package specifier to a `--preload` + * plugin -- `onResolve` is only consulted for relative paths and `#`-prefixed + * subpath imports. So for every `veryfront/...` specifier in the sources, the + * only mapping Bun can see is `tsconfig.json`'s `paths`, and it has to agree + * with the import map Deno reads out of `deno.json`. When the two drift, the + * Bun suite fails at module load with "Cannot find module", far from the edit + * that caused it. + */ + +type PathsMap = Record; + +// Anchored to this module rather than the working directory: the suite runs in +// parallel with tests that move the process out of the repository root. +const repoRoot = new URL("../../", import.meta.url); + +function readRepoJson(name: string): Record { + return JSON.parse(Deno.readTextFileSync(new URL(name, repoRoot))); +} + +const denoImports = readRepoJson("deno.json").imports as + | Record + | undefined; +const tsconfigPaths = + ((readRepoJson("tsconfig.json").compilerOptions as { paths?: PathsMap } | undefined) + ?.paths ?? {}) as PathsMap; + +/** Applies TypeScript's `paths` rules: exact key first, then longest prefix. */ +function resolveThroughPaths(specifier: string, paths: PathsMap): string | null { + const exact = paths[specifier]?.[0]; + if (exact) return exact; + + let best: { prefix: string; suffix: string; target: string } | null = null; + for (const [pattern, targets] of Object.entries(paths)) { + const star = pattern.indexOf("*"); + const target = targets[0]; + if (star === -1 || target === undefined) continue; + const prefix = pattern.slice(0, star); + const suffix = pattern.slice(star + 1); + if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue; + if (specifier.length < prefix.length + suffix.length) continue; + if (best && best.prefix.length >= prefix.length) continue; + best = { prefix, suffix, target }; + } + if (!best) return null; + + const captured = specifier.slice( + best.prefix.length, + specifier.length - best.suffix.length, + ); + return best.target.replace("*", captured); +} + +/** Collapses the extensionless and directory forms a resolver would accept. */ +function toExistingFile(target: string): string | null { + const candidates = [ + target, + `${target}.ts`, + `${target}.tsx`, + `${target}/index.ts`, + `${target}/index.tsx`, + ]; + for (const candidate of candidates) { + try { + if (Deno.statSync(new URL(candidate, repoRoot)).isFile) return candidate; + } catch { + continue; + } + } + return null; +} + +const bareVeryfrontImports = Object.entries(denoImports ?? {}).filter( + ([specifier, target]) => + (specifier === "veryfront" || specifier.startsWith("veryfront/")) && + typeof target === "string" && target.startsWith("./"), +); + +describe("config/tsconfig-paths-parity", () => { + it("has bare veryfront specifiers to check", () => { + assert( + bareVeryfrontImports.length > 50, + `expected deno.json to map many veryfront/* specifiers, found ${bareVeryfrontImports.length}`, + ); + }); + + it("resolves every bare veryfront specifier to the file deno.json names", () => { + const drifted: string[] = []; + for (const [specifier, denoTarget] of bareVeryfrontImports) { + const expected = toExistingFile(denoTarget); + if (expected === null) { + drifted.push(`${specifier}: deno.json points at missing ${denoTarget}`); + continue; + } + const viaPaths = resolveThroughPaths(specifier, tsconfigPaths); + if (viaPaths === null) { + drifted.push(`${specifier}: no tsconfig paths entry`); + continue; + } + const actual = toExistingFile(viaPaths); + if (actual !== expected) { + drifted.push( + `${specifier}: tsconfig resolves to ${actual ?? viaPaths}, deno.json to ${expected}`, + ); + } + } + + assertEquals(drifted, [], drifted.join("\n")); + }); +}); diff --git a/src/extensions/parser/index.ts b/src/extensions/parser/index.ts index 68ed872ec6..499f15f8d1 100644 --- a/src/extensions/parser/index.ts +++ b/src/extensions/parser/index.ts @@ -1,5 +1,7 @@ /** - * Parser category barrel — CodeParser (AST traversal) contract. + * Parser category barrel — CodeParser (AST traversal), SkillDocumentParser + * (Skill frontmatter decoding), and YamlParser (general YAML decoding) + * contracts. * * @module extensions/parser */ @@ -22,3 +24,11 @@ export { SkillDocumentParserProviderName, snapshotSkillDocumentParserProvider, } from "./skill-document-parser.ts"; + +export { + createYamlParserProvider, + snapshotYamlParserProvider, + type YamlParseOptions, + type YamlParserProvider, + YamlParserProviderName, +} from "./yaml-parser.ts"; diff --git a/src/extensions/parser/yaml-defaults.ts b/src/extensions/parser/yaml-defaults.ts new file mode 100644 index 0000000000..2996372d7e --- /dev/null +++ b/src/extensions/parser/yaml-defaults.ts @@ -0,0 +1,84 @@ +/** + * Product-distribution default for the general YAML parser contract. + * + * Mirrors `skill-defaults.ts`: the first-party `ext-yaml` extension owns the + * third-party parser, and core reaches it through a dynamic import so the root + * package never statically depends on it. An installation without the + * extension simply has no default, and the call site raises the registry's + * missing-extension error. + * + * @module extensions/parser/yaml-defaults + */ + +import { + importFirstPartyExtensionModule, + isMissingFirstPartyExtensionModule, +} from "../first-party-import.ts"; +import { snapshotYamlParserProvider, type YamlParserProvider } from "./yaml-parser.ts"; + +const DEFAULT_YAML_SOURCE_DIRECTORY = "ext-yaml"; +const DEFAULT_YAML_EXTENSION_PACKAGE = "@veryfront/ext-yaml"; +const DEFAULT_YAML_FACTORY_EXPORT = "createYamlParser"; + +interface YamlExtensionModule { + readonly createYamlParser?: unknown; +} + +function readProviderFactory(extensionModule: unknown): () => unknown { + if ( + extensionModule === null || + (typeof extensionModule !== "object" && typeof extensionModule !== "function") + ) { + throw new TypeError( + `Invalid ${DEFAULT_YAML_EXTENSION_PACKAGE} module: expected a module namespace`, + ); + } + + let factory: unknown; + try { + factory = (extensionModule as YamlExtensionModule).createYamlParser; + } catch (cause) { + throw new TypeError( + `Invalid ${DEFAULT_YAML_EXTENSION_PACKAGE} module: could not read export "${DEFAULT_YAML_FACTORY_EXPORT}"`, + { cause }, + ); + } + if (typeof factory !== "function") { + throw new TypeError( + `Invalid ${DEFAULT_YAML_EXTENSION_PACKAGE} module: export "${DEFAULT_YAML_FACTORY_EXPORT}" must be callable`, + ); + } + return factory as () => unknown; +} + +/** + * Load the product distribution's extension-owned default YAML parser. + * + * Returns undefined when the extension is not installed. A load failure + * *inside* an installed extension is rethrown: that is a broken installation, + * not an absent one, and hiding it would surface later as a confusing + * "install @veryfront/ext-yaml" message for an extension that is present. + */ +export async function loadDefaultYamlParserProvider(): Promise< + Readonly | undefined +> { + let extensionModule: unknown; + try { + extensionModule = await importFirstPartyExtensionModule( + DEFAULT_YAML_SOURCE_DIRECTORY, + DEFAULT_YAML_EXTENSION_PACKAGE, + ); + } catch (error) { + if ( + !isMissingFirstPartyExtensionModule(error, [ + "extensions/ext-yaml/src/index", + DEFAULT_YAML_EXTENSION_PACKAGE, + ]) + ) { + throw error; + } + return undefined; + } + + return snapshotYamlParserProvider(readProviderFactory(extensionModule)()); +} diff --git a/src/extensions/parser/yaml-parser.test.ts b/src/extensions/parser/yaml-parser.test.ts new file mode 100644 index 0000000000..54f95d49ff --- /dev/null +++ b/src/extensions/parser/yaml-parser.test.ts @@ -0,0 +1,83 @@ +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createYamlParserProvider, snapshotYamlParserProvider } from "./yaml-parser.ts"; + +describe("extensions/parser/yaml-parser", () => { + it("should freeze the captured provider generation", () => { + const provider = createYamlParserProvider(() => ({ ok: true })); + + assertEquals(Object.isFrozen(provider), true); + assertEquals(provider.parseYaml("a: 1"), { ok: true }); + }); + + it("should forward the source and options to the captured parser", () => { + const calls: unknown[][] = []; + const provider = createYamlParserProvider((source, options) => { + calls.push([source, options]); + return null; + }); + + provider.parseYaml("a: 1", { schema: "json" }); + + assertEquals(calls, [["a: 1", { schema: "json" }]]); + }); + + it("should ignore later mutation of the registration object", () => { + const registration = { parseYaml: () => "first" }; + const provider = snapshotYamlParserProvider(registration); + registration.parseYaml = () => "second"; + + assertEquals(provider.parseYaml("a: 1"), "first"); + }); + + it("should reject a registration without a callable parseYaml", () => { + for (const value of [null, "parser", {}, { parseYaml: 42 }]) { + assertThrows(() => snapshotYamlParserProvider(value), TypeError, "parseYaml"); + } + }); + + it("should reject an accessor-backed parseYaml under a poisoned prototype", () => { + const registration = {}; + Object.defineProperty(registration, "parseYaml", { + configurable: true, + enumerable: true, + get: () => () => "from-accessor", + }); + // A poisoned Object.prototype.value makes a naive `descriptor.value` read + // look like a data property holding a function. + Object.defineProperty(Object.prototype, "value", { + configurable: true, + value: () => "poisoned", + }); + try { + assertThrows(() => snapshotYamlParserProvider(registration), TypeError, "parseYaml"); + } finally { + delete (Object.prototype as Record).value; + } + }); + + it("should reject a non-string source before calling the parser", () => { + let called = false; + const provider = createYamlParserProvider(() => { + called = true; + return null; + }); + + assertThrows( + () => provider.parseYaml(42 as unknown as string), + TypeError, + "must be a string", + ); + assertEquals(called, false); + }); + + it("should reject an asynchronous parser rather than leak a pending value", () => { + const provider = createYamlParserProvider(() => Promise.resolve({ a: 1 })); + + assertThrows( + () => provider.parseYaml("a: 1"), + TypeError, + "must be synchronous", + ); + }); +}); diff --git a/src/extensions/parser/yaml-parser.ts b/src/extensions/parser/yaml-parser.ts new file mode 100644 index 0000000000..df7e4d924e --- /dev/null +++ b/src/extensions/parser/yaml-parser.ts @@ -0,0 +1,105 @@ +/** + * Extension boundary for general-purpose YAML decoding. + * + * Core owns front matter framing, mapping policy, and every call site's + * downstream validation. Implementations own only YAML decoding, which is the + * one part that needs a third-party parser and therefore may not live in core. + * + * This is a sibling of `SkillDocumentParserProvider`, not an extension of it, + * for two reasons. First, `snapshotSkillDocumentParserProvider` enforces that + * a provider has exactly one own key named `parseFrontmatter`; adding a second + * method would mean weakening an invariant that guards an untrusted Skill + * document trust boundary. Second, general YAML decoding needs the decoding + * options (`schema`, `allowDuplicateKeys`) that the Skill contract + * deliberately withholds so that core, not the extension, fixes Skill policy. + * + * @module extensions/parser/yaml-parser + */ + +import { isNativePromiseWithoutHooks } from "#veryfront/platform/compat/error-introspection.ts"; + +/** Stable runtime identifier for the general YAML parser contract. */ +export const YamlParserProviderName = "YamlParserProvider" as const; + +/** + * Decoding options, named after the `@std/yaml` options the framework's call + * sites already pass so that repointing a call site is a specifier change. + */ +export interface YamlParseOptions { + /** + * Accept a mapping that repeats a key. Defaults to false: a repeated key is + * a source defect, and picking a winner silently hides it. + */ + readonly allowDuplicateKeys?: boolean; + /** + * `"json"` restricts resolution to JSON-representable types. Use it at + * trust boundaries where a timestamp, a binary blob, or any other + * implementation-specific tag must not appear in the decoded value. + */ + readonly schema?: "core" | "json"; +} + +/** Dependency-free contract implemented by YAML parser extensions. */ +export interface YamlParserProvider { + /** + * Decode one YAML document. + * + * Implementations must be synchronous, must reject a source holding more + * than one document, and must raise `SyntaxError` for malformed input. The + * returned value is untrusted; core validates it at each call site. + */ + readonly parseYaml: (source: string, options?: YamlParseOptions) => unknown; +} + +function providerInspectionError(): TypeError { + return new TypeError( + "YAML parser provider must be an object with a callable parseYaml property", + ); +} + +/** + * Capture one immutable provider generation. + * + * The captured facade re-checks the synchronous contract on every call: an + * implementation that returns a Promise would otherwise hand a pending value + * to synchronous call sites that cannot await it, and the resulting `[object + * Promise]` front matter is far harder to diagnose than a thrown TypeError. + */ +export function snapshotYamlParserProvider( + value: unknown, +): Readonly { + if (typeof value !== "object" || value === null) throw providerInspectionError(); + const descriptor = Object.getOwnPropertyDescriptor(value, "parseYaml"); + // An accessor descriptor has no own `value`, so a plain `descriptor.value` + // read would return whatever a poisoned `Object.prototype.value` supplies. + // Requiring the own property keeps an accessor-backed registration out. + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, "value") || + typeof descriptor.value !== "function" + ) { + throw providerInspectionError(); + } + + const parseYaml = descriptor.value as YamlParserProvider["parseYaml"]; + const facade: YamlParserProvider = { + parseYaml(source: string, options?: YamlParseOptions): unknown { + if (typeof source !== "string") { + throw new TypeError("YAML source must be a string"); + } + const parsed = Reflect.apply(parseYaml, undefined, [source, options]); + if (isNativePromiseWithoutHooks(parsed)) { + throw new TypeError("YAML parser provider must be synchronous"); + } + return parsed; + }, + }; + return Object.freeze(facade); +} + +/** Create immutable provider registration metadata from a standalone parser. */ +export function createYamlParserProvider( + parseYaml: YamlParserProvider["parseYaml"], +): Readonly { + return snapshotYamlParserProvider({ parseYaml }); +} diff --git a/src/extensions/recommendations.ts b/src/extensions/recommendations.ts index 2ecf9f036f..e3764fc3e9 100644 --- a/src/extensions/recommendations.ts +++ b/src/extensions/recommendations.ts @@ -28,6 +28,10 @@ const recommendations = new Map([ ["SqliteStore", "@veryfront/ext-db-sqlite"], ["SandboxShellToolsProvider", "@veryfront/ext-sandbox-shell-tools"], ["NodeWebSocketServerProvider", "@veryfront/ext-node-websocket-ws"], + // Skill frontmatter decoding and general YAML decoding are both satisfied by + // the single parser bundled in ext-yaml. + ["SkillDocumentParserProvider", "@veryfront/ext-yaml"], + ["YamlParserProvider", "@veryfront/ext-yaml"], ]); /** Return recommendation. */ diff --git a/src/platform/compat/shims/std-front-matter.ts b/src/platform/compat/shims/std-front-matter.ts index 7b66614103..78dde0e55b 100644 --- a/src/platform/compat/shims/std-front-matter.ts +++ b/src/platform/compat/shims/std-front-matter.ts @@ -1,4 +1,4 @@ -import { parse } from "@std/yaml/parse"; +import { parse } from "#std/yaml/parse"; interface FrontMatterResult> { attrs: T; diff --git a/src/platform/compat/std/front-matter-yaml.ts b/src/platform/compat/std/front-matter-yaml.ts index cc135fc385..460f46dfc0 100644 --- a/src/platform/compat/std/front-matter-yaml.ts +++ b/src/platform/compat/std/front-matter-yaml.ts @@ -4,7 +4,7 @@ * @module */ -import { parse } from "@std/yaml/parse"; +import { parse } from "#std/yaml/parse"; export interface Extract { attrs: T; diff --git a/src/platform/compat/std/testing/time.test.ts b/src/platform/compat/std/testing/time.test.ts new file mode 100644 index 0000000000..1dabaf8de0 --- /dev/null +++ b/src/platform/compat/std/testing/time.test.ts @@ -0,0 +1,157 @@ +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { FakeTime } from "./time.ts"; + +describe("platform/compat/std/testing/time", () => { + it("runs a timeout once the clock reaches its due time", () => { + using time = new FakeTime(); + const fired: string[] = []; + + setTimeout(() => fired.push("late"), 100); + setTimeout(() => fired.push("early"), 10); + + time.tick(9); + assertEquals(fired, []); + + time.tick(1); + assertEquals(fired, ["early"]); + + time.tick(90); + assertEquals(fired, ["early", "late"]); + }); + + it("fires same-due timers in the order they were scheduled", () => { + using time = new FakeTime(); + const fired: number[] = []; + + setTimeout(() => fired.push(1), 5); + setTimeout(() => fired.push(2), 5); + setTimeout(() => fired.push(3), 5); + + time.tick(5); + + assertEquals(fired, [1, 2, 3]); + }); + + it("runs timers a callback schedules inside the same tick", () => { + using time = new FakeTime(); + const fired: string[] = []; + + setTimeout(() => { + fired.push("outer"); + setTimeout(() => fired.push("inner"), 10); + }, 10); + + time.tick(25); + + assertEquals(fired, ["outer", "inner"]); + }); + + it("repeats an interval for every period the tick spans", () => { + using time = new FakeTime(); + let ticks = 0; + + const id = setInterval(() => { + ticks += 1; + }, 100); + + time.tick(350); + assertEquals(ticks, 3); + + clearInterval(id); + time.tick(1000); + assertEquals(ticks, 3); + }); + + it("does not run a timeout that was cleared before it came due", () => { + using time = new FakeTime(); + let fired = false; + + const id = setTimeout(() => { + fired = true; + }, 10); + clearTimeout(id); + + time.tick(1000); + + assertEquals(fired, false); + }); + + it("reports the faked clock through Date while keeping the real statics", () => { + using time = new FakeTime(new Date("2026-01-01T00:00:00.000Z")); + + assertEquals(Date.now(), 1767225600000); + assertEquals(new Date().toISOString(), "2026-01-01T00:00:00.000Z"); + assertEquals(new Date() instanceof Date, true); + assertEquals(Date.UTC(2026, 0, 1), 1767225600000); + + time.tick(1500); + + assertEquals(Date.now(), 1767225601500); + assertEquals(new Date("2026-06-01T00:00:00.000Z").getUTCMonth(), 5); + }); + + it("exposes the fake clock to a timer callback while it runs", () => { + using time = new FakeTime(0); + const observed: number[] = []; + + setTimeout(() => observed.push(Date.now()), 30); + setTimeout(() => observed.push(Date.now()), 70); + + time.tick(100); + + assertEquals(observed, [30, 70]); + }); + + it("lets already-pending jobs settle before advancing the clock", async () => { + using time = new FakeTime(); + let settled = false; + + Promise.resolve().then(() => Promise.resolve()).then(() => { + settled = true; + }); + + assertEquals(settled, false); + await time.tickAsync(0); + assertEquals(settled, true); + }); + + it("settles pending jobs against the clock as it stood before the tick", async () => { + using time = new FakeTime(0); + let observed: number | undefined; + + Promise.resolve().then(() => { + observed = Date.now(); + }); + + await time.tickAsync(100); + + assertEquals(observed, 0); + assertEquals(Date.now(), 100); + }); + + it("restores the real clock and timers on dispose", () => { + const realNow = Date.now; + const realSetTimeout = globalThis.setTimeout; + + { + using _time = new FakeTime(0); + assertEquals(Date.now(), 0); + } + + assertEquals(Date.now === realNow, true); + assertEquals(globalThis.setTimeout === realSetTimeout, true); + }); + + it("refuses a second installation while one is active", () => { + using _time = new FakeTime(); + + assertThrows(() => new FakeTime(), Error); + }); + + it("refuses to move the clock backwards", () => { + using time = new FakeTime(1000); + + assertThrows(() => time.tick(-1), RangeError); + }); +}); diff --git a/src/platform/compat/std/testing/time.ts b/src/platform/compat/std/testing/time.ts new file mode 100644 index 0000000000..ec2941eb6d --- /dev/null +++ b/src/platform/compat/std/testing/time.ts @@ -0,0 +1,229 @@ +/** + * Cross-runtime `FakeTime`, the shape `@std/testing/time` exposes. + * + * Deno resolves `#std/testing/time` to the real jsr module; Node and Bun have + * no such package to reach for, so the tests that drive a clock forward need a + * native implementation with the same surface. + * + * @module platform/compat/std/testing/time + */ + +type TimerCallback = (...args: unknown[]) => void; + +type FakeTimer = { + id: number; + due: number; + sequence: number; + /** Repeat delay for intervals; `null` marks a one-shot timeout. */ + period: number | null; + callback: TimerCallback; + args: unknown[]; +}; + +type TimerGlobals = { + setTimeout: typeof globalThis.setTimeout; + clearTimeout: typeof globalThis.clearTimeout; + setInterval: typeof globalThis.setInterval; + clearInterval: typeof globalThis.clearInterval; + Date: DateConstructor; +}; + +type MutableGlobals = Record; + +// A callback that reschedules itself with no delay would otherwise spin until +// the process is killed, which reads as a hung suite rather than a bad test. +const MAX_TIMERS_PER_TICK = 100_000; + +function toDelay(value: unknown): number { + const delay = Number(value); + return Number.isFinite(delay) && delay > 0 ? delay : 0; +} + +function toStartTime(start: number | string | Date | undefined, fallback: number): number { + if (start === undefined) return fallback; + const time = start instanceof Date ? start.getTime() : new Date(start).getTime(); + if (!Number.isFinite(time)) { + throw new TypeError(`FakeTime start must be a valid date; received ${String(start)}`); + } + return time; +} + +/** + * Replaces the global clock and timer functions so tests advance time by hand. + * + * Only one instance may be installed at a time. Dispose it — `using time = new + * FakeTime()` — or call {@linkcode FakeTime.restore} to put the real globals back. + */ +export class FakeTime { + static #installed: FakeTime | undefined; + + readonly #originals: TimerGlobals; + readonly #timers = new Map(); + #now: number; + #nextId = 1; + #sequence = 0; + #restored = false; + + constructor(start?: number | string | Date) { + if (FakeTime.#installed) { + throw new Error("FakeTime is already installed; restore the previous instance first"); + } + + const globals = globalThis as unknown as MutableGlobals; + this.#originals = { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + Date: globalThis.Date, + }; + this.#now = toStartTime(start, this.#originals.Date.now()); + + globals.setTimeout = (callback: TimerCallback, delay?: unknown, ...args: unknown[]) => + this.#schedule(callback, toDelay(delay), null, args); + globals.setInterval = (callback: TimerCallback, delay?: unknown, ...args: unknown[]) => { + const period = Math.max(1, toDelay(delay)); + return this.#schedule(callback, period, period, args); + }; + globals.clearTimeout = (id?: unknown) => this.#clear(id); + globals.clearInterval = (id?: unknown) => this.#clear(id); + globals.Date = this.#createDate(); + + FakeTime.#installed = this; + } + + /** The faked wall clock, in milliseconds since the epoch. */ + get now(): number { + return this.#now; + } + + /** Advance the clock, running every timer that comes due on the way. */ + tick(ms = 0): void { + const target = this.#now + ms; + if (target < this.#now) { + throw new RangeError(`Cannot move the fake clock backwards; received ${ms}ms`); + } + let fired = 0; + for (const timer of this.#due(target)) { + this.#invoke(timer); + if (++fired > MAX_TIMERS_PER_TICK) { + throw new Error(`FakeTime fired more than ${MAX_TIMERS_PER_TICK} timers in one tick`); + } + } + this.#now = target; + } + + /** + * Let already-pending jobs settle on the real event loop, then advance the + * clock exactly as {@linkcode FakeTime.tick} does. Work the newly fired timers + * start is deliberately left in flight. + */ + async tickAsync(ms = 0): Promise { + await this.runMicrotasks(); + this.tick(ms); + } + + /** Hand control back to the real event loop so pending jobs can run. */ + runMicrotasks(): Promise { + // Deno's setTimeout rejects a receiver other than the global object, so the + // saved reference has to be called as a plain function. + const realSetTimeout = this.#originals.setTimeout; + return new Promise((resolve) => { + realSetTimeout(resolve, 0); + }); + } + + /** Put the real clock and timer functions back. Safe to call twice. */ + restore(): void { + if (this.#restored) return; + this.#restored = true; + + const globals = globalThis as unknown as MutableGlobals; + globals.setTimeout = this.#originals.setTimeout; + globals.clearTimeout = this.#originals.clearTimeout; + globals.setInterval = this.#originals.setInterval; + globals.clearInterval = this.#originals.clearInterval; + globals.Date = this.#originals.Date; + + this.#timers.clear(); + if (FakeTime.#installed === this) FakeTime.#installed = undefined; + } + + [Symbol.dispose](): void { + this.restore(); + } + + #schedule( + callback: TimerCallback, + delay: number, + period: number | null, + args: unknown[], + ): number { + const id = this.#nextId++; + this.#timers.set(id, { + id, + due: this.#now + delay, + sequence: this.#sequence++, + period, + callback, + args, + }); + return id; + } + + #clear(id: unknown): void { + const key = Number(id); + if (Number.isFinite(key)) this.#timers.delete(key); + } + + /** + * Yields each timer due at or before `target` in firing order, taking timers + * scheduled by earlier callbacks into account as it goes. + */ + *#due(target: number): Generator { + while (true) { + let next: FakeTimer | undefined; + for (const timer of this.#timers.values()) { + if (timer.due > target) continue; + if ( + !next || timer.due < next.due || + (timer.due === next.due && timer.sequence < next.sequence) + ) { + next = timer; + } + } + if (!next) return; + + this.#now = Math.max(this.#now, next.due); + if (next.period === null) { + this.#timers.delete(next.id); + } else { + next.due = this.#now + next.period; + next.sequence = this.#sequence++; + } + yield next; + } + } + + #invoke(timer: FakeTimer): void { + timer.callback(...timer.args); + } + + /** + * A `Date` that reads the faked clock. Proxying the real constructor keeps + * `instanceof`, `Date.parse`, `Date.UTC` and every argument overload intact. + */ + #createDate(): DateConstructor { + const readNow = () => this.#now; + return new Proxy(this.#originals.Date, { + construct(target, args, newTarget) { + const effective = args.length === 0 ? [readNow()] : args; + return Reflect.construct(target, effective, newTarget); + }, + get(target, property, receiver) { + if (property === "now") return readNow; + return Reflect.get(target, property, receiver); + }, + }); + } +} diff --git a/src/platform/compat/std/yaml.test.ts b/src/platform/compat/std/yaml.test.ts new file mode 100644 index 0000000000..626ffe6801 --- /dev/null +++ b/src/platform/compat/std/yaml.test.ts @@ -0,0 +1,62 @@ +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { register, unregister } from "#veryfront/extensions/contracts.ts"; +import { getRecommendation } from "#veryfront/extensions/recommendations.ts"; +import { + createYamlParserProvider, + type YamlParseOptions, + YamlParserProviderName, +} from "#veryfront/extensions/parser/yaml-parser.ts"; +import { parse } from "./yaml.ts"; + +function withRegisteredProvider( + parseYaml: (source: string, options?: YamlParseOptions) => unknown, + body: () => void, +): void { + register(YamlParserProviderName, createYamlParserProvider(parseYaml)); + try { + body(); + } finally { + unregister(YamlParserProviderName); + } +} + +describe("platform/compat/std/yaml", () => { + it("should decode YAML through the extension-owned default parser", () => { + assertEquals(parse("title: Typed\ncount: 42"), { + title: "Typed", + count: 42, + }); + }); + + it("should surface a malformed document as SyntaxError", () => { + assertThrows(() => parse("name: first\nname: second"), SyntaxError); + }); + + it("should forward decoding options to the provider unchanged", () => { + const seen: Array<[string, YamlParseOptions | undefined]> = []; + withRegisteredProvider((source, options) => { + seen.push([source, options]); + return { decoded: true }; + }, () => { + assertEquals(parse("a: 1", { allowDuplicateKeys: false, schema: "json" }), { + decoded: true, + }); + }); + + assertEquals(seen, [["a: 1", { allowDuplicateKeys: false, schema: "json" }]]); + }); + + it("should prefer an app-registered provider over the built-in default", () => { + withRegisteredProvider(() => "from-registered-provider", () => { + assertEquals(parse("count: 42"), "from-registered-provider"); + }); + + // The registration is scoped: the default is back once it is withdrawn. + assertEquals(parse("count: 42"), { count: 42 }); + }); + + it("should name the installable extension when the contract is unbound", () => { + assertEquals(getRecommendation(YamlParserProviderName), "@veryfront/ext-yaml"); + }); +}); diff --git a/src/platform/compat/std/yaml.ts b/src/platform/compat/std/yaml.ts new file mode 100644 index 0000000000..a684783870 --- /dev/null +++ b/src/platform/compat/std/yaml.ts @@ -0,0 +1,47 @@ +/** + * Portable `@std/yaml/parse` shim. + * + * `jsr:@std/yaml` only resolves under Deno, so importing it from core made + * every Node and Bun test that transitively touched YAML unresolvable. A + * third-party parser cannot move into core either — core may depend on the + * Deno standard library and nothing else. So core keeps the call sites and the + * `YamlParserProvider` contract, and the first-party `ext-yaml` extension + * keeps the parser. + * + * @module platform/compat/std/yaml + */ + +import { resolve, tryResolve } from "#veryfront/extensions/contracts.ts"; +import { loadDefaultYamlParserProvider } from "#veryfront/extensions/parser/yaml-defaults.ts"; +import { + type YamlParseOptions, + type YamlParserProvider, + YamlParserProviderName, +} from "#veryfront/extensions/parser/yaml-parser.ts"; + +/** + * Every framework call site for this module is synchronous — front matter + * extraction runs inside synchronous render and build paths — so the + * extension-owned parser has to be in hand before `parse` is first callable. + * Top-level await is the same mechanism `src/agent/runtime/skill-metadata.ts` + * uses to load this extension's Skill parser for its own synchronous callers. + */ +const defaultProvider = await loadDefaultYamlParserProvider(); + +function requireProvider(): YamlParserProvider { + // Extension orchestration owns the binding when an app configured one; the + // product distribution's default only fills the gap. + const registered = tryResolve(YamlParserProviderName); + if (registered !== undefined) return registered; + if (defaultProvider !== undefined) return defaultProvider; + // Raises the registry's missing-extension error, whose detail names the + // package to install. Never a bare throw. + return resolve(YamlParserProviderName); +} + +/** Decode one YAML document. */ +export function parse(source: string, options?: YamlParseOptions): unknown { + return requireProvider().parseYaml(source, options); +} + +export type { YamlParseOptions }; diff --git a/src/react/compat/ssr-adapter/_test-setup.ts b/src/react/compat/ssr-adapter/_test-setup.ts new file mode 100644 index 0000000000..426114af8c --- /dev/null +++ b/src/react/compat/ssr-adapter/_test-setup.ts @@ -0,0 +1,31 @@ +/** + * Test-only helper: serve React and react-dom/server to the SSR adapter from + * the modules the runtime already has, instead of downloading them. + * + * `getReactDOMServer` normally caches an esm.sh bundle to disk and imports it, + * so that project components and the server renderer share one React instance. + * A unit test that only wants to render a `
` pays for that with real + * network egress, and on Node it pays twice: the bundle esm.sh serves for + * `react-dom/server` is the browser build, whose module-scope `MessageChannel` + * is a ref'd libuv handle. The tests then pass and the process still never + * exits. + * + * Import this file as a side effect at the top of any `*.test.ts` that renders + * through the SSR adapter without asserting on the download path itself. + * + * @module react/compat/ssr-adapter/_test-setup + */ + +import * as React from "react"; +import * as ReactDOMServer from "react-dom/server"; +import { __setServerModuleLoaderForTests, resetReactCache } from "./server-loader.ts"; + +/** Points the SSR adapter's module loader at the statically imported modules. */ +export function useLocalReactForSSRTests(): void { + resetReactCache(); + __setServerModuleLoaderForTests((_url, label) => + Promise.resolve(label === "React" ? { default: React } : ReactDOMServer) + ); +} + +useLocalReactForSSRTests(); diff --git a/src/rendering/rsc/server-renderer/rsc-renderer.test.ts b/src/rendering/rsc/server-renderer/rsc-renderer.test.ts index deb7d77b68..131c2eda9a 100644 --- a/src/rendering/rsc/server-renderer/rsc-renderer.test.ts +++ b/src/rendering/rsc/server-renderer/rsc-renderer.test.ts @@ -1,4 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; +import "#veryfront/react/compat/ssr-adapter/_test-setup.ts"; import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { RSCRenderer } from "./rsc-renderer.ts"; diff --git a/src/rendering/rsc/server-renderer/tree-processor.test.ts b/src/rendering/rsc/server-renderer/tree-processor.test.ts index 6dbb05a765..4b3c10d612 100644 --- a/src/rendering/rsc/server-renderer/tree-processor.test.ts +++ b/src/rendering/rsc/server-renderer/tree-processor.test.ts @@ -1,4 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; +import "#veryfront/react/compat/ssr-adapter/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { renderChildren, renderTree } from "./tree-processor.ts"; diff --git a/tests/bun/preload.ts b/tests/bun/preload.ts index 5bc43bb846..46ea684ebd 100644 --- a/tests/bun/preload.ts +++ b/tests/bun/preload.ts @@ -51,6 +51,10 @@ const stdImportMap: Record = { "#std/path.ts": "./src/platform/compat/std/path.ts", "#std/path/posix": "./src/platform/compat/std/path.ts", "#std/path/posix.ts": "./src/platform/compat/std/path.ts", + "#std/yaml": "./src/platform/compat/std/yaml.ts", + "#std/yaml.ts": "./src/platform/compat/std/yaml.ts", + "#std/yaml/parse": "./src/platform/compat/std/yaml.ts", + "#std/yaml/parse.ts": "./src/platform/compat/std/yaml.ts", }; const reactImportMap: Record = { diff --git a/tests/node-resolver-workspace-imports.test.ts b/tests/node-resolver-workspace-imports.test.ts new file mode 100644 index 0000000000..d747ba2cc8 --- /dev/null +++ b/tests/node-resolver-workspace-imports.test.ts @@ -0,0 +1,126 @@ +/** + * The Node resolver has to honour a workspace member's own import map. + * + * Six `@veryfront/react-*-upstream` aliases exist only in `react/deno.json`. + * When the Node loader read the root import map alone they escaped to a real + * npm lookup, and every test whose graph touches React died at the import. The + * entries must therefore come from the member configs the loader already + * parses, not from a copy kept in the loader — a copy silently rots the next + * time `react/deno.json` moves. + * + * @module tests/node-resolver-workspace-imports + */ + +import { readFileSync } from "node:fs"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { + bareSpecifierFromRemoteTarget, + findWorkspaceImportScope, + workspaceImportScopes, +} from "./node/resolver-hooks.mjs"; + +const projectRoot = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); +const reactMemberDir = `${projectRoot}/react`; + +interface WorkspaceImportScope { + readonly dir: string; + readonly imports: Record; +} + +const scopes = workspaceImportScopes as WorkspaceImportScope[]; + +function reactMemberImports(): Record { + const config = JSON.parse(readFileSync(`${reactMemberDir}/deno.json`, "utf-8")) as { + imports?: Record; + }; + return config.imports ?? {}; +} + +describe("tests/node-resolver-workspace-imports", () => { + describe("workspace member import maps", () => { + it("registers a scope for the react workspace member", () => { + const reactScope = scopes.find((scope) => scope.dir === reactMemberDir); + assert(reactScope !== undefined, "no import scope registered for ./react"); + }); + + it("carries every entry react/deno.json declares, without transcribing them", () => { + const reactScope = scopes.find((scope) => scope.dir === reactMemberDir); + assert(reactScope !== undefined); + assertEquals(reactScope.imports, reactMemberImports()); + }); + + it("covers the upstream aliases that exist nowhere else", () => { + const reactScope = scopes.find((scope) => scope.dir === reactMemberDir); + assert(reactScope !== undefined); + for ( + const alias of [ + "@veryfront/react-upstream", + "@veryfront/react-dom-upstream", + "@veryfront/react-dom-client-upstream", + "@veryfront/react-dom-server-upstream", + "@veryfront/react-jsx-runtime-upstream", + "@veryfront/react-jsx-dev-runtime-upstream", + ] + ) { + assert(alias in reactScope.imports, `${alias} missing from the ./react scope`); + } + }); + }); + + describe("findWorkspaceImportScope", () => { + it("applies a member's map only inside that member", () => { + const scope = findWorkspaceImportScope(`${reactMemberDir}/react.ts`); + assertEquals(scope?.dir, reactMemberDir); + }); + + it("leaves modules outside every member on the root map", () => { + assertEquals(findWorkspaceImportScope(`${projectRoot}/src/react/index.ts`), null); + }); + + it("does not treat a sibling directory as inside the member", () => { + assertEquals(findWorkspaceImportScope(`${reactMemberDir}-extra/thing.ts`), null); + }); + + it("has no scope for a module Node cannot place on disk", () => { + assertEquals(findWorkspaceImportScope(null), null); + }); + }); + + describe("bareSpecifierFromRemoteTarget", () => { + it("keeps the subpath an esm.sh target points at", () => { + assertEquals( + bareSpecifierFromRemoteTarget( + "https://esm.sh/react-dom@19.2.4/server?external=react&target=es2022", + ), + "react-dom/server", + ); + }); + + it("reduces a bare versioned target to the package name", () => { + assertEquals( + bareSpecifierFromRemoteTarget("https://esm.sh/react@19.2.4?target=es2022"), + "react", + ); + }); + + it("keeps the subpath an npm: target points at", () => { + assertEquals( + bareSpecifierFromRemoteTarget("npm:ajv@8.18.0/dist/2019.js"), + "ajv/dist/2019.js", + ); + }); + + it("keeps a scoped package's scope", () => { + assertEquals( + bareSpecifierFromRemoteTarget("https://esm.sh/@types/react@19.2.14?deps=csstype@3.2.3"), + "@types/react", + ); + }); + + it("ignores targets that are not remote packages", () => { + assertEquals(bareSpecifierFromRemoteTarget("./src/react/index.ts"), null); + assertEquals(bareSpecifierFromRemoteTarget("jsr:@std/path@1.1.2"), null); + }); + }); +}); diff --git a/tests/node/resolver-hooks.mjs b/tests/node/resolver-hooks.mjs index 17a32e8b12..84448af0c4 100644 --- a/tests/node/resolver-hooks.mjs +++ b/tests/node/resolver-hooks.mjs @@ -18,6 +18,13 @@ const projectRoot = pathResolve(__dirname, "../.."); const importMap = {}; const workspacePackageMap = {}; const workspacePackagePatterns = []; +// Deno applies a workspace member's own `imports` to the modules inside that +// member's directory; the root import map does not contain them. `react/` is +// the case that matters here — its wrappers import `@veryfront/react-*-upstream`, +// which exists only in `react/deno.json`. These scopes are derived from the +// member configs this loader already reads, so a new member entry needs no +// edit here. +export const workspaceImportScopes = []; const stdImportMap = { "#std/assert": "./src/testing/assert.ts", @@ -87,11 +94,25 @@ function registerWorkspaceExport(packageName, exportName, target, workspaceDir) if (!specifier.includes("*")) workspacePackageMap[specifier] = absoluteTarget; } +function registerWorkspaceImports(config, workspaceDir) { + const imports = config.imports; + if (!imports || typeof imports !== "object" || Array.isArray(imports)) return; + const scoped = {}; + for (const [key, value] of Object.entries(imports)) { + if (typeof value === "string") scoped[key] = value; + } + if (Object.keys(scoped).length === 0) return; + workspaceImportScopes.push({ dir: workspaceDir, imports: scoped }); +} + function registerWorkspacePackage(workspaceEntry) { if (typeof workspaceEntry !== "string") return; const workspaceDir = pathResolve(projectRoot, workspaceEntry); try { const config = JSON.parse(readFileSync(pathResolve(workspaceDir, "deno.json"), "utf-8")); + // Registered before the `name`/`exports` guards below: a member can carry + // imports without publishing exports. + registerWorkspaceImports(config, workspaceDir); if (typeof config.name !== "string" || !config.name) return; if (typeof config.exports === "string") { registerWorkspaceExport(config.name, ".", config.exports, workspaceDir); @@ -139,14 +160,14 @@ function resolveStdCompatTarget(specifier) { return null; } -function resolveFromImportMap(specifier) { +function resolveFromMap(map, specifier) { // 1. Direct match (highest priority) - if (importMap[specifier]) { - return importMap[specifier]; + if (map[specifier]) { + return map[specifier]; } // 2. Prefix match with wildcard (e.g., #veryfront/testing/* -> ./src/testing/*.ts) - for (const [prefix, target] of Object.entries(importMap)) { + for (const [prefix, target] of Object.entries(map)) { if (prefix.endsWith("/*") && specifier.startsWith(prefix.slice(0, -1))) { let suffix = specifier.slice(prefix.length - 1); // If target ends with *.ts and suffix also ends with .ts, strip .ts from suffix @@ -158,7 +179,7 @@ function resolveFromImportMap(specifier) { } // 3. Prefix match without wildcard (e.g., #veryfront/ -> ./src/) - for (const [prefix, target] of Object.entries(importMap)) { + for (const [prefix, target] of Object.entries(map)) { if (prefix.endsWith("/") && !prefix.endsWith("/*") && specifier.startsWith(prefix)) { const suffix = specifier.slice(prefix.length); return target + suffix; @@ -168,8 +189,45 @@ function resolveFromImportMap(specifier) { return null; } -function findActualFile(relativePath) { - const fullPath = pathResolve(projectRoot, relativePath); +function resolveFromImportMap(specifier) { + return resolveFromMap(importMap, specifier); +} + +/** + * The workspace member whose directory contains the importing module, so its + * import map applies only where Deno would apply it. The deepest match wins + * when members nest. + */ +export function findWorkspaceImportScope(parentPath) { + if (!parentPath) return null; + let best = null; + for (const scope of workspaceImportScopes) { + if (parentPath !== scope.dir && !parentPath.startsWith(`${scope.dir}/`)) continue; + if (!best || scope.dir.length > best.dir.length) best = scope; + } + return best; +} + +/** + * The bare npm specifier behind a remote target, e.g. + * `https://esm.sh/react-dom@19.2.4/server?external=react` -> `react-dom/server`. + * Lets one lookup table cover both the esm.sh URLs Deno uses and `npm:` targets. + */ +export function bareSpecifierFromRemoteTarget(target) { + let rest = null; + if (target.startsWith("https://esm.sh/")) rest = target.slice("https://esm.sh/".length); + else if (target.startsWith("npm:")) rest = target.slice("npm:".length); + else return null; + + const queryIndex = rest.indexOf("?"); + if (queryIndex >= 0) rest = rest.slice(0, queryIndex); + const match = /^((?:@[^/]+\/)?[^@/]+)(?:@[^/]+)?(\/.*)?$/.exec(rest); + if (!match) return null; + return `${match[1]}${match[2] ?? ""}`; +} + +function findActualFile(relativePath, baseDir = projectRoot) { + const fullPath = pathResolve(baseDir, relativePath); const tryPaths = [ fullPath, @@ -195,16 +253,24 @@ function findActualFile(relativePath) { return null; } -function resolveAliasSpecifier(specifier) { +function resolveAliasSpecifier(specifier, scope) { const stdNormalized = normalizeStdSpecifier(specifier); const mapped = resolveFromImportMap(specifier) ?? resolveFromImportMap(stdNormalized); + // The root map keeps priority so this stays behaviour-preserving for every + // specifier it already covers; a member map only fills the gaps. + const scoped = mapped || !scope + ? null + : resolveFromMap(scope.imports, specifier) ?? resolveFromMap(scope.imports, stdNormalized); const fallback = fallbackAliasMap[specifier] ?? fallbackAliasMap[stdNormalized]; - const target = mapped ?? fallback; + const target = mapped ?? scoped ?? fallback; if (!target) return null; + // A member's relative targets are relative to the member directory. + const baseDir = scoped ? scope.dir : projectRoot; + if (target.startsWith("./") || target.startsWith("../")) { - return findActualFile(target.replace(/^\.\//, "")); + return findActualFile(target.replace(/^\.\//, ""), baseDir); } if (target.startsWith("jsr:@std/")) { @@ -213,17 +279,21 @@ function resolveAliasSpecifier(specifier) { return findActualFile(stdTarget.replace(/^\.\//, "")); } - if (target.startsWith("https://esm.sh/react") || target.startsWith("npm:react")) { - const reactTarget = reactImportMap[specifier] ?? reactImportMap[stdNormalized]; - if (!reactTarget) return null; - return findActualFile(reactTarget.replace(/^\.\//, "")); + // React is vendored under ./npm/node_modules for Node tests. Match on the + // package the target points at, not on the specifier, so the aliases the + // react workspace member declares (`@veryfront/react-dom-server-upstream` -> + // esm.sh/react-dom/server) land on the same vendored files. + const remoteBare = bareSpecifierFromRemoteTarget(target); + if (remoteBare && reactImportMap[remoteBare]) { + return findActualFile(reactImportMap[remoteBare].replace(/^\.\//, "")); } if (target.startsWith("npm:")) { - const npmSpecifier = target.slice(4); - const atIndex = npmSpecifier.indexOf("@", 1); - const packageName = atIndex > 0 ? npmSpecifier.slice(0, atIndex) : npmSpecifier; - return { packageName }; + // Keep the subpath: `npm:ajv@8/dist/2019.js` is not `ajv`, and the package + // entry point does not carry the subpath's exports. + const nodeSpecifier = remoteBare; + if (!nodeSpecifier) return null; + return { nodeSpecifier }; } return null; @@ -292,10 +362,16 @@ export async function resolve(specifier, context, nextResolve) { return nextResolve(packageName, context); } - const resolvedAlias = resolveAliasSpecifier(cleanSpecifier); + const parentPath = typeof context?.parentURL === "string" && context.parentURL.startsWith("file:") + ? fileURLToPath(context.parentURL) + : null; + const resolvedAlias = resolveAliasSpecifier( + cleanSpecifier, + findWorkspaceImportScope(parentPath), + ); if (resolvedAlias) { - if (typeof resolvedAlias === "object" && "packageName" in resolvedAlias) { - return nextResolve(resolvedAlias.packageName, context); + if (typeof resolvedAlias === "object" && "nodeSpecifier" in resolvedAlias) { + return nextResolve(resolvedAlias.nodeSpecifier, context); } if (typeof resolvedAlias === "string") { return { diff --git a/tsconfig.json b/tsconfig.json index 3a77faed31..5dd9388d07 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,8 @@ "#std/testing.ts": ["./src/testing/index.ts"], "#std/testing/bdd": ["./src/testing/bdd.ts"], "#std/testing/bdd.ts": ["./src/testing/bdd.ts"], + "#std/testing/time": ["./src/platform/compat/std/testing/time.ts"], + "#std/testing/time.ts": ["./src/platform/compat/std/testing/time.ts"], "#std/expect": ["./src/platform/compat/std/expect.ts"], "#std/expect.ts": ["./src/platform/compat/std/expect.ts"], "#std/async": ["./src/platform/compat/std/async.ts"], @@ -35,6 +37,10 @@ "#std/path.ts": ["./src/platform/compat/std/path.ts"], "#std/path/posix": ["./src/platform/compat/std/path.ts"], "#std/path/posix.ts": ["./src/platform/compat/std/path.ts"], + "#std/yaml": ["./src/platform/compat/std/yaml.ts"], + "#std/yaml.ts": ["./src/platform/compat/std/yaml.ts"], + "#std/yaml/parse": ["./src/platform/compat/std/yaml.ts"], + "#std/yaml/parse.ts": ["./src/platform/compat/std/yaml.ts"], "@std/assert": ["./src/testing/assert.ts"], "@std/testing": ["./src/testing/index.ts"], "@std/testing/bdd": ["./src/testing/bdd.ts"], @@ -46,6 +52,7 @@ "@std/front-matter/yaml": ["./src/platform/compat/std/front-matter-yaml.ts"], "@std/fs": ["./src/platform/compat/std/fs.ts"], "@std/path": ["./src/platform/compat/std/path.ts"], + "@std/yaml/parse": ["./src/platform/compat/std/yaml.ts"], "react": ["./npm/node_modules/react/index.js"], "react/jsx-runtime": ["./npm/node_modules/react/jsx-runtime.js"], "react/jsx-dev-runtime": ["./npm/node_modules/react/jsx-dev-runtime.js"], @@ -53,11 +60,36 @@ "react-dom/client": ["./npm/node_modules/react-dom/client.js"], "react-dom/server": ["./npm/node_modules/react-dom/server.node.js"], "veryfront": ["./src/index.ts"], - "veryfront/head": ["./src/react/components/Head.tsx"], - "veryfront/router": ["./src/react/router/index.tsx"], - "veryfront/context": ["./src/react/context/index.tsx"], - "veryfront/fonts": ["./src/react/fonts/index.ts"], "veryfront/*": ["./src/*"], + "veryfront/head": ["./src/react/runtime/core.ts"], + "veryfront/router": ["./src/react/runtime/core.ts"], + "veryfront/context": ["./src/react/runtime/core.ts"], + "veryfront/fonts": ["./src/react/fonts/index.ts"], + "veryfront/chat/uploads": ["./src/chat/upload-handler.ts"], + "veryfront/react/head": ["./src/react/runtime/core.ts"], + "veryfront/react/router": ["./src/react/runtime/core.ts"], + "veryfront/react/context": ["./src/react/runtime/core.ts"], + "veryfront/components/chat": ["./src/react/components/chat/index.ts"], + "veryfront/components/ui": ["./src/react/components/ui/index.ts"], + "veryfront/ui": ["./src/react/components/ui/index.ts"], + "veryfront/ui/adapter": ["./src/react/components/ui/adapter/contract.ts"], + "veryfront/ui/icons": ["./src/react/components/ui/icons/index.ts"], + "veryfront/agent/identity": ["./src/agent/identity-contracts.ts"], + "veryfront/transforms/mdx-cache": [ + "./src/transforms/mdx/esm-module-loader/cache/index.ts" + ], + "veryfront/observability/otlp-setup": [ + "./src/observability/tracing/otlp-setup.ts" + ], + "veryfront/platform/esbuild-init": [ + "./src/platform/compat/esbuild-init.ts" + ], + "veryfront/platform/env": ["./src/platform/compat/process/env.ts"], + "veryfront/platform/path": ["./src/platform/compat/path/index.ts"], + "veryfront/platform/http": ["./src/platform/compat/http/index.ts"], + "veryfront/errors/general": ["./src/errors/error-registry/general.ts"], + "veryfront/errors/module": ["./src/errors/error-registry/module.ts"], + "veryfront/cli": ["./cli/main.ts"], "#veryfront/testing": ["./src/testing/index.ts"], "#veryfront/testing/*": ["./src/testing/*"], "#veryfront": ["./src/index.ts"], From 29b1577081258b55de824fe0f8751be2e5d415e9 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 10 Aug 2026 12:22:50 +0200 Subject: [PATCH 2/7] fix(ext-yaml): forward the JSON schema, and stop the root map shadowing members Two review findings, both real, both reproduced before fixing. `schema: "json"` was computed and never passed to the parser, so the core schema ran and `0o7` decoded to the number 7 -- the exact widening the option exists to prevent at a Skill-document trust boundary. It is forwarded now. Forwarding it alone would have been worse than the bug. Under this schema the library raises TAG_RESOLVE_FAILED for every ordinary unquoted string, so `name: code-review` raises it twice and the existing check -- which threw on `errors[0] ?? warnings[0]` -- would have rejected every Skill document. The diagnostic is filtered per entry rather than by position, because a document that raises it also raises the real ones beside it: measured, `a: 1\na: 2` reports TAG_RESOLVE_FAILED twice before DUPLICATE_KEY, so reading the first would have hidden the duplicate. `!!binary` still decodes to a Buffer here, so the explicit-tag assertion stays load-bearing. Separately, the Node resolver consulted the root import map before a workspace member's own. Deno gives the member precedence inside that member, and the previous order silently resolved the six React specifiers that appear in both maps to the root's targets, so the aliases the member declared never applied. Checked in the direction that matters: reverting the schema forwarding fails the suite, and widening the diagnostic filter to swallow every diagnostic -- which would hide DUPLICATE_KEY -- fails it too. --- extensions/ext-yaml/src/adapter.test.ts | 29 +++++++++++++++++++++++++ extensions/ext-yaml/src/adapter.ts | 15 ++++++++++++- tests/node/resolver-hooks.mjs | 16 +++++++++----- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/extensions/ext-yaml/src/adapter.test.ts b/extensions/ext-yaml/src/adapter.test.ts index 311c1b7d92..aec4baea76 100644 --- a/extensions/ext-yaml/src/adapter.test.ts +++ b/extensions/ext-yaml/src/adapter.test.ts @@ -143,3 +143,32 @@ describe("yaml general parser", () => { }); }); }); + +describe("@std/yaml JSON schema fidelity", () => { + it("does not widen YAML 1.1 octals under the JSON schema", () => { + // The parser ran the core schema before the schema was forwarded, so this + // decoded to the number 7 -- a type the caller asked the JSON schema to + // exclude. + const parsed = parseYamlSource("o: 0o7", { schema: "json" }) as { o: unknown }; + + assertEquals(parsed.o, "0o7"); + }); + + it("accepts ordinary metadata, which the JSON schema flags per scalar", () => { + // Every plain scalar raises TAG_RESOLVE_FAILED under this schema, so a + // parser that rejected on any diagnostic would reject every Skill document. + assertEquals( + parseYamlSource("name: code-review\ndescription: Review code.", { schema: "json" }), + { name: "code-review", description: "Review code." }, + ); + }); + + it("still reports the real error hiding behind those diagnostics", () => { + // A duplicate key raises DUPLICATE_KEY *after* two TAG_RESOLVE_FAILEDs, so + // reading the first diagnostic would have surfaced the benign one. + assertThrows( + () => parseYamlSource("a: 1\na: 2", { schema: "json" }), + SyntaxError, + ); + }); +}); diff --git a/extensions/ext-yaml/src/adapter.ts b/extensions/ext-yaml/src/adapter.ts index 87d7ba7ee6..eab4663446 100644 --- a/extensions/ext-yaml/src/adapter.ts +++ b/extensions/ext-yaml/src/adapter.ts @@ -83,6 +83,10 @@ function decodeDocument(source: string, options: YamlParseOptions): unknown { try { documents = parseAllDocuments(source, { uniqueKeys: options.allowDuplicateKeys !== true, + // Forwarded, not merely recorded: without it the parser runs the core + // schema and resolves `0o7` to 7, which is exactly the widening + // `schema: "json"` is asked for at a trust boundary. + schema: jsonSchema ? "json" : "core", // Warnings are inspected below; left to the library they would be // written straight to the host process's stderr. logLevel: "silent", @@ -102,7 +106,16 @@ function decodeDocument(source: string, options: YamlParseOptions): unknown { // An unresolved tag is a warning in `yaml` and an error in `@std/yaml`. // Treat it as an error: a tag the parser did not understand means the // decoded value is not the one the document asked for. - const problem = document.errors[0] ?? document.warnings[0]; + // + // `TAG_RESOLVE_FAILED` is the exception, and only under the JSON schema, + // where it fires for every ordinary unquoted string -- `name: code-review` + // raises it twice. It is filtered per diagnostic rather than by taking the + // first: a document that raises it also raises the real ones beside it, so + // reading `errors[0]` would report the benign one and hide `BAD_INDENT` or + // `DUPLICATE_KEY` behind it. + const problem = [...document.errors, ...document.warnings].find( + (diagnostic) => !(jsonSchema && diagnostic.code === "TAG_RESOLVE_FAILED"), + ); if (problem) throw new SyntaxError(problem.message, { cause: problem }); if (jsonSchema) assertJsonRepresentableTags(document); diff --git a/tests/node/resolver-hooks.mjs b/tests/node/resolver-hooks.mjs index 84448af0c4..6d0dbb1ca4 100644 --- a/tests/node/resolver-hooks.mjs +++ b/tests/node/resolver-hooks.mjs @@ -255,14 +255,18 @@ function findActualFile(relativePath, baseDir = projectRoot) { function resolveAliasSpecifier(specifier, scope) { const stdNormalized = normalizeStdSpecifier(specifier); - const mapped = resolveFromImportMap(specifier) ?? resolveFromImportMap(stdNormalized); - // The root map keeps priority so this stays behaviour-preserving for every - // specifier it already covers; a member map only fills the gaps. - const scoped = mapped || !scope + // A member's own map wins inside that member, which is what Deno does and + // what the member declared it for. Consulting the root first looked + // conservative but silently resolved the six React specifiers that appear in + // both maps to the root's targets, so the member's aliases never applied. + const scoped = scope + ? resolveFromMap(scope.imports, specifier) ?? resolveFromMap(scope.imports, stdNormalized) + : null; + const mapped = scoped ? null - : resolveFromMap(scope.imports, specifier) ?? resolveFromMap(scope.imports, stdNormalized); + : resolveFromImportMap(specifier) ?? resolveFromImportMap(stdNormalized); const fallback = fallbackAliasMap[specifier] ?? fallbackAliasMap[stdNormalized]; - const target = mapped ?? scoped ?? fallback; + const target = scoped ?? mapped ?? fallback; if (!target) return null; From 8fcaee8fe66a51ca6cd07e30a6d27085f1e11af0 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 10 Aug 2026 12:27:04 +0200 Subject: [PATCH 3/7] fix: close the remaining cross-runtime review findings `FakeTime.tick(NaN)` passed the backwards check, because `NaN < now` is false, and then `#due` treated every pending timer as due, because `due > NaN` is false too. The clock ended up NaN and every later assertion on it was quietly meaningless. Rejected now, with a test that fails without the guard. `collectCrossRuntimeFiles` guarded the `Deno.readDir` call, but readDir is lazy: a missing root raises when iteration starts, so the catch never ran. The loop is guarded instead, and only NotFound is swallowed -- a permissions or I/O failure is a reason to stop, not to audit fewer files than the caller believes were scanned. The lint's test imports its helpers from `#veryfront/testing`, as its sibling in this PR already did, and `yaml-defaults.ts` reaches `first-party-import` through the `#veryfront/` alias rather than a relative path out of its module. The new FakeTime case was written with `Deno.test` at first, which would have put `Deno.` into a file both alternate runners exclude on sight -- removing the coverage it was added for. It uses describe/it like the rest of the file, and the file names no runtime API. --- scripts/lint/audit-cross-runtime-jsr.test.ts | 4 +-- scripts/lint/audit-cross-runtime-jsr.ts | 32 +++++++++++--------- src/extensions/parser/yaml-defaults.ts | 2 +- src/platform/compat/std/testing/time.test.ts | 15 +++++++++ src/platform/compat/std/testing/time.ts | 7 +++++ 5 files changed, 42 insertions(+), 18 deletions(-) diff --git a/scripts/lint/audit-cross-runtime-jsr.test.ts b/scripts/lint/audit-cross-runtime-jsr.test.ts index 17f6924455..92170c74de 100644 --- a/scripts/lint/audit-cross-runtime-jsr.test.ts +++ b/scripts/lint/audit-cross-runtime-jsr.test.ts @@ -1,5 +1,5 @@ -import { assertEquals } from "#std/assert"; -import { describe, it } from "#std/testing/bdd"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; import { auditCrossRuntimeImports, compareAgainstBaseline, diff --git a/scripts/lint/audit-cross-runtime-jsr.ts b/scripts/lint/audit-cross-runtime-jsr.ts index 33220354a3..6ba1b56065 100644 --- a/scripts/lint/audit-cross-runtime-jsr.ts +++ b/scripts/lint/audit-cross-runtime-jsr.ts @@ -465,23 +465,25 @@ async function collectCrossRuntimeFiles( root: string, out: string[], ): Promise { - let entries: AsyncIterable; + // `Deno.readDir` is lazy: a missing root raises when iteration starts, not + // when it is called, so guarding the call alone caught nothing. Only an + // absent root is expected -- a permissions failure or an I/O error is a + // reason to stop, not to silently audit fewer files than the caller thinks. try { - entries = Deno.readDir(root); - } catch (_) { - return; // expected: a scan root may be absent in a partial checkout - } - for await (const entry of entries) { - if (entry.name === "node_modules") continue; - const path = `${root}/${entry.name}`; - if (entry.isDirectory) { - await collectCrossRuntimeFiles(path, out); - } else if ( - entry.isFile && (path.endsWith(".ts") || path.endsWith(".tsx")) && - !path.endsWith(".d.ts") - ) { - out.push(path); + for await (const entry of Deno.readDir(root)) { + if (entry.name === "node_modules") continue; + const path = `${root}/${entry.name}`; + if (entry.isDirectory) { + await collectCrossRuntimeFiles(path, out); + } else if ( + entry.isFile && (path.endsWith(".ts") || path.endsWith(".tsx")) && + !path.endsWith(".d.ts") + ) { + out.push(path); + } } + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; } } diff --git a/src/extensions/parser/yaml-defaults.ts b/src/extensions/parser/yaml-defaults.ts index 2996372d7e..62be14802b 100644 --- a/src/extensions/parser/yaml-defaults.ts +++ b/src/extensions/parser/yaml-defaults.ts @@ -13,7 +13,7 @@ import { importFirstPartyExtensionModule, isMissingFirstPartyExtensionModule, -} from "../first-party-import.ts"; +} from "#veryfront/extensions/first-party-import.ts"; import { snapshotYamlParserProvider, type YamlParserProvider } from "./yaml-parser.ts"; const DEFAULT_YAML_SOURCE_DIRECTORY = "ext-yaml"; diff --git a/src/platform/compat/std/testing/time.test.ts b/src/platform/compat/std/testing/time.test.ts index 1dabaf8de0..0f0e0081ab 100644 --- a/src/platform/compat/std/testing/time.test.ts +++ b/src/platform/compat/std/testing/time.test.ts @@ -155,3 +155,18 @@ describe("platform/compat/std/testing/time", () => { assertThrows(() => time.tick(-1), RangeError); }); }); + +describe("platform/compat/std/testing/time non-finite advance", () => { + it("rejects a non-finite advance", () => { + // NaN slips past the backwards check (`NaN < now` is false) and then makes + // #due treat every pending timer as due (`due > NaN` is false too), leaving + // the clock NaN and every later assertion on it meaningless. + using time = new FakeTime(); + let fired = false; + setTimeout(() => (fired = true), 10_000); + + assertThrows(() => time.tick(NaN), RangeError); + assertEquals(fired, false, "a rejected tick must not fire timers"); + assertEquals(Number.isFinite(time.now), true, "the clock must stay finite"); + }); +}); diff --git a/src/platform/compat/std/testing/time.ts b/src/platform/compat/std/testing/time.ts index ec2941eb6d..2aa82642f5 100644 --- a/src/platform/compat/std/testing/time.ts +++ b/src/platform/compat/std/testing/time.ts @@ -99,6 +99,13 @@ export class FakeTime { /** Advance the clock, running every timer that comes due on the way. */ tick(ms = 0): void { + if (!Number.isFinite(ms)) { + // NaN slips past the comparison below -- `NaN < this.#now` is false -- + // and then `#due` treats every pending timer as due, because + // `timer.due > NaN` is false too. The clock ends up NaN and every later + // assertion on it is quietly meaningless. + throw new RangeError(`Cannot advance the fake clock by ${ms}ms`); + } const target = this.#now + ms; if (target < this.#now) { throw new RangeError(`Cannot move the fake clock backwards; received ${ms}ms`); From 242e5f3c77a5e5c95773d72471175a1f51ef3ea1 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 10 Aug 2026 12:31:59 +0200 Subject: [PATCH 4/7] fix: close the second review round, including a vacuous test of my own CodeQL was right, and it was the red check rather than infrastructure: `best.target.replace("*", captured)` substitutes one occurrence, so a tsconfig target carrying two wildcards kept a literal `*` and compared unequal. Uses replaceAll. The workspace containment check appended a literal `/` while both sides come from pathResolve and fileURLToPath. On Windows those are backslash-separated, so no file matched its member scope and every member map silently failed to apply -- the platform separator is used instead. And the duplicate-key test I added in the previous commit asserted only that a SyntaxError was thrown, which the benign TAG_RESOLVE_FAILED preceding it would also have satisfied. It asserts the message now, and reverting the diagnostic filter to `errors[0]` fails it -- which the earlier version did not. That is the third test in this branch to have passed for the wrong reason, and the second one I wrote while fixing the first. --- extensions/ext-yaml/src/adapter.test.ts | 3 +++ src/config/tsconfig-paths-parity.test.ts | 5 ++++- tests/node/resolver-hooks.mjs | 7 +++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/extensions/ext-yaml/src/adapter.test.ts b/extensions/ext-yaml/src/adapter.test.ts index aec4baea76..cd3942329a 100644 --- a/extensions/ext-yaml/src/adapter.test.ts +++ b/extensions/ext-yaml/src/adapter.test.ts @@ -166,9 +166,12 @@ describe("@std/yaml JSON schema fidelity", () => { it("still reports the real error hiding behind those diagnostics", () => { // A duplicate key raises DUPLICATE_KEY *after* two TAG_RESOLVE_FAILEDs, so // reading the first diagnostic would have surfaced the benign one. + // Asserting the type alone would pass on the TAG_RESOLVE_FAILED that + // precedes it, which is the very confusion this guards against. assertThrows( () => parseYamlSource("a: 1\na: 2", { schema: "json" }), SyntaxError, + "Map keys must be unique", ); }); }); diff --git a/src/config/tsconfig-paths-parity.test.ts b/src/config/tsconfig-paths-parity.test.ts index 692d7f453f..19f85053e4 100644 --- a/src/config/tsconfig-paths-parity.test.ts +++ b/src/config/tsconfig-paths-parity.test.ts @@ -51,7 +51,10 @@ function resolveThroughPaths(specifier: string, paths: PathsMap): string | null best.prefix.length, specifier.length - best.suffix.length, ); - return best.target.replace("*", captured); + // Every wildcard, not just the first: `replace` with a string pattern + // substitutes one occurrence, so a target carrying two would keep a literal + // `*` and silently compare unequal. + return best.target.replaceAll("*", captured); } /** Collapses the extensionless and directory forms a resolver would accept. */ diff --git a/tests/node/resolver-hooks.mjs b/tests/node/resolver-hooks.mjs index 6d0dbb1ca4..e682efaa71 100644 --- a/tests/node/resolver-hooks.mjs +++ b/tests/node/resolver-hooks.mjs @@ -9,7 +9,7 @@ */ import { existsSync, readFileSync, statSync } from "node:fs"; -import { dirname, resolve as pathResolve } from "node:path"; +import { dirname, resolve as pathResolve, sep } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -202,7 +202,10 @@ export function findWorkspaceImportScope(parentPath) { if (!parentPath) return null; let best = null; for (const scope of workspaceImportScopes) { - if (parentPath !== scope.dir && !parentPath.startsWith(`${scope.dir}/`)) continue; + // `sep`, not a literal "/": both sides come from pathResolve/fileURLToPath, + // so on Windows they are backslash-separated and a hard-coded slash matches + // nothing -- every member scope would silently fail to apply. + if (parentPath !== scope.dir && !parentPath.startsWith(`${scope.dir}${sep}`)) continue; if (!best || scope.dir.length > best.dir.length) best = scope; } return best; From df300bbc7ce6ee98e6a6cefc1adb17970652d57b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 10 Aug 2026 12:35:27 +0200 Subject: [PATCH 5/7] build: refresh the proxy dependency lock `tests-proxy-binary` verifies the lock is current by regenerating it and failing on any diff. Routing YAML through `ext-yaml` removed `jsr:@std/yaml` from the proxy's dependency graph, so the committed lock still named a dependency the proxy no longer has. Regenerated rather than hand-edited. The removal of that specifier is the whole change and is exactly what the extension move implies. I called this failure infrastructure twice on the strength of a `curl 404` in the setup step. It was reproducible on this branch all three times, and the failing step was "Verify proxy dependency lock is current" throughout. --- scripts/build/proxy-deno.lock | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/scripts/build/proxy-deno.lock b/scripts/build/proxy-deno.lock index a3878f0d72..1127f5fbbe 100644 --- a/scripts/build/proxy-deno.lock +++ b/scripts/build/proxy-deno.lock @@ -1,7 +1,6 @@ { "version": "5", "specifiers": { - "jsr:@std/yaml@1.1.0": "1.1.0", "npm:@opentelemetry/api-logs@0.220.0": "0.220.0", "npm:@opentelemetry/api@1.9.1": "1.9.1", "npm:@opentelemetry/auto-instrumentations-node@0.78.0": "0.78.0_@opentelemetry+api@1.9.1_@opentelemetry+core@2.9.0__@opentelemetry+api@1.9.1", @@ -24,11 +23,6 @@ "npm:redis@5.11.0": "5.11.0", "npm:zod@4.3.6": "4.3.6" }, - "jsr": { - "@std/yaml@1.1.0": { - "integrity": "fc1c5c63e05c4c5eb6118355f557958035d41940d6c29d35b306ef7155d6edb0" - } - }, "npm": { "@apm-js-collab/code-transformer-bundler-plugins@0.7.1": { "integrity": "sha512-Yidf5GOl60db80UxUtNdKK3pnY7obU/gs0xOfA0SCdnvVLMCvfYIer/egC3TqpPiT0Jg22eg3RlzcO+zKfPMcA==", @@ -1510,8 +1504,7 @@ "jsr:@std/fmt@1.0.9", "jsr:@std/fs@1.0.23", "jsr:@std/path@1.1.4", - "jsr:@std/testing@1.0.17", - "jsr:@std/yaml@1.1.0" + "jsr:@std/testing@1.0.17" ], "members": { "extensions/ext-auth-jwt": { @@ -1743,7 +1736,7 @@ "dependencies": [ "jsr:@std/assert@1.0.19", "jsr:@std/testing@1.0.17", - "jsr:@std/yaml@1.1.0" + "npm:yaml@2.9.0" ] } } From 49373595fd25ea956aa80c7774228bb13863dbeb Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 10 Aug 2026 14:34:30 +0200 Subject: [PATCH 6/7] fix(test): address cross-runtime review gaps --- scripts/lint/audit-cross-runtime-jsr.test.ts | 16 ++++++++ scripts/lint/audit-cross-runtime-jsr.ts | 27 +++++-------- src/config/tsconfig-paths-parity.test.ts | 41 +++++--------------- 3 files changed, 35 insertions(+), 49 deletions(-) diff --git a/scripts/lint/audit-cross-runtime-jsr.test.ts b/scripts/lint/audit-cross-runtime-jsr.test.ts index 92170c74de..4d08955eec 100644 --- a/scripts/lint/audit-cross-runtime-jsr.test.ts +++ b/scripts/lint/audit-cross-runtime-jsr.test.ts @@ -106,6 +106,22 @@ describe("resolveTsconfigPath", () => { assertEquals(resolveTsconfigPath(paths, "#a/x.ts"), "./short/x.ts"); }); + it("keeps the suffix and target from the selected wildcard key", () => { + const paths = { + "#a/*.json": "./json/*.json", + "#a/*.ts": "./typed/*.ts", + "#a/*": "./plain/*", + }; + assertEquals(resolveTsconfigPath(paths, "#a/x.ts"), "./typed/x.ts"); + }); + + it("substitutes every wildcard in the selected target", () => { + assertEquals( + resolveTsconfigPath({ "#a/*": "./*/index/*.ts" }, "#a/x"), + "./x/index/x.ts", + ); + }); + it("returns null for an unmapped specifier", () => { assertEquals(resolveTsconfigPath({ "#a/b": "./x.ts" }, "#a/c"), null); }); diff --git a/scripts/lint/audit-cross-runtime-jsr.ts b/scripts/lint/audit-cross-runtime-jsr.ts index 6ba1b56065..059405b6b6 100644 --- a/scripts/lint/audit-cross-runtime-jsr.ts +++ b/scripts/lint/audit-cross-runtime-jsr.ts @@ -303,31 +303,24 @@ export function resolveTsconfigPath( const exact = paths[specifier]; if (exact) return exact; - let bestPrefix: string | null = null; - for (const key of Object.keys(paths)) { + let best: { prefix: string; suffix: string; target: string } | null = null; + for (const [key, target] of Object.entries(paths)) { const star = key.indexOf("*"); if (star === -1) continue; const prefix = key.slice(0, star); const suffix = key.slice(star + 1); if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue; if (specifier.length < prefix.length + suffix.length) continue; - if (bestPrefix === null || prefix.length > bestPrefix.length) { - bestPrefix = prefix; - } + if (best && best.prefix.length >= prefix.length) continue; + best = { prefix, suffix, target }; } - if (bestPrefix === null) return null; + if (!best) return null; - for (const [key, target] of Object.entries(paths)) { - const star = key.indexOf("*"); - if (star === -1 || key.slice(0, star) !== bestPrefix) continue; - const suffix = key.slice(star + 1); - const matched = specifier.slice( - bestPrefix.length, - specifier.length - suffix.length, - ); - return target.replace("*", matched); - } - return null; + const matched = specifier.slice( + best.prefix.length, + specifier.length - best.suffix.length, + ); + return best.target.replaceAll("*", matched); } /** Would Bun resolve this specifier to a file? */ diff --git a/src/config/tsconfig-paths-parity.test.ts b/src/config/tsconfig-paths-parity.test.ts index 19f85053e4..7cb31094b7 100644 --- a/src/config/tsconfig-paths-parity.test.ts +++ b/src/config/tsconfig-paths-parity.test.ts @@ -1,5 +1,10 @@ +import { readFileSync, statSync } from "node:fs"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + flattenTsconfigPaths, + resolveTsconfigPath, +} from "../../scripts/lint/audit-cross-runtime-jsr.ts"; /** * Bun's runtime resolver never hands a bare package specifier to a `--preload` @@ -18,7 +23,7 @@ type PathsMap = Record; const repoRoot = new URL("../../", import.meta.url); function readRepoJson(name: string): Record { - return JSON.parse(Deno.readTextFileSync(new URL(name, repoRoot))); + return JSON.parse(readFileSync(new URL(name, repoRoot), "utf8")); } const denoImports = readRepoJson("deno.json").imports as @@ -27,35 +32,7 @@ const denoImports = readRepoJson("deno.json").imports as const tsconfigPaths = ((readRepoJson("tsconfig.json").compilerOptions as { paths?: PathsMap } | undefined) ?.paths ?? {}) as PathsMap; - -/** Applies TypeScript's `paths` rules: exact key first, then longest prefix. */ -function resolveThroughPaths(specifier: string, paths: PathsMap): string | null { - const exact = paths[specifier]?.[0]; - if (exact) return exact; - - let best: { prefix: string; suffix: string; target: string } | null = null; - for (const [pattern, targets] of Object.entries(paths)) { - const star = pattern.indexOf("*"); - const target = targets[0]; - if (star === -1 || target === undefined) continue; - const prefix = pattern.slice(0, star); - const suffix = pattern.slice(star + 1); - if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue; - if (specifier.length < prefix.length + suffix.length) continue; - if (best && best.prefix.length >= prefix.length) continue; - best = { prefix, suffix, target }; - } - if (!best) return null; - - const captured = specifier.slice( - best.prefix.length, - specifier.length - best.suffix.length, - ); - // Every wildcard, not just the first: `replace` with a string pattern - // substitutes one occurrence, so a target carrying two would keep a literal - // `*` and silently compare unequal. - return best.target.replaceAll("*", captured); -} +const flattenedTsconfigPaths = flattenTsconfigPaths(tsconfigPaths); /** Collapses the extensionless and directory forms a resolver would accept. */ function toExistingFile(target: string): string | null { @@ -68,7 +45,7 @@ function toExistingFile(target: string): string | null { ]; for (const candidate of candidates) { try { - if (Deno.statSync(new URL(candidate, repoRoot)).isFile) return candidate; + if (statSync(new URL(candidate, repoRoot)).isFile()) return candidate; } catch { continue; } @@ -98,7 +75,7 @@ describe("config/tsconfig-paths-parity", () => { drifted.push(`${specifier}: deno.json points at missing ${denoTarget}`); continue; } - const viaPaths = resolveThroughPaths(specifier, tsconfigPaths); + const viaPaths = resolveTsconfigPath(flattenedTsconfigPaths, specifier); if (viaPaths === null) { drifted.push(`${specifier}: no tsconfig paths entry`); continue; From 31e636069ae9dd1fa8eb04156efa2e7a71fcdb88 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 10 Aug 2026 14:42:18 +0200 Subject: [PATCH 7/7] fix(test): isolate shared path resolution --- scripts/lint/audit-cross-runtime-jsr.test.ts | 3 +- scripts/lint/audit-cross-runtime-jsr.ts | 44 +------------------- scripts/lint/tsconfig-paths.ts | 43 +++++++++++++++++++ src/config/tsconfig-paths-parity.test.ts | 5 +-- 4 files changed, 46 insertions(+), 49 deletions(-) create mode 100644 scripts/lint/tsconfig-paths.ts diff --git a/scripts/lint/audit-cross-runtime-jsr.test.ts b/scripts/lint/audit-cross-runtime-jsr.test.ts index 4d08955eec..0a1a0abb50 100644 --- a/scripts/lint/audit-cross-runtime-jsr.test.ts +++ b/scripts/lint/audit-cross-runtime-jsr.test.ts @@ -5,7 +5,6 @@ import { compareAgainstBaseline, type CrossRuntimeImport, failingRuntimes, - flattenTsconfigPaths, hasFailures, isShimmedEverywhere, isStdOrJsrSpecifier, @@ -13,9 +12,9 @@ import { parseStdShimMap, resolvesOnBun, resolvesOnNode, - resolveTsconfigPath, type RuntimeResolutionContext, } from "./audit-cross-runtime-jsr.ts"; +import { flattenTsconfigPaths, resolveTsconfigPath } from "./tsconfig-paths.ts"; /** * A miniature repo: `#std/path` is shimmed everywhere, `#std/testing/time` is diff --git a/scripts/lint/audit-cross-runtime-jsr.ts b/scripts/lint/audit-cross-runtime-jsr.ts index 059405b6b6..10a3cf1c32 100644 --- a/scripts/lint/audit-cross-runtime-jsr.ts +++ b/scripts/lint/audit-cross-runtime-jsr.ts @@ -50,6 +50,7 @@ */ import { extractImports } from "./check-module-boundaries.ts"; +import { flattenTsconfigPaths, resolveTsconfigPath } from "./tsconfig-paths.ts"; /** * Roots that Node and Bun execute. Mirrors the runner globs: `test:node` runs @@ -291,38 +292,6 @@ export function resolvesOnNode( return false; } -/** - * Resolve a specifier through tsconfig `paths`: exact key first, then the - * longest matching wildcard key. This is TypeScript's documented algorithm and - * the one Bun implements. - */ -export function resolveTsconfigPath( - paths: Readonly>, - specifier: string, -): string | null { - const exact = paths[specifier]; - if (exact) return exact; - - let best: { prefix: string; suffix: string; target: string } | null = null; - for (const [key, target] of Object.entries(paths)) { - const star = key.indexOf("*"); - if (star === -1) continue; - const prefix = key.slice(0, star); - const suffix = key.slice(star + 1); - if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue; - if (specifier.length < prefix.length + suffix.length) continue; - if (best && best.prefix.length >= prefix.length) continue; - best = { prefix, suffix, target }; - } - if (!best) return null; - - const matched = specifier.slice( - best.prefix.length, - specifier.length - best.suffix.length, - ); - return best.target.replaceAll("*", matched); -} - /** Would Bun resolve this specifier to a file? */ export function resolvesOnBun( specifier: string, @@ -489,17 +458,6 @@ function fileExistsOnDisk(path: string): boolean { } /** Flatten tsconfig `paths` (arrays of candidates) to the first candidate. */ -export function flattenTsconfigPaths( - raw: Record, -): Record { - const paths: Record = {}; - for (const [key, value] of Object.entries(raw)) { - const first = Array.isArray(value) ? value[0] : value; - if (typeof first === "string") paths[key] = first; - } - return paths; -} - async function loadContext(): Promise { const config = JSON.parse(await Deno.readTextFile(DENO_CONFIG_PATH)) as { imports?: Record; diff --git a/scripts/lint/tsconfig-paths.ts b/scripts/lint/tsconfig-paths.ts new file mode 100644 index 0000000000..0b04b9617d --- /dev/null +++ b/scripts/lint/tsconfig-paths.ts @@ -0,0 +1,43 @@ +/** + * Resolve a specifier through tsconfig `paths`: exact key first, then the + * longest matching wildcard key. This is TypeScript's documented algorithm and + * the one Bun implements. + */ +export function resolveTsconfigPath( + paths: Readonly>, + specifier: string, +): string | null { + const exact = paths[specifier]; + if (exact) return exact; + + let best: { prefix: string; suffix: string; target: string } | null = null; + for (const [key, target] of Object.entries(paths)) { + const star = key.indexOf("*"); + if (star === -1) continue; + const prefix = key.slice(0, star); + const suffix = key.slice(star + 1); + if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue; + if (specifier.length < prefix.length + suffix.length) continue; + if (best && best.prefix.length >= prefix.length) continue; + best = { prefix, suffix, target }; + } + if (!best) return null; + + const matched = specifier.slice( + best.prefix.length, + specifier.length - best.suffix.length, + ); + return best.target.replaceAll("*", matched); +} + +/** Flatten tsconfig `paths` entries to their first string target. */ +export function flattenTsconfigPaths( + raw: Readonly>, +): Record { + const paths: Record = {}; + for (const [key, value] of Object.entries(raw)) { + const first = Array.isArray(value) ? value[0] : value; + if (typeof first === "string") paths[key] = first; + } + return paths; +} diff --git a/src/config/tsconfig-paths-parity.test.ts b/src/config/tsconfig-paths-parity.test.ts index 7cb31094b7..ad5c3f93e5 100644 --- a/src/config/tsconfig-paths-parity.test.ts +++ b/src/config/tsconfig-paths-parity.test.ts @@ -1,10 +1,7 @@ import { readFileSync, statSync } from "node:fs"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { - flattenTsconfigPaths, - resolveTsconfigPath, -} from "../../scripts/lint/audit-cross-runtime-jsr.ts"; +import { flattenTsconfigPaths, resolveTsconfigPath } from "../../scripts/lint/tsconfig-paths.ts"; /** * Bun's runtime resolver never hands a bare package specifier to a `--preload`