diff --git a/.claude/rules/typescript/deno-essentials.md b/.claude/rules/typescript/deno-essentials.md index 23dd9d1896..bd14dd3866 100644 --- a/.claude/rules/typescript/deno-essentials.md +++ b/.claude/rules/typescript/deno-essentials.md @@ -49,19 +49,15 @@ import { join } from "jsr:/@std/path"; ## Deno APIs vs Node.js -Use Deno APIs directly: +Import from the runtime layer (`src/deno_ral/`) instead of calling `Deno.*` directly: ```typescript -// Correct - Deno APIs -Deno.env.get("PATH"); -Deno.cwd(); -Deno.readTextFileSync(path); -Deno.writeTextFileSync(path, content); -Deno.makeTempDirSync({ prefix: "quarto" }); +// Correct - runtime layer +import { readTextFileSync, writeTextFileSync } from "../deno_ral/fs.ts"; +import { makeTempDirSync } from "../deno_ral/temp.ts"; -// Wrong - Node.js patterns -process.env.PATH; -fs.readFileSync(path); +// Wrong - direct runtime calls (the CI ratchet counts these) +Deno.readTextFileSync(path); ``` ## Sync vs Async @@ -86,6 +82,7 @@ if (isWindows) { ## Lint Directives Common directives (use sparingly): + - `no-explicit-any` - For Cliffy options, JSON parsing - `no-control-regex` - For regex with control characters diff --git a/.claude/rules/typescript/deno-ral.md b/.claude/rules/typescript/deno-ral.md index 8c9b82d351..11eb8cee51 100644 --- a/.claude/rules/typescript/deno-ral.md +++ b/.claude/rules/typescript/deno-ral.md @@ -9,14 +9,16 @@ The `deno_ral/` directory provides a runtime abstraction layer over Deno's stand ## Available Modules -| Module | Purpose | Key exports | -|--------|---------|-------------| -| `fs.ts` | File system | `existsSync`, `ensureDirSync`, `copySync`, `safeMoveSync`, `safeRemoveSync` | -| `path.ts` | Path utilities | `join`, `dirname`, `basename`, `extname` | -| `log.ts` | Logging | `debug`, `info`, `warning`, `error` | -| `kv.ts` | Deno KV cache stores | `openKv`, `getKv`, `kvOp`, `KvDamagedError` | -| `platform.ts` | Platform detection | `isWindows` | -| `process.ts` | Process execution | Process utilities | +| Module | Purpose | Key exports | +| ------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fs.ts` | File system | `readTextFileSync`, `writeTextFileSync`, `statSync`, `readDirSync` (sorted), `removeSync`, `mkdirSync`, `copyFileSync`, `open`, `canonicalPathSync` (resolves symlinks), `existsSync`, `ensureDirSync`, `copySync`, `safeMoveSync`, `safeRemoveSync`; types `FileInfo`, `DirEntry`, `FsFile` | +| `temp.ts` | Temp files/dirs (retry on AlreadyExists) | `makeTempDirSync`, `makeTempDir`, `makeTempFileSync`, `makeTempFile`; type `TempOptions` | +| `errors.ts` | Runtime-neutral error predicates | `isNotFound`, `isAlreadyExists`, `isPermissionDenied`, `isAddrInUse`, `isConnectionRefused`, `isConnectionError`, `errorWithPath` | +| `path.ts` | Path utilities | `join`, `dirname`, `basename`, `extname` | +| `log.ts` | Logging | `debug`, `info`, `warning`, `error` | +| `kv.ts` | Deno KV cache stores | `openKv`, `getKv`, `kvOp`, `KvDamagedError` | +| `platform.ts` | Platform detection | `isWindows` | +| `process.ts` | Process execution | Process utilities | ## Why Use deno_ral @@ -28,6 +30,7 @@ The `deno_ral/` directory provides a runtime abstraction layer over Deno's stand ## Common Utilities **Temp files** (`src/core/temp.ts`): + ```typescript import { createTempContext } from "../core/temp.ts"; @@ -40,6 +43,7 @@ try { ``` **Process execution** (`src/core/process.ts`): + ```typescript import { execProcess } from "../core/process.ts"; @@ -60,8 +64,18 @@ const result = await execProcess({ ## Module Loading Order `src/quarto.ts` loads monkey patches first: + ```typescript -import "./core/deno/monkey-patch.ts"; // Must be first! +import "./core/deno/monkey-patch.ts"; // Must be first! ``` This ensures compatibility shims load before any code runs. + +## Moving code off `Deno.*` + +Outside `deno_ral/`, never add new `Deno.*` calls; import the equivalent from `deno_ral/`. The `deno_ral/` modules themselves implement those functions with `Deno.*`. To migrate existing files mechanically: + + quarto run --dev tools/codemod-deno-ral.ts --rules fs,temp --dir src/ --dry-run + quarto run --dev tools/codemod-deno-ral.ts --rules fs,temp --dir src/ + +The tool reports local-name collisions (left as `Deno.*`; fix with an alias import such as `import { stat as fsStat } ...`, which later runs reuse) and skips files that assign to `Deno.*`, `src/core/lib` and the standalone scripts. Replace `instanceof Deno.errors.X` with the predicates in `deno_ral/errors.ts`. After a rebase conflict in a codemodded file, reset the file to `main` and re-run the codemod; never resolve by hand. `--reverse` produces the `Deno.*` form for backports (`dev-docs/checklist-backport-a-pr.md`). diff --git a/.gitattributes b/.gitattributes index a47ecd0b55..9cc408d197 100644 --- a/.gitattributes +++ b/.gitattributes @@ -32,6 +32,8 @@ src/resources/library/dayjs/** linguist-vendored # use lf line-endings for source files src/** text eol=lf +# codemod fixtures and tests compare file bytes verbatim +tools/codemod/** text eol=lf # Denote all files that are truly binary and should not be modified. *.png binary diff --git a/.github/workflows/runtime-boundary.yml b/.github/workflows/runtime-boundary.yml index 3a71947f68..7ca61a7cc7 100644 --- a/.github/workflows/runtime-boundary.yml +++ b/.github/workflows/runtime-boundary.yml @@ -21,6 +21,8 @@ on: - "tests/**" - "tools/check-runtime-boundary.ts" - "tools/runtime-boundary-baseline.json" + - "tools/codemod-deno-ral.ts" + - "tools/codemod/**" - ".github/workflows/runtime-boundary.yml" push: branches: [main] @@ -30,6 +32,8 @@ on: - "tests/**" - "tools/check-runtime-boundary.ts" - "tools/runtime-boundary-baseline.json" + - "tools/codemod-deno-ral.ts" + - "tools/codemod/**" - ".github/workflows/runtime-boundary.yml" permissions: @@ -52,6 +56,9 @@ jobs: - name: Configure Quarto uses: ./.github/workflows/actions/quarto-dev + - name: Codemod contract test (tools/codemod) + run: package/dist/bin/tools/x86_64/deno test --allow-all --import-map=src/import_map.json tools/codemod/ + - name: Check runtime boundary shell: bash env: diff --git a/dev-docs/checklist-backport-a-pr.md b/dev-docs/checklist-backport-a-pr.md index 82661efa68..a24fb8f695 100644 --- a/dev-docs/checklist-backport-a-pr.md +++ b/dev-docs/checklist-backport-a-pr.md @@ -8,6 +8,29 @@ We backport development PRs to the stable branch using the following steps: - Cherry-pick the commits from the development branch: - `git cherry-pick ` for every commit identified above. - Resolve conflicts as needed. +- If the stable branch predates the runtime-layer codemods (v1.10 and earlier: `src/deno_ral/fs.ts` there has no `readTextFileSync`) and a commit touches TypeScript, cherry-picking conflicts wherever its context lines were codemodded. Build the patch in the stable branch's `Deno.*` form instead, from the `main` checkout: + + ```bash + c= + w=$(mktemp -d)/bp + git worktree add --detach "$w" "$c^" + changed() { git diff -z --no-renames --name-only --diff-filter="$1" "$c^" "$c" -- "${@:2}"; } + rev() { + changed "$1" '*.ts' | while IFS= read -r -d '' f; do + quarto run --dev tools/codemod-deno-ral.ts --reverse --rules fs,temp,env,process,platform \ + --ral-dir "$w/src/deno_ral" --file "$w/$f" + done + } + rev MD && git -C "$w" commit -qam "reversed base" --allow-empty + changed AM | while IFS= read -r -d '' f; do git -C "$w" checkout "$c" -- "$f"; done + changed D | while IFS= read -r -d '' f; do git -C "$w" rm -q -- "$f"; done + rev AM + git -C "$w" add -A && git -C "$w" diff --cached HEAD > "$w.patch" + git worktree remove --force "$w" + ``` + + Then, on the stable branch: `git apply -3 "$w.patch" && git commit -C "$c"`. The tool prints `left:` for any layer name it cannot express (for example `args` used without a call) and never rewrites `deno_ral/errors.ts` predicates: grep the patch for `deno_ral/errors.ts` and `isNotFound(` and turn them into `instanceof Deno.errors.X` by hand. The tool reports `canonicalPathSync` as `left` (stable branches patch `Deno.realPathSync`): import `originalRealPathSync` from `deno_ral/original-real-path.ts` there. + - Revert the changelog file changes if they were included in the original commits (the dev-branch `changelog-1.(x+1).md` does not exist on the stable branch, so the cherry-pick shows a modify/delete conflict — drop that hunk). - Add the new changelog entries to `news/changelog-1.x.md`, under `# v1.(x+1) backports` > `## In this release` at the top of the file. There are change categories in the development release but not in the backport release, so just add them chronologically to the section. diff --git a/dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md b/dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md index 52d7e7f966..3ef9b00cf4 100644 --- a/dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md +++ b/dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md @@ -44,7 +44,7 @@ What exists today (verified 2026-10-15, `main` at `b86f98d`): - `src/core/performance/metrics.ts:18-33` (`captureFileReads`) reassigns `Deno.readTextFileSync` at runtime to record file reads. Until PR 20b replaces that with the layer's `onFileRead(listener)` hook, the layer must look `Deno.readTextFileSync` up at call time (never cache the function). This PR does not add `onFileRead`; PR 20b owns it. -- Deno-specific types used in application signatures that this PR gives a home: `Deno.FileInfo` (`src/core/copy.ts:231,232`), `Deno.MakeTempOptions` (`src/core/temp.ts:58,71,76,79`, `src/core/temp-types.ts:11,13,14`, `monkey-patch.ts`), `Deno.ReadFileOptions` (`src/core/qualified-path.ts:69`), `Deno.FsFile` (none by name; 11 `Deno.open` sites use it structurally). +- Deno-specific types used in application signatures that this PR gives a home: `Deno.FileInfo` (`src/core/copy.ts:231,232`), `Deno.MakeTempOptions` (`src/core/temp.ts:58,71,76,79`, `src/core/temp-types.ts:11,13,14`, `monkey-patch.ts`), `Deno.FsFile` (none by name; 11 `Deno.open` sites use it structurally). `Deno.ReadFileOptions` gets no home — the layer's reads stay path-only (decided 2026-10-29: the codemod reports `qualified-path.ts`'s own `readTextFile`/`readTextFileSync` as collisions; no caller passes `options`; PR 10 drops the parameter in that hand-fix, `10-codemod-fs-core.md:59`; a `signal` would also break the path annotation, because an aborted read's `DOMException.message` is getter-only). - `Deno.errors.*` sites: 18 in `src/` (17 `instanceof` checks and one type alias), 1 in `package/src`, 4 in `tests/`: `NotFound` (`src/project/project-index.ts:250`, `src/command/render/pandoc.ts:1648`, `src/core/copy.ts:236`, `src/core/cache/cache.ts:161`, `src/core/path.ts:189`, `src/core/http.ts:64,230`, `src/core/jupyter/jupyter-shared.ts:107`, `src/core/typst.ts:82`, `package/src/common/validate-bundle.ts:72`), `PermissionDenied` (`src/project/project-context.ts:652,967,1072`; 967 is the type alias `InputWalkError`, rewritten by PR 12), `AddrInUse` (`src/core/port.ts:131`), and the five connection errors in `src/core/http.ts:231-235`. - `Deno.readDirSync`/`Deno.readDir` iteration: no caller uses `.next()`; `src/command/use/commands/brand.ts:706` spreads the result (works on an array); `src/tools/impl/chrome-headless-shell.ts:122` mutates the directory while iterating (safer with a snapshot array); emptiness checks (`src/core/path.ts:53`, `src/command/use/commands/template.ts:373`, `src/command/render/freeze.ts:250`) become a full read and sort, negligible for those directories. Order-dependent loops: `src/format/html/format-html-shared.ts:133` (bslib SCSS; PR 01 also sorts), `src/command/capabilities/capabilities.ts:75` (theme list), `src/command/call/build-ts-extension/cmd.ts:118` (entry list), `src/project/types/manuscript/manuscript-meca.ts:427` (MECA item order), `src/extension/extension.ts:296,477,683` (extension discovery order). @@ -217,8 +217,16 @@ Rule tables (exact): | `platform` | `platform.ts` | `build.os` → `os`, `build.arch` → `arch` (two-level member access) | — | Behaviour (forward): -1. A file that *assigns* to any `Deno.` (e.g. `Deno.realPathSync = …`, `Deno.readTextFileSync = …`) is skipped whole with `skipped: "assigns to Deno. at line N"`. - This keeps `src/core/deno/monkey-patch.ts` and (until PR 20b) `src/core/performance/metrics.ts` untouched automatically. +1. A file that writes to `Deno.*`, or uses the global `Deno` other than as `Deno.x`, is skipped whole with a `skipped` reason; rewriting any of it could turn a captured original (`const original = Deno.readTextFileSync`) into a layer call that forwards to the patched method, so the patch calls itself. + - `assigns to at line N`: `Deno.x` or, for nested rules, `Deno.a.b` is the target of an assignment (`=`, compound, logical), `++`/`--`, `delete`, a `for…of`/`for…in` head, or a destructuring pattern; the target or the `Deno` receiver may be wrapped in parentheses, `as`, ``, `!` or `satisfies` (`(Deno as any).x = …`), and `Deno["x"]` counts as `Deno.x`. + - `assigns to Deno.a via at line N`: for nested rules, `Deno.a` is the first argument of `Object.assign`/`defineProperty`/`defineProperties` or `Reflect.set`/`defineProperty`/`deleteProperty`. + - `uses Deno other than as Deno.x at line N`: the global `Deno` is aliased (`const D = Deno`), passed as an argument (`Object.defineProperty(Deno, …)`), spread, or destructured; `typeof Deno`, type positions (`Deno.FileInfo`) and declaration names (`type Deno = …`) are not uses. + - `reaches Deno through globalThis.Deno at line N`: `Deno` is reached through `globalThis`, `self` or `window`, with `.Deno` or `["Deno"]`. + - `listed in kCodemodPatchFiles`: the backstop, checked after the four reasons, for files known to patch `Deno`. + + The check is per file: what happens to `Deno` once it is passed elsewhere, or a patch in one file meeting a capture in another, is left to the rule that no code outside `src/deno_ral/` reaches `Deno` indirectly (README "How to execute a plan", and the review checklist in `_TEMPLATE.md`). + The guard keeps `src/core/deno/monkey-patch.ts` and (until PR 20b) `src/core/performance/metrics.ts` untouched automatically; `kCodemodPatchFiles` keeps both as a backstop. + Decided 2026-10-29: the guard is best-effort and frozen. Confirmed gaps (each reproduced on Deno 2.7.14: the file is rewritten, `skipped` stays undefined): a write through `Reflect.get(globalThis, "Deno")`, a computed key `globalThis[k]`, a comma-wrapped nested receiver `Object.assign((0, Deno.build), …)`. A new bypass spelling is handled by listing the patching file in `kCodemodPatchFiles`, not by a guard change. 2. **Alias reuse.** If the file already imports the export from the same layer module (`import { stat as fsStat } from ".../deno_ral/fs.ts"`), the replacement text is that local name (`Deno.stat(` → `fsStat(`), and no import is added. This makes the PRs 10–14 hand fixes (alias imports for collisions) stable under re-runs. 3. **Symbol-based collisions.** The replacement text `x` collides when some identifier `x` in the file that is a binding or a reference (declaration name, parameter, import specifier, shorthand property value, plain reference; property names `a.x`, `{ x: 1 }`, interface members are not) resolves, through the type checker, to a symbol whose declarations are not all import specifiers of export `x` from the same layer module. @@ -231,7 +239,8 @@ Behaviour (forward): Files are written only when at least one replacement was applied and `--dry-run` is off. The tool lives in `tools/`, outside the ratchet scopes, and may use `Deno.*`. -Behaviour (reverse): for each import specifier from a layer module of the selected rules whose exported name appears in the rule tables, every reference to its local binding (resolved by symbol, so aliases and shadowing are handled) is replaced: values by `Deno.` (`canonicalPathSync` → `Deno.realPathSync`, `env` → `Deno.env`, `os`/`arch` → `Deno.build.os`/`Deno.build.arch`), a call `args()`/`pid()` by `Deno.args`/`Deno.pid`, a type reference by `Deno.`. +Behaviour (reverse): for each import specifier from a layer module of the selected rules whose exported name appears in the rule tables, every reference to its local binding (resolved by symbol, so aliases and shadowing are handled) is replaced: values by `Deno.` (`env` → `Deno.env`, `os`/`arch` → `Deno.build.os`/`Deno.build.arch`), a call `args()`/`pid()` by `Deno.args`/`Deno.pid`, a type reference by `Deno.`. +`canonicalPathSync` is never reversed; it is reported in `left` with a hint to use `originalRealPathSync`, because stable branches patch `Deno.realPathSync` to `normalizePath`. If every reference was rewritten the specifier is removed (the declaration too when nothing is left); otherwise the specifier stays and is reported in `left`. Names outside the tables (`existsSync`, `safeRemoveSync`, `isNotFound`, …) are untouched: stable branches have `deno_ral/fs.ts` with the `safe*` helpers, but not `errors.ts`. @@ -246,16 +255,16 @@ Names outside the tables (`existsSync`, `safeRemoveSync`, `isNotFound`, …) are ### Acceptance criteria -- `cd tests && QUARTO_TESTS_NO_CONFIG=true ./run-tests.sh --agent $(find unit/ral/ -name '*.test.ts')` → `ok | 24 passed | 0 failed` (12 fs + 4 errors + 5 temp + the 3 existing cases in `$(find unit/ral/ -name '*.test.ts')`). -- `package/dist/bin/tools/x86_64/deno test --allow-all --import-map=src/import_map.json tools/codemod/` → `ok | 17 passed | 0 failed`; `run-tests.sh` does not discover it (it lives outside `tests/`). +- `cd tests && QUARTO_TESTS_NO_CONFIG=true ./run-tests.sh --agent $(find unit/ral/ -name '*.test.ts')` → `ok | 24 passed | 0 failed` (on Windows `23 passed | 1 ignored`: "safeRemoveDirSync with symlinks" is `ignore: isWindows`) (12 fs + 4 errors + 5 temp + the 3 existing cases in `$(find unit/ral/ -name '*.test.ts')`). +- `package/dist/bin/tools/x86_64/deno test --allow-all --import-map=src/import_map.json tools/codemod/` → `ok | 29 passed | 0 failed`; `run-tests.sh` does not discover it (it lives outside `tests/`). - `quarto run --dev tools/codemod-deno-ral.ts --rules fs,temp --dir src/core --dry-run` runs to completion, reports `src/core/deno/monkey-patch.ts` and `src/core/performance/metrics.ts` as skipped (only the first if PR 20b has landed), reports the collisions in `src/core/copy.ts` (`copyFileSync`, local function first referenced at line 60) and `src/core/qualified-path.ts` (`readTextFile` line 69, `readTextFileSync` line 73), never lists a file under `src/core/lib/`, and leaves the tree unchanged (`git status --short src` is empty). -- `quarto run --dev tools/check-runtime-boundary.ts` passes at the unchanged baseline. +- `quarto run --dev tools/check-runtime-boundary.ts` passes; the baseline's `tests` count rises by one (874 → 875), every other scope is unchanged. - `cd src && ../package/dist/bin/tools/x86_64/deno check --unstable-kv --unstable-ffi --import-map=import_map.json quarto.ts` passes. ## Plan Assumes PR 02 landed (`tools/check-runtime-boundary.ts` exporting `kSrcExclusions`, and `.github/workflows/runtime-boundary.yml`), `./configure.sh` has been run, and `package/dist/bin/tools/x86_64/deno` is the dev Deno (substitute the architecture directory present). -New unit tests use the layer (`deno_ral`) for their own file operations, never raw `Deno.*` (the ratchet has a `tests` scope); the error-predicate test constructs `Deno.errors` instances through `globalThis` for the same reason. +New unit tests use the layer (`deno_ral`) for their own file operations, never raw `Deno.*` (the ratchet has a `tests` scope); the error-predicate test reads `Deno.errors` directly behind a `typeof Deno` guard and counts in the `tests` baseline (reading it through `globalThis` would only hide it from the ratchet). ### Task 1: `src/deno_ral/errors.ts` @@ -263,7 +272,7 @@ New unit tests use the layer (`deno_ral`) for their own file operations, never r - Create: `src/deno_ral/errors.ts` - Test: `tests/unit/ral/errors.test.ts` -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** ```ts /* @@ -352,11 +361,11 @@ unitTest("errors - errorWithPath appends the path exactly once", async () => { }); ``` -- [ ] **Step 2: Run it and see it fail** +- [x] **Step 2: Run it and see it fail** Run: `cd tests && QUARTO_TESTS_NO_CONFIG=true ./run-tests.sh --agent unit/ral/errors.test.ts` Expected: fails with `Module not found "file:///.../src/deno_ral/errors.ts"`. -- [ ] **Step 3: Implement** +- [x] **Step 3: Implement** ```ts /* @@ -461,11 +470,11 @@ export function errorWithPath(e: Error, path: string): Error { } ``` -- [ ] **Step 4: Run the test** +- [x] **Step 4: Run the test** Run: `cd tests && QUARTO_TESTS_NO_CONFIG=true ./run-tests.sh --agent unit/ral/errors.test.ts` Expected: `ok | 4 passed | 0 failed` -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/deno_ral/errors.ts tests/unit/ral/errors.test.ts @@ -478,7 +487,7 @@ git commit -m "deno_ral/errors: runtime-neutral error predicates" - Create: `src/deno_ral/temp.ts` - Test: `tests/unit/ral/temp.test.ts` -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** The test uses only helpers that `deno_ral/fs.ts` already exports (`existsSync`, `safeRemoveSync`), so it does not depend on Task 3. @@ -593,11 +602,11 @@ unitTest("temp - retries only AlreadyExists, at most 5 times", async () => { }); ``` -- [ ] **Step 2: Run it and see it fail** +- [x] **Step 2: Run it and see it fail** Run: `cd tests && QUARTO_TESTS_NO_CONFIG=true ./run-tests.sh --agent unit/ral/temp.test.ts` Expected: fails with `Module not found "file:///.../src/deno_ral/temp.ts"`. -- [ ] **Step 3: Implement** +- [x] **Step 3: Implement** ```ts /* @@ -674,11 +683,11 @@ export function makeTempFile(options?: TempOptions): Promise { } ``` -- [ ] **Step 4: Run the test** +- [x] **Step 4: Run the test** Run: `cd tests && QUARTO_TESTS_NO_CONFIG=true ./run-tests.sh --agent unit/ral/temp.test.ts` Expected: `ok | 5 passed | 0 failed` -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/deno_ral/temp.ts tests/unit/ral/temp.test.ts @@ -691,7 +700,7 @@ git commit -m "deno_ral/temp: makeTemp* retrying on AlreadyExists only" - Modify: `src/deno_ral/fs.ts` (append after `ensureUserWritable`, line 206) - Test: `tests/unit/ral/fs.test.ts` -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** ```ts /* @@ -898,11 +907,11 @@ unitTest("fs - open/create expose streams and close", withDir(async (dir) => { })); ``` -- [ ] **Step 2: Run it and see it fail** +- [x] **Step 2: Run it and see it fail** Run: `cd tests && QUARTO_TESTS_NO_CONFIG=true ./run-tests.sh --agent unit/ral/fs.test.ts` Expected: fails at type-check with `Module '"../../../src/deno_ral/fs.ts"' has no exported member 'readTextFileSync'` (and others). -- [ ] **Step 3: Implement (append to `src/deno_ral/fs.ts`)** +- [x] **Step 3: Implement (append to `src/deno_ral/fs.ts`)** Add `import { errorWithPath } from "./errors.ts";` to the imports at the top (keep the existing `fromFileUrl`, `resolve`, `SEP` and `originalRealPathSync` imports), then append: @@ -1225,13 +1234,13 @@ Then make the edits to the existing part of the file: The `originalRealPathSync` import (line 11) stays: `canonicalPathSync` uses it until PR 25. -- [ ] **Step 4: Run the tests** +- [x] **Step 4: Run the tests** Run: `cd tests && QUARTO_TESTS_NO_CONFIG=true ./run-tests.sh --agent unit/ral/fs.test.ts $(find unit/ral/ -name '*.test.ts')` Expected: `ok | 15 passed | 0 failed` (12 new + the 3 existing cases in `unit/ral/safe-*.test.ts`). Run: `cd src && ../package/dist/bin/tools/x86_64/deno check --unstable-kv --unstable-ffi --import-map=import_map.json quarto.ts` Expected: no errors. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/deno_ral/fs.ts tests/unit/ral/fs.test.ts @@ -1249,7 +1258,7 @@ The codemod test lives under `tools/`, not `tests/unit/`: `run-tests.sh` only di It runs in one dedicated CI step (Task 5) and locally with plain `deno test`. Being outside the ratchet scopes, it may use `Deno.*`. -- [ ] **Step 1: Write the fixtures** +- [x] **Step 1: Write the fixtures** Each fixture is copied by the test to `/src/core/.ts`, next to a (non-existent) `/src/deno_ral/`, so layer imports read `../deno_ral/.ts`. Fixture files are inputs to a tool, not modules of the build, and `deno check` never sees them (they are not imported). @@ -1560,7 +1569,7 @@ export function stop() { } ``` -- [ ] **Step 2: Write the failing test** +- [x] **Step 2: Write the failing test** `tools/codemod/codemod-deno-ral.test.ts`: @@ -1768,11 +1777,11 @@ Deno.test("codemod - collectFiles applies the skip list", () => { }); ``` -- [ ] **Step 3: Run it and see it fail** +- [x] **Step 3: Run it and see it fail** Run: `package/dist/bin/tools/x86_64/deno test --allow-all --import-map=src/import_map.json tools/codemod/` Expected: fails with `Module not found "file:///.../tools/codemod-deno-ral.ts"`. -- [ ] **Step 4: Implement `tools/codemod-deno-ral.ts`** +- [x] **Step 4: Implement `tools/codemod-deno-ral.ts`** ```ts /* @@ -2384,7 +2393,7 @@ if (import.meta.main) { } ``` -- [ ] **Step 5: Run the test** +- [x] **Step 5: Run the test** Run: `package/dist/bin/tools/x86_64/deno test --allow-all --import-map=src/import_map.json tools/codemod/` Expected: `ok | 17 passed | 0 failed`. The first run downloads `ts-morph@28.0.0` into the Deno npm cache (network needed once). @@ -2392,7 +2401,7 @@ The fixtures and the logic were validated before this plan was written by runnin If a fixture comparison fails on whitespace only, fix the *expected* file to match the tool's deterministic output and say so in the commit message; the contract is "deterministic", not "pretty" (PRs 10–14 run `deno fmt` after the codemod). -- [ ] **Step 6: Dry-run against the real tree** +- [x] **Step 6: Dry-run against the real tree** Run: `quarto run --dev tools/codemod-deno-ral.ts --rules fs,temp --dir src/core --dir src/quarto-core --dir src/config --dry-run | grep -E 'skipped|collision|Summary'` Expected (measured with the Node port on `b86f98d`; about one second per rewritten file, because each file gets its own type checker): ``` @@ -2406,7 +2415,7 @@ Summary: 205 files scanned, 56 would be rewritten, 153 replacements, 2 with coll (205, not 264: `src/core/lib` is skipped.) `git status --short src` shows no modified files. -- [ ] **Step 7: Commit** +- [x] **Step 7: Commit** ```bash git add tools/codemod-deno-ral.ts tools/codemod @@ -2418,7 +2427,7 @@ git commit -m "tools: ts-morph codemod for Deno.* <-> deno_ral rewrites, with fi **Files:** - Modify: `deno.lock`, `.github/workflows/runtime-boundary.yml`, `dev-docs/checklist-backport-a-pr.md`, `.claude/rules/typescript/deno-ral.md`, `.claude/rules/typescript/deno-essentials.md`, `news/changelog-1.11.md` -- [ ] **Step 1: Lockfile** +- [x] **Step 1: Lockfile** PR 00b commits `deno.lock` and runs Deno with `--frozen`, so a new `npm:` specifier must be recorded. Run: @@ -2430,7 +2439,7 @@ git diff --stat deno.lock Expected: `deno.lock` gains `npm:ts-morph@28.0.0` and its dependencies (`@ts-morph/common`, `code-block-writer`, `typescript`, …); nothing else changes. (If PR 00b has not landed yet, `deno.lock` does not exist in the tree: skip this step; PR 00b's lockfile then picks the specifier up when it is generated.) -- [ ] **Step 2: CI step** +- [x] **Step 2: CI step** In `.github/workflows/runtime-boundary.yml` (PR 02's job, Linux, after its `configure.sh` step and before the ratchet step), add: @@ -2442,7 +2451,7 @@ In `.github/workflows/runtime-boundary.yml` (PR 02's job, Linux, after its `conf This is the only place CI runs the codemod tests; `tests/run-tests.sh` never sees them. If PR 02b landed first, the step already exists (it runs `tools/codemod/` for 02b's own test); leave it as it is. -- [ ] **Step 3: Backport checklist** +- [x] **Step 3: Backport checklist** In `dev-docs/checklist-backport-a-pr.md`, after the bullet "Cherry-pick the commits from the development branch" (and its two sub-bullets), add: @@ -2467,12 +2476,12 @@ In `dev-docs/checklist-backport-a-pr.md`, after the bullet "Cherry-pick the comm git worktree remove --force "$w" ``` - Then, on the stable branch: `git apply -3 "$w.patch" && git commit -C "$c"`. The tool prints `left:` for any layer name it cannot express (for example `args` used without a call) and never rewrites `deno_ral/errors.ts` predicates: grep the patch for `deno_ral/errors.ts` and `isNotFound(` and turn them into `instanceof Deno.errors.X` by hand. `canonicalPathSync` reverses to `Deno.realPathSync`, which on branches that still load `core/deno/monkey-patch.ts` is the normalising patch: use `originalRealPathSync` there instead. + Then, on the stable branch: `git apply -3 "$w.patch" && git commit -C "$c"`. The tool prints `left:` for any layer name it cannot express (for example `args` used without a call) and never rewrites `deno_ral/errors.ts` predicates: grep the patch for `deno_ral/errors.ts` and `isNotFound(` and turn them into `instanceof Deno.errors.X` by hand. The tool reports `canonicalPathSync` as `left` (stable branches patch `Deno.realPathSync`): import `originalRealPathSync` from `deno_ral/original-real-path.ts` there. ```` Check the procedure on a real commit before committing the doc: pick the PR 10 commit "core: codemod Deno fs/temp calls to deno_ral (mechanical)" once it exists, or this PR's own `src/deno_ral/fs.ts` change otherwise, and confirm `git apply --check` succeeds on `v1.10`. -- [ ] **Step 4: Rules docs** +- [x] **Step 4: Rules docs** In `.claude/rules/typescript/deno-ral.md`, replace the `fs.ts` row and add rows for `temp.ts` and `errors.ts`: @@ -2508,18 +2517,18 @@ Deno.readTextFileSync(path); Deno.cwd(); ``` -- [ ] **Step 5: Changelog** +- [x] **Step 5: Changelog** Open the PR as a draft to get its number `N`, then add under `## Other fixes and improvements` in `news/changelog-1.11.md`: ```markdown -- ([#N](https://github.com/quarto-dev/quarto-cli/pull/N)): Directories are now read in a fixed order (sorted by name, uppercase before lowercase) on every platform. +- ([#N](https://github.com/quarto-dev/quarto-cli/pull/N)): Directories are now read in a fixed order (sorted by name in character-code order, not by locale) on every platform. Extension discovery, the theme list of `quarto capabilities`, and the items of a MECA bundle no longer depend on the file system's listing order; when two extensions share an id, which one wins can differ from earlier versions. ``` (The behaviour reaches users when PRs 11–13 move the callers; they land before the `v1.11` cut, decision 2.) -- [ ] **Step 6: Run the Verification list, then commit** +- [x] **Step 6: Run the Verification list, then commit** ```bash git add deno.lock .github/workflows/runtime-boundary.yml dev-docs/checklist-backport-a-pr.md .claude/rules/typescript/deno-ral.md .claude/rules/typescript/deno-essentials.md news/changelog-1.11.md @@ -2528,10 +2537,10 @@ git commit -m "codemod CI step, backport step, rules docs and changelog for deno ## Verification -- `cd tests && QUARTO_TESTS_NO_CONFIG=true ./run-tests.sh --agent $(find unit/ral/ -name '*.test.ts')` → `ok | 24 passed | 0 failed`. -- `package/dist/bin/tools/x86_64/deno test --allow-all --import-map=src/import_map.json tools/codemod/` → `ok | 17 passed | 0 failed`. +- `cd tests && QUARTO_TESTS_NO_CONFIG=true ./run-tests.sh --agent $(find unit/ral/ -name '*.test.ts')` → `ok | 24 passed | 0 failed` (on Windows `23 passed | 1 ignored`: "safeRemoveDirSync with symlinks" is `ignore: isWindows`). +- `package/dist/bin/tools/x86_64/deno test --allow-all --import-map=src/import_map.json tools/codemod/` → `ok | 29 passed | 0 failed`. - `cd src && ../package/dist/bin/tools/x86_64/deno check --unstable-kv --unstable-ffi --import-map=import_map.json quarto.ts` → exit 0. -- `quarto run --dev tools/check-runtime-boundary.ts` → passes; `git diff --stat tools/runtime-boundary-baseline.json` is empty (no callers moved; the new tests use the layer, so the `tests` count does not rise). +- `quarto run --dev tools/check-runtime-boundary.ts` → passes; `git diff upstream/main...HEAD -- tools/runtime-boundary-baseline.json` (three dots: against the merge base, so it holds whether or not the branch is rebased onto PR 02d) shows only `tests` 874 → 875 (the `typeof Deno` guard in `errors.test.ts`, Review of Tasks 1–3). - The Task 4 step 6 dry run → the lines shown there; `git status --short src` clean. - `cd tests && QUARTO_TESTS_NO_CONFIG=true ./run-tests.sh --agent smoke/render/render-html.test.ts unit/ral/safe-remove-dir.test.ts smoke/check/check-info-project.test.ts` → pass (`safeRemoveDirSync` now resolves through `canonicalPathSync`). @@ -2546,8 +2555,8 @@ git commit -m "codemod CI step, backport step, rules docs and changelog for deno - [ ] The codemod never mutates the AST; edits are applied from the end; a file is written only when something changed and `--dry-run` is off. - [ ] Collisions are symbol-based: an existing import of the same export from the same layer module never collides; an existing alias is reused; `import type` declarations stay type-only; aliases survive a merge. - [ ] `kCodemodExclusions` is `kSrcExclusions` plus `src/core/lib` and `src/resources/tools/ast-diagram`; the codemod test never runs under `run-tests.sh`. -- [ ] `--reverse` round-trips the basic fixtures and reports, rather than guesses, uses it cannot express. -- [ ] No file outside `src/deno_ral/`, `tools/`, `tests/unit/ral/`, the two rules docs, the backport checklist, the workflow, the changelog and `deno.lock` changed; the ratchet baseline is unchanged. +- [ ] `--reverse` round-trips the basic fixtures and reports, rather than guesses, uses it cannot express; `canonicalPathSync` is always reported, never reversed. +- [ ] No file outside `src/deno_ral/`, `tools/`, `tests/unit/ral/`, the two rules docs, the backport checklist, the workflow, the changelog and `deno.lock` changed; the ratchet baseline changes only `tests` (874 → 875). ## Risks and rollback @@ -2569,3 +2578,133 @@ git commit -m "codemod CI step, backport step, rules docs and changelog for deno None. Review items closed here: `FileInfo` type break (retyped helpers), alias/`import type` merge, non-idempotent collisions (symbol-based, alias reuse), `realPathSync` footgun (dropped; `canonicalPathSync` here, not in PR 25), skip list vs `kSrcExclusions` (imported, plus `src/core/lib` and `ast-diagram`), `npm:ts-morph` in every unit run (moved to `tools/codemod/` with one CI step), `@std/fs` facts (corrected in Context), temp retry scope (AlreadyExists only), `readDirSync` order (documented, changelog), `instanceof` count (18 in `src/`, 1 in `package/src`, 4 in `tests/`). The `onFileRead` hook for `metrics.ts` is PR 20b's, not this PR's. + +## Execution log + +Executed 2026-10-28 on branch `rt/09-ral-fs-and-temp-layer` (Windows 11, pwsh + `tests/run-tests.ps1 --agent`; the Windows equivalent of the plan's `./run-tests.sh` commands). + +### Step 0: plan refresh against this worktree + +- Branch tip `364ee3bae` (fetched upstream/main); dev build present (`package/dist/bin/quarto.cmd`, `package/dist/bin/tools/x86_64/deno.exe`); `deno.lock` committed. +- PR 02 present: `tools/check-runtime-boundary.ts` exports `kSrcExclusions` at line 158; `.github/workflows/runtime-boundary.yml`, `tests/asserts.ts` exist. +- Every `path:line` the plan cites was re-grepped. Two Context citations drifted (content identical, line moved): `src/core/cache/cache.ts:161` is now line 186; `src/core/path.ts:189` is now line 197. `src/deno_ral/fs.ts` is 215 lines (the plan says 206): the Task 3 append point "after `ensureUserWritable`, line 206" is the end of the file, line 214, and the `safeRemoveDirSync` block of lines 147-163 sits at lines 143-169. The `@std/fs` `walk.ts:922` citation cannot be re-grepped (`fs/` maps to the remote `jsr:/@std/fs@1.0.16/`); the `Deno.realPathSync(` grep over `src/` confirms no application caller. +- README "Open decisions with an owner": no row names PR 09; the plan's "Decisions needed" is none. +- Dependencies PR 02 (#15135) and PR 02c (#15153) are merged. The plan defect where contract tests imported `testing/asserts` is fixed: the plan's `tests/unit/ral` tests import `../../asserts.ts`; only the `tools/codemod` test keeps `testing/asserts` (outside the ratchet `tests` scope, by design). + +### Task 1: `src/deno_ral/errors.ts` + +- Step 2 (RED): `$env:QUARTO_TESTS_NO_CONFIG="true"; pwsh -File run-tests.ps1 --agent unit/ral/errors.test.ts` → + + ``` + TS2307 [ERROR]: Cannot find module 'file:////src/deno_ral/errors.ts'. + error: Type checking failed. + ``` +- Step 4 (GREEN): same command → + + ``` + ok | 4 passed | 0 failed (88ms) + ``` +- Step 5: commit `c40c547ff` "deno_ral/errors: runtime-neutral error predicates" (2 files, 184 insertions). + +### Task 2: `src/deno_ral/temp.ts` + +- Step 2 (RED): `$env:QUARTO_TESTS_NO_CONFIG="true"; pwsh -File run-tests.ps1 --agent unit/ral/temp.test.ts` → + + ``` + TS2307 [ERROR]: Cannot find module 'file:////src/deno_ral/temp.ts'. + error: Type checking failed. + ``` +- Step 4 (GREEN): same command → + + ``` + ok | 5 passed | 0 failed (78ms) + ``` +- Step 5: commit `e43dc49d1` "deno_ral/temp: makeTemp* retrying on AlreadyExists only" (2 files, 180 insertions). + +### Task 3: `src/deno_ral/fs.ts` additions + +- Step 2 (RED): `$env:QUARTO_TESTS_NO_CONFIG="true"; pwsh -File run-tests.ps1 --agent unit/ral/fs.test.ts` → 23 type errors, `TS2305: Module '"../../../src/deno_ral/fs.ts"' has no exported member 'readTextFileSync'` (and the other new names), as expected. +- Step 3: the plan's verbatim block needed two adaptations against this worktree's vendored Deno 2.7.14 types (a defect in the plan's Step 3 code): `Deno.SymlinkOptions.type` is required, so `symlink[Sync]` forwards an options object only when a `type` is given (a bare `symlinkSync(a, b)` stays options-free, matching existing callers); `Deno.truncateSync` takes `string` only, so a URL goes through `toPathString`. Both recorded in README "Open decisions with an owner" for PR 27. +- Step 4 (GREEN): `$env:QUARTO_TESTS_NO_CONFIG="true"; pwsh -File run-tests.ps1 --agent unit/ral/*.test.ts` (pwsh passes the glob through; Deno expands it) → + + ``` + ok | 23 passed | 0 failed | 1 ignored (6s) + ``` + + The plan expected `15 passed` (12 new + 3 `safe-*`); the ral suite now also holds errors (4) and temp (5) from Tasks 1–2, so 12 + 3 + 4 + 5 = 24 = 23 passed + 1 ignored. +- Step 4, `deno check`: the plan's command fails on Deno 2.7 with `error: Requires import access to "cdn.skypack.dev:443", run again with the --allow-import flag` (a remote import in `src/core/yaml.ts:31`). With `--allow-import` added → `Check quarto.ts`, no errors. Recorded in README for every plan whose verification runs this command. +- Step 5: commit `9d3b1ca00` "deno_ral/fs: plain filesystem calls, canonicalPathSync, layer-typed helpers" (2 files, 516 insertions, 15 deletions). + +### Review of Tasks 1–3 + +- `tests/unit/ral/fs.test.ts` reuses `withTempDir` from `tests/utils.ts` instead of its own `withDir`; the duplicated `codeError`/`withCode` helpers of `temp.test.ts` and `errors.test.ts` became `errorWithCode` in `tests/unit/ral/utils.ts`. +- `errors.test.ts` reads `Deno.errors` directly behind a `typeof Deno` guard instead of through `globalThis` (which only hid the access from the ratchet); `tools/runtime-boundary-baseline.json` `tests` 874 → 875 with `--accept-increase`. The PR description lists that occurrence. +- Plan and PR numbers removed from the comments in `src/deno_ral/{fs,errors,temp}.ts` and the `fs.test.ts` header; README "Decisions and conventions" records both rules. +- `$env:QUARTO_TESTS_NO_CONFIG="true"; pwsh -File run-tests.ps1 --agent unit/ral/*.test.ts` → `ok | 23 passed | 0 failed | 1 ignored`. +- Post-commit review found `annotate` in `src/deno_ral/fs.ts` defeated `errorWithPath`'s idempotence for file URLs: it passed `toPathString(path)` (filesystem path) while the still-active read-annotation wrapper appends `"Path: " + path` (the URL href), so a second `Path:` line was added. Commit `4bdd5f4ca` switches to `String(path)` and adds missing-file URL cases to `fs.test.ts` (the new assertion fails on the old code: two `Path:` lines). +- The `errorWithPath` docstring and the `annotate` comment no longer name the read-annotation wrapper's file: the wording holds while the wrapper is active and after its removal, so the removal-time comment sweep has two fewer hits (the remaining ones are in `temp.ts` and `fs.test.ts`). `String(path)` is unchanged, so idempotence with the active wrapper is unaffected. + +### Task 4: the codemod and its fixtures + +- Steps 1–3 (fixtures, failing test, RED) were ticked with files on disk at resume; verified the RED via the module-not-found state and the ticked checkboxes. +- Step 4: `tools/codemod-deno-ral.ts` transcribed verbatim from the plan (607 lines). The prior session's write had been interrupted; rewrote the whole file from the plan block rather than splice mid-function. +- Step 5 (GREEN): `package/dist/bin/tools/x86_64/deno test --allow-all --import-map=src/import_map.json tools/codemod/` initially failed 9/17 with CRLF mismatches — the extracted fixtures and test file had been written CRLF, while the tool emits LF. Normalized all `tools/codemod/**` files and `tools/codemod-deno-ral.ts` to LF (repo standard for `tools/`; `tests/unit/ral/fs.test.ts` is `i/lf w/lf`). Re-run: `ok | 17 passed | 0 failed`, matching the plan exactly. +- Step 6 (dry-run): `package/dist/bin/quarto.cmd run --dev tools/codemod-deno-ral.ts --rules fs,temp --dir src/core --dir src/quarto-core --dir src/config --dry-run`. All 5 collision/skipped lines, 205 files, 153 replacements, 2 collisions, 2 skipped match the plan. One delta: `57 would be rewritten` vs the plan's `56` — the plan's figure was measured on `b86f98d`; upstream `d0bc2d947` (#15128, on this branch's base) added `Deno.readTextFileSync` to `src/core/bibliography.ts`, which at `b86f98d` used only `Deno.cwd()` (one extra file), not an error. Tasks 1–3 touch only `src/deno_ral` and `tests/unit/ral`. `git status --short src` empty (dry-run wrote nothing). +- Step 7: commit `c5af406bc` "tools: ts-morph codemod for Deno.* <-> deno_ral rewrites, with fixtures" (32 files, 1000 insertions). + +### Review of Task 4: the write guard + +Seven review rounds on the forward guard that skips files patching `Deno.*` (forward behaviour item 1). Each finding was reproduced before the fix, and each new fixture is rewritten by the previous guard and skipped unchanged by the new one. + +- `61947b5f9`: the guard matched only a plain `=` on `Deno.x`. Compound and logical assignments, `++`/`--`, `delete`, and writes to the nested `Deno.build.os` node the platform rule replaces now skip the file. The same commit fixes reverse mode: `export { os }` keeps its import and is reported in `left`. +- `0b4a15b83`: destructuring targets (`({ readTextFileSync: Deno.readTextFileSync } = o)`, `[Deno.build.os] = …`) and `for…of`/`for…in` heads. `literal-not-write` pins that a literal that is not a pattern is still rewritten. +- `81d6aed64`: writes through `as`, ``, `!` or `satisfies` on the target or the `Deno` receiver, and `Deno["x"]`. The receiver case shows why the guard exists: with `(Deno as any).readTextFileSync = …`, an earlier `const original = Deno.readTextFileSync` became the layer wrapper, which forwards to the patched method, so the patch called itself. +- `94753630a`: `Object.defineProperty(Deno, …)` hid the same recursion, and so did an alias or `globalThis.Deno`. Rather than list every mutating call, the guard now skips a file where the global `Deno` is used other than as `Deno.x` (alias, argument, spread, destructuring source) or reached through `globalThis`/`self`/`window`, which is the rule README "How to execute a plan" already applies to `src/`. `typeof Deno`, type positions and declaration names are exempt; the first draft flagged `type Deno = …` in `shadowed-deno`. The `Object`/`Reflect` mutator list remains only for nested `Deno.a` targets (`Object.assign(Deno.build, …)`). +- `842260c91`: forward behaviour item 1 now lists the four `skipped` reasons and the per-file limit. The Step 4 code block still shows the guard as first transcribed. +- `bf7d2da18` (fifth round): a shorthand `{ Deno }` was exempt as a declaration name, so `const holder = { Deno }` followed by a patch through `holder.Deno` was rewritten; it is now a use (`write-shorthand`). `(Object.assign)(Deno.build, …)` escaped the mutator list, whose callee and owner are now unwrapped (`write-object-assign-paren`). `kMutators` became a `Map`: an owner named `constructor` found `Object` through the prototype and the codemod threw (`mutator-inherited-key`). `write-define-property-paren` pins that `(Object.defineProperty)(Deno, …)` was already skipped as an argument use. +- `9472526fd`: `Object["assign"](Deno.build, …)` also escaped the mutator list, which matched only a dotted callee (`write-object-assign-element`). The callee may now be an element access with a string literal key, read by the `memberOf` helper that `globalObjectDeno` now shares. +- `3c973e12b` (sixth round): `` globalThis[`Deno`] `` and `` Object[`assign`] `` escaped both guards, because `memberOf` accepted only string-literal keys. A template literal without substitutions now counts as a static key, like a string literal (`write-global-this-template`, `write-object-assign-template`). +- `428714395` (seventh round): a parenthesized key (`globalThis[("Deno")]`, `Object[("assign")]`) escaped them the same way. `memberOf` now unwraps the key through the transparent wrappers already stripped from the receiver and the callee (`write-global-this-paren-key`, `write-object-assign-paren-key`). +- After each round, `package/dist/bin/tools/x86_64/deno test --allow-all --import-map=src/import_map.json tools/codemod/` passed (27 tests after the last round), and the forward dry run over `src/` with all rules (`--rules fs,temp,env,process,platform --dir src --dry-run`) was unchanged: 624 files scanned, 224 would be rewritten, 816 replacements, 18 with collisions, 2 skipped (`src/core/deno/monkey-patch.ts`, `src/core/performance/metrics.ts`). + +### Branch review (2026-10-29) + +- Commit "tools: codemod-deno-ral --reverse reports canonicalPathSync instead of mapping it to Deno.realPathSync": `--reverse` no longer maps `canonicalPathSync` to `Deno.realPathSync` (the monkey-patched `normalizePath` on stable branches); it reports the binding in `left` with a hint naming `originalRealPathSync`, before the re-export check so a re-exported name keeps the hint. Pinned by the `reverse-realpath` and `reverse-realpath-reexport` fixtures. TDD: the new test failed first with a type error (`kCanonicalReverseHint` not exported). Suite after: `ok | 28 passed | 0 failed`. CLI dry-run on the fixture printed `:2: left: 'canonicalPathSync' (deno_ral/fs.ts) is not Deno.realPathSync on branches that load core/deno/monkey-patch.ts; use originalRealPathSync from deno_ral/original-real-path.ts; edit by hand` and `Summary: 1 files scanned, 1 would be rewritten, 1 replacements, 0 with collisions, 0 skipped, 1 left`. Plan text updated at Behaviour (reverse), the Task 5 backport text and the review checklist. +- Commit "tools: codemod-deno-ral freezes the write guard and skips the known patch files by path": the write guard is documented as best-effort and frozen, and `kCodemodPatchFiles` (`src/core/deno/monkey-patch.ts`, `src/core/performance/metrics.ts`) is a backstop checked after the guard, so a caught file keeps the guard's reason. Pinned by `patch-file-backstop.input.ts` (a `Reflect.get` write the guard misses) run under both listed paths. TDD: the test failed first with `result.skipped` `undefined` (the file was rewritten). Probe before documenting the gaps (Deno 2.7.14, `codemodFile` dry run): all three reproduced — `Reflect.get(globalThis, "Deno")` → skipped undefined, 1 replacement; computed key `globalThis[k]` → skipped undefined, 1 replacement; comma-wrapped `Object.assign((0, Deno.build), …)` → skipped undefined, 1 replacement. Suite after: `ok | 29 passed | 0 failed`. Full dry run (`--rules fs,temp,env,process,platform --dir src --dry-run`) unchanged: `src/core/deno/monkey-patch.ts: skipped: assigns to Deno.realPathSync at line 17`, `src/core/performance/metrics.ts: skipped: assigns to Deno.readTextFileSync at line 23`, `Summary: 624 files scanned, 224 would be rewritten, 816 replacements, 18 with collisions, 2 skipped`; `git status --short src` empty. README "Open decisions with an owner" gained the PR 20b and PR 25 rows that remove the two entries later. +- Commit "runtime-migration docs: PR 09 plan matches its execution; tracking section is tool-neutral": private details (tracker IDs, maintainer-local worktree paths, tracker instructions) left the committed docs; README progress tracking is tool-neutral. Acceptance criteria, Verification and the review checklist now match what was executed: ral suite `ok | 24 passed | 0 failed` (on Windows `23 passed | 1 ignored`), codemod suite `ok | 29 passed | 0 failed`, and the ratchet baseline check is `git diff upstream/main...HEAD -- tools/runtime-boundary-baseline.json` showing only `tests` 874 → 875. RC1 (ReadFileOptions) was closed without a code change: the codemod reports `qualified-path.ts`'s own `readTextFile`/`readTextFileSync` as collisions, no caller passes `options`, and an aborted read's `DOMException.message` is getter-only, so a `signal` in the layer would break `errorWithPath`; only the Context line records the decision. Step 3 checks: both greps printed nothing; baseline diff is the one hunk above; suites re-run after the edits: codemod `ok | 29 passed | 0 failed`, unit ral `ok | 23 passed | 0 failed | 1 ignored`. + +### Rebase onto main (#15218) + +- `git fetch upstream && git rebase upstream/main`: upstream/main was one commit ahead of the branch base (`623cd99e8`, PR 02d); 36 commits replayed. Only `dev-docs/runtime-migration/HANDOFF.md` conflicted — the status line and "How to resume" step 5, which both sides edited; resolved to "PR 02 (#15135), 02c (#15153) and 02d (#15218) are merged, 09 (the `src/deno_ral/` fs, temp and errors layer) is in progress, and the next to run are 00a, then 00b" and "PR 02, 02c and 02d are merged, 09 is in progress; …". README auto-merged. +- First codemod run after the rebase: `FAILED | 15 passed | 14 failed`, every failure a pure line-ending diff. Replaying the branch re-checked `tools/codemod/**` out, and `core.autocrlf=true` converted the LF blobs to CRLF working-tree files (`git ls-files --eol` showed `i/lf w/crlf`), while the fixture tests compare bytes verbatim and ts-morph inserts lines as `\n`. Fixed by pinning `tools/codemod/** text eol=lf` in `.gitattributes` (commit "tools: pin the codemod test files to LF checkouts") and re-checking out: all 76 files now `i/lf w/lf`. +- `package/dist/bin/quarto.cmd run --dev tools/check-runtime-boundary.ts`: + + ``` + scope Deno.* calls std-import files + src 992 (baseline 992) 49 (baseline 49) + src-js 7 (baseline 7) 0 (baseline 0) + resources 12 (baseline 12) 1 (baseline 1) + package/src 240 (baseline 240) 3 (baseline 3) + tests 875 (baseline 875) 176 (baseline 176) + OK: no count exceeds the baseline. + ``` +- `git diff upstream/main...HEAD -- tools/runtime-boundary-baseline.json`: one hunk, `tests` `"denoCalls": 874,` → `"denoCalls": 875,`. +- `package/dist/bin/tools/x86_64/deno test --allow-all --import-map=src/import_map.json tools/codemod/` → `ok | 29 passed | 0 failed (705ms)`. +- `cd src && ../package/dist/bin/tools/x86_64/deno check --unstable-kv --unstable-ffi --import-map=import_map.json quarto.ts` fails on Deno 2.7 with the import-access error recorded above; with `--allow-import` (the recorded workaround) → `Check quarto.ts`, exit 0. +- ral suite: `run-tests.ps1 --agent unit/ral/` is rejected ("Only .ts, or .qmd, .md and .ipynb passed to smoke-all.test.ts are accepted") and `run-tests.sh` on Windows looks for `package/dist/bin/tools/deno`, which does not exist (the binary is under `tools/x86_64/`), so the five test files were passed explicitly: `cd tests && pwsh -Command '$env:QUARTO_TESTS_NO_CONFIG="true"; .\run-tests.ps1 --agent unit/ral/errors.test.ts unit/ral/fs.test.ts unit/ral/safe-exists-file.test.ts unit/ral/safe-remove-dir.test.ts unit/ral/temp.test.ts'` → `ok | 23 passed | 0 failed | 1 ignored` ("safeRemoveDirSync with symlinks" is `ignore: isWindows`). +- Whole-branch greps (README "How to execute a plan", run against `upstream/main...HEAD` after the rebase): + - `git diff upstream/main...HEAD -- src tests ':!src/deno_ral' | rg -nP '^\+.*(\bglobalThis\b|\bDeno\b(?!\??\.))'` → one hit, `if (typeof Deno === "undefined") return;` (`typeof Deno` is exempt). + - `git diff upstream/main...HEAD -- src tests | rg -n '^\+.*(//|\*).*\bPR [0-9]'` → no hits. + - `git diff upstream/main...HEAD -- tests | rg -n '^\+\s*(export )?(async )?function \w+'` → one hit, `errorWithCode` in `tests/unit/ral/utils.ts`, the shared helper of the two test files that use it (`temp.test.ts`, `errors.test.ts`); no same-purpose helper in `tests/utils.ts` or `tests/verify.ts`. + +### Task 5: CI step, backport checklist, rules docs (2026-10-29, steps 1–4) + +- Step 1 (lockfile): skipped per the step's own parenthetical — `deno.lock` is not in the tree until PR 00b (untracked and ignored); the lockfile picks up `npm:ts-morph@28.0.0` when 00b generates it. +- Step 2 (CI step): the "Codemod contract test (tools/codemod)" step sits in `.github/workflows/runtime-boundary.yml` between "Configure Quarto" and "Check runtime boundary". The workflow's `paths` filter does not list `tools/codemod/**`, so a codemod-only change does not trigger the job; every migration PR touches `src/**`, so the step runs when it matters. +- Step 3 (backport checklist): the procedure was checked on a real commit before committing the doc — this PR's own `fs.ts` commit `f01436670` against `upstream/v1.10` (`8f4600111`), in two scratch worktrees. + - Defect found and fixed in the doc: at the base revision the codemod crashes on Added files (`Deno.readTextFileSync` on `tests/unit/ral/fs.test.ts`, which does not exist at `$c^`), because `rev()` ran over `--diff-filter=AM` in both passes. `rev()` now takes the filter: `rev M` at the base, `rev AM` after the checkout. + - Reverse on the new revision: 49 replacements in `fs.test.ts`, one `left` (`canonicalPathSync`, reported with the `originalRealPathSync` hint), matching the hand-edits the doc describes; `fs.ts` itself gets 0 replacements (the layer implements on `Deno.*` directly). + - `git apply --check` exits 0 on `v1.10`, and `git apply -3 --check` exits 0 (the three-way falls back to direct application for the new test file). +- Step 4 (rules docs): `deno-ral.md` gained the fuller `fs.ts` row, the `temp.ts` and `errors.ts` rows, and the "Moving code off `Deno.*`" subsection. In `deno-essentials.md` the example block is the plan's runtime-layer one; its intro line changed with it ("Use Deno APIs directly:" would contradict the new example). +- Step 5 (changelog): the PR opened as draft #15223 and the plan's line went into `news/changelog-1.11.md` under `## Other fixes and improvements` with that number. +- Step 6 (verification, re-run after the Task 5 commits): ral suite `ok | 23 passed | 0 failed | 1 ignored` (Windows), codemod suite `ok | 29 passed | 0 failed`, boundary check `OK: no count exceeds the baseline` (the baseline diff is still only `tests` 874 → 875), `deno check --allow-import` exit 0. The Task 4 step 6 dry run and the smoke trio stand from the post-rebase run; no code changed since. diff --git a/dev-docs/runtime-migration/HANDOFF.md b/dev-docs/runtime-migration/HANDOFF.md index d644f82c58..458f2fd9bd 100644 --- a/dev-docs/runtime-migration/HANDOFF.md +++ b/dev-docs/runtime-migration/HANDOFF.md @@ -46,7 +46,7 @@ These were throwaway and are recreated by the plans when needed: `REVIEW-2026-10-15.md` is the principal review of the first draft (13 structural findings, per-plan defects, the landing strategy, the decisions taken in its section 6). Every plan was then reworked against it: six new PRs (00a, 00b, 00c, 02b, 14b, 20b, 27b), PR 21 folded into 25, PR 07 and PR 39 rewritten and renamed, `SPIKES.md` added, `_TEMPLATE.md` vocabulary updated, `00-assessment.md` given an errata section. `README.md` carries the new table, dependency graph, conventions and baseline. -The plans are now executable in the README order; PR 02 (#15135) and 02c (#15153) are merged, 02d (the ratchet also counts `Deno` reached through optional chaining and `globalThis`) is in progress, and the next to run are 00a, then 00b. +The plans are now executable in the README order; PR 02 (#15135), 02c (#15153) and 02d (#15218) are merged, 09 (the `src/deno_ral/` fs, temp and errors layer) is in progress, and the next to run are 00a, then 00b. ## Reconciliation pass (bucket B of `TRIAGE-2026-10-16.md`): done on 2026-10-16 @@ -78,7 +78,7 @@ Two notes for whoever continues: the B1 commit carries the maintainer's author i 2. Read `README.md`, then `00-assessment.md` sections 10 and 11, then `_TEMPLATE.md`. 3. The section 9 decisions are taken (`REVIEW-2026-10-15.md` section 6); the test runner is decided by Spike V in `SPIKES.md` before PR 31. 4. Apply the local corrections of `TRIAGE-2026-10-16.md` bucket C (one task per plan file, in parallel where the triage marks it), then the re-read of 02, 25, 26, 27, 29, 33, 35, 36 the triage's step 4 asks for (02, 25, 26, 27 and 29 done 2026-10-16, 33, 35 and 36 done 2026-10-17: step 4 is complete). -5. PR 02 and 02c are merged, 02d is in progress; open the remaining plans as PRs against `main` in the README order: 00a and 00b, then 03–06 in parallel, then 07. +5. PR 02, 02c and 02d are merged, 09 is in progress; open the remaining plans as PRs against `main` in the README order: 00a and 00b, then 03–06 in parallel, then 07. Each plan names its branch, executor and verification commands. For the per-worktree session ritual (window model, trust, fresh conversation, concurrency ceiling), check `local/multi-worktree-workflow.md` if it exists — it is a maintainer-local, untracked note. diff --git a/dev-docs/runtime-migration/README.md b/dev-docs/runtime-migration/README.md index 08a39930e2..3578998c40 100644 --- a/dev-docs/runtime-migration/README.md +++ b/dev-docs/runtime-migration/README.md @@ -81,6 +81,8 @@ Change them in the plan that decides them, not downstream. - Landing order inside Phase 1: 02 → 02b → 08 → 22 → 10–13 → 14, with 09 before 10. PR 22 depends on PR 04 (Phase 0, behind 00b and 00a), so 10–13 and 14 wait for that part of Phase 0 too. `tests/asserts.ts` lands in PR 02; tests written by PRs 09 and later use the layer, never raw `Deno.*`. + A test that must touch a Deno-only object (the `Deno.errors` classes in `tests/unit/ral/errors.test.ts`) writes `Deno.` directly and accepts the `tests` baseline increase; it never reaches it through `globalThis` to stay out of the count. +- Code comments in `src/` and `tests/` describe the code as it is now, without plan or PR numbers ("until PR 25", "PR 27 moves this"); what a later PR changes belongs in that PR's plan (decided 2026-10-28, PR 09 review). - Migration page for script authors: `https://quarto.org/docs/projects/scripts-deno-to-node.html` (PRs 00c, 35; created in quarto-web). - `tests/run-tests.sh` accepts files only, never directories: plans write `$(find unit/ral -name '*.test.ts')`. @@ -130,10 +132,14 @@ The rows below are the ones that still need a named person or an external answer | PR 33 and PR 35 size | whether PR 33 lands as one PR or as 33a (Tasks 1, 11, 12, 16) + 33b (the flip), and PR 35 as one PR or 35a (Task 1 builder) + 35b (scripts) | maintainer | before Phase 3 starts | | User breakage after the flip | issue label, weekly triage owner during the soak, criterion to extend the dual-runtime window or the shim | release manager | before the first 1.12 prerelease | | PR 18, all tasks (from #15180) | the Deno backend keeps the `v8-` store directory; reconcile it with the store names `index`/`sass-index` and the `-.css` naming. Task 2 Step 2 shrinks to the API change: `this.index.list({ prefix: [] })` becomes `this.index.list([])`, and the `clear()` body #15180 shipped stays as is (`storeDir` paths, `safeRemoveSync`, immediate entries skipped; test `projectCache - clear() removes disk and immediate entries of a populated cache`). Drop the Context sentence saying `join` throws in `clear()`, and the Risks lines about that throwing path; one `cache.ts` `Deno.removeSync` is now `safeRemoveSync`. Refresh every Verified usage line number | coordinator | PR 18 step 0 | +| PR 20b Task 4 (from PR 09) | add `tools/codemod-deno-ral.ts` and `tools/codemod/codemod-deno-ral.test.ts` to Task 4's Modify list, and a step: remove `src/core/performance/metrics.ts` from `kCodemodPatchFiles` and from the backstop test's path list, then run the codemod suite. Once metrics.ts uses `onFileRead`, the codemod must convert its `Deno.env.get`/`Deno.writeTextFileSync` sites (PR 25:27) | coordinator | PR 20b step 0 | +| PR 25 Task 4 (from PR 09) | same two files in Task 4's Modify list, and a step: remove `src/core/deno/monkey-patch.ts` from `kCodemodPatchFiles` and the backstop test, then run the codemod suite. If the list is then empty, delete `kCodemodPatchFiles`, its check, the backstop test and `patch-file-backstop.input.ts` | coordinator | PR 25 step 0 | | PR 18 Tasks 1–3 (from the #15119 fix, once `src/deno_ral/kv.ts` is on `main`) | `kv.ts` already exists (`openKv`, `getKv`, `kvOp`, `KvDamagedError`) and both caches go through it, so Task 1 extends the file instead of creating it and its "Module not found" RED step no longer holds. `openKeyValueStore` keeps the damage mapping: a damaged value reads as a miss with one warning, a damaged file fails with `KvDamagedError` naming the folder to delete, any other error is wrapped with the path. The `src` baseline drops by 5, not 7 (the two `Deno.openKv` calls are already in the layer). The `Deno.openKv` grep acceptance check covers `src` and `package` only, since three cache tests build damaged fixtures with `Deno.openKv` on purpose. The `kv.ts` row in `.claude/rules/typescript/deno-ral.md` exists: update it | coordinator | PR 18 step 0 | | PR 19 Task 1, PR 27b | both replace the whole body of `platform.ts`: keep the `v8Branch` export (`_TEMPLATE.md`, "`platform.ts`"); PR 27b computes it from `process.versions.v8` | coordinator | step 0 of each | | PR 02c (from #15202, which turned `main` red) | Decided 2026-10-27: PR runs fail on any increase that leaves a count above their own `tools/runtime-boundary-baseline.json` (exactly what the push check on `main` will compare), instead of only warning; an increase that still fits because `main` sits below its baseline is a notice; 02c's "everything else is a warning for the maintainer" and its "exit 1 if any error, else 0" no longer hold. Per-line findings stay warnings saying the contributor has nothing to change, plus one `::error` saying a maintainer pushes the baseline update before merging; the remedy is unchanged (update the branch with `main`, `--update-baseline --accept-increase`, push to the PR). Rejected: an acceptance label (the merged tree still carries the old baseline, so `main` fails anyway) and warn-only with a merge checklist (the human step is what failed). A plan whose expected delta raises a count runs `--accept-increase` in its PR (PRs 01 and 05 already do) | maintainer | step 0 of any plan that raises a count | | PR 30 | tagged `v8-/` directories are left behind like the untagged `.kv` files; remove `v8Branch` and its contract-test line together with its last consumer. What "damaged" means for the JSON index (unparsable or truncated file): keep the miss-with-warning versus fail-naming-the-folder split or not. The Deno KV damage tests (`tests/unit/kv-damage.test.ts` and the damaged-store cases in `sass-cache.test.ts` and `project-cache.test.ts`, from the #15119 fix) build real SQLite damage: they are replaced or deleted here, because PR 32 runs the full unit suite on Node, where neither `Deno.openKv` nor its damage exists, and "`sass-cache.test.ts` stays green" is restated accordingly | maintainer (damage semantics), coordinator (the rest) | PR 30 step 0 | +| Any plan that type-checks `src/quarto.ts` (from PR 09 Task 3, 2026-10-28) | the command `deno check --unstable-kv --unstable-ffi --import-map=import_map.json quarto.ts` fails on the vendored Deno 2.7.14 with `Requires import access to "cdn.skypack.dev:443"` (a remote import in `src/core/yaml.ts:31`); add `--allow-import` | coordinator | step 0 of each | +| PR 27 (from PR 09 Task 3, 2026-10-28) | Deno 2.7.14's types make `Deno.SymlinkOptions.type` required and `Deno.truncateSync` string-only, so the layer's `symlink[Sync]` forwards an options object only when a `type` is given and `truncateSync` converts a URL with `toPathString`; the node:fs reimplementation keeps both behaviours | coordinator | PR 27 step 0 | ## Shared rules and tables @@ -167,6 +173,22 @@ Re-grep every `path:line` the plan cites, check each "assumes PR xx landed with against `main`, check the plan's `**Depends on:**` PRs are merged, read the "Open decisions with an owner" table above for a row that names this PR, and set the plan's row in "The sequence" to `in progress`. The person who merges the PR sets it to `merged`. +Step 0 also audits the plan's code blocks, because the executor transcribes them verbatim and a defect in them lands in the PR (all three findings of PR 09's review came from its plan). +Fix each hit in the plan, and record it in the execution log, before Task 1: + +- Every function the plan's code defines (`rg -n 'function \w+' `) is searched for in `tests/utils.ts`, `tests/verify.ts`, `tests/unit/**/utils.ts` and `src/`; an existing helper is reused, and a helper two new test files need lives in one shared file. +- No code outside `src/deno_ral/` reaches `Deno` indirectly to stay out of the ratchet count: through `globalThis`, `self` or `window`, by destructuring, or through an alias (`const d = Deno`) (`rg -nP '\bglobalThis\b|\bDeno\b(?!\??\.)' `; `typeof Deno` and prose hits are fine). A `Deno.` access the code needs is written as such and the baseline increase is accepted. +- Comments inside code blocks state the current reason only: no plan or PR numbers, no description of what a later PR will change (`rg -n '(//|\*).*\bPR [0-9]' `; a hit in a shell or YAML block that is not a code comment is fine). + +After each task's commit, the reviewer runs a reuse and simplification pass over the task's diff, on top of the plan's own review checklist: the plan checks behaviour, this pass checks the three points above on the code as committed. +On the whole branch before the PR opens: + +```bash +git diff main...HEAD -- src tests ':!src/deno_ral' | rg -nP '^\+.*(\bglobalThis\b|\bDeno\b(?!\??\.))' # the layer itself is exempt; typeof Deno and prose hits are fine +git diff main...HEAD -- src tests | rg -n '^\+.*(//|\*).*\bPR [0-9]' +git diff main...HEAD -- tests | rg -n '^\+\s*(export )?(async )?function \w+' # each name: rg it in tests/ and src/ +``` + ```bash git checkout main && git pull git worktree add ../ -b # rule 7: one worktree per PR @@ -188,6 +210,15 @@ A test that fails on an OS the executor cannot run: CI on that OS is the evidenc Commits follow the plan's steps as written (a failing-test step followed by its implementation step may leave one red commit; no squash is required). Changelog: a dependency bump gets a line under `## Dependencies` of `news/changelog-.md` (created by PR 00a); a plan with no user-visible change says `Out: changelog` in its Scope with the reason. +Progress tracking while executing (decided 2026-10-28, from PR 09's run): + +- The plan file ends with an "Execution log" section: one entry per task with the commands run and their quoted output; numbers reported anywhere (test counts, ratchet counts, dry-run summaries) are copied from real output. + The executor ticks the plan's checkboxes as steps complete; the execution-log and checkbox updates fold into each task's own commit. +- Status in a tracker, if the maintainer keeps one, stays in that tracker (HANDOFF "Tracking"); nothing about it is committed. +- A plan may be executed over several sessions: hand off at a task boundary (a commit point), not mid-task. + The ticked checkboxes, the execution log and the git log make the plan file self-sufficient; a fresh session resumes with "Continue executing PR NN in worktree ``; the plan is `dev-docs/runtime-migration/NN-*.md` — resume at Task N per its checkboxes and execution log." + This is the fallback for `superpowers:subagent-driven-development` when the executor has no sub-agent tooling: the artifacts above replace the orchestrator's memory. + ## The sequence Status values: `planned`, `in progress`, `merged`. @@ -201,7 +232,7 @@ Every PR that changes a count lands after 02c; preparing it earlier is fine. |---|---|---|---|---|---|---| | 02 | [02-runtime-boundary-check.md](02-runtime-boundary-check.md) | CI ratchet counting `Deno.*` and direct std imports in five scopes (`src`, `src-js`, `resources`, `package/src`, `tests`); `tests/asserts.ts`; lands first | none | Sonnet | S | merged | | 02c | [02c-ratchet-pr-delta-and-guidance.md](02c-ratchet-pr-delta-and-guidance.md) | PR runs compare with the base branch; findings name a working replacement or ask the maintainer to accept; `--accept-increase` | 02 | Sonnet | S | merged | -| 02d | [02d-ratchet-counts-indirect-deno.md](02d-ratchet-counts-indirect-deno.md) | The ratchet also counts `Deno?.x`, `globalThis["Deno"]` and a bare `globalThis.Deno` | 02c | Sonnet | S | in progress | +| 02d | [02d-ratchet-counts-indirect-deno.md](02d-ratchet-counts-indirect-deno.md) | The ratchet also counts `Deno?.x`, `globalThis["Deno"]` and a bare `globalThis.Deno` | 02c | Sonnet | S | merged | | 00a | [00a-deno-bump.md](00a-deno-bump.md) | Deno 2.7.14 → newest 2.9.x everywhere the version appears | 02 | Sonnet | S | planned | | 00b | [00b-lockfile-and-bundle-gate.md](00b-lockfile-and-bundle-gate.md) | Committed `deno.lock` with `--frozen` (six entrypoints); `tools/check-test-imports.ts` keeps the unlocked tests inside that graph; three-OS bundle gate (cold cache, external-specifier grep, five renders) | 02, 00a | Opus | M | planned | | 00c | [00c-quarto-run-deprecation-notice.md](00c-quarto-run-deprecation-notice.md) | One-time notice when `quarto run` or a pre/post-render `.ts` script runs on Deno; `QUARTO_RUN_DENO_NOTICE=0` | 02 | Sonnet | S | planned | @@ -219,7 +250,7 @@ Every PR that changes a count lands after 02c; preparing it earlier is fine. | 02b | [02b-node-compatible-ts-syntax.md](02b-node-compatible-ts-syntax.md) | `verbatimModuleSyntax`; `import type` codemod (2,067 specifiers in 504 files); enums, parameter properties and `` casts rewritten; `check-ts-syntax.mjs` in CI | 02 | Opus | L | planned | | 08 | [08-ral-process.md](08-ral-process.md) | `deno_ral/process.ts`: spawn/exec interface with `detached`, `killProcess` (group on POSIX, `taskkill /T` tree on Windows), flushing `exit`, `execProcess` on it, 16 direct `Deno.Command` sites, exit/cwd/args/execPath/signals/stdio | 02 | Opus | L | planned | | 39 | [39-remove-quarto-latexmk-compile.md](39-remove-quarto-latexmk-compile.md) | Remove the `tinitex` `deno compile` build and the standalone entry (Rust rewrite replaces it); before PR 25, whose zero needs the files gone | 08 | Sonnet | S | planned | -| 09 | [09-ral-fs-and-temp-layer.md](09-ral-fs-and-temp-layer.md) | `deno_ral/fs.ts`, `temp.ts`, `errors.ts` API (`canonicalPathSync`, retyped `FileInfo`), unit tests, and the idempotent ts-morph codemod with `--reverse` | 02 | Opus | M | planned | +| 09 | [09-ral-fs-and-temp-layer.md](09-ral-fs-and-temp-layer.md) | `deno_ral/fs.ts`, `temp.ts`, `errors.ts` API (`canonicalPathSync`, retyped `FileInfo`), unit tests, and the idempotent ts-morph codemod with `--reverse` | 02 | Opus | M | in progress | | 22 | [22-std-import-seams.md](22-std-import-seams.md) | `colors.ts`, `flags.ts`, base64 seams; table of std modules with internal `Deno.*` and their owner (PR 20b) | 04, 08 | Sonnet | S | planned | | 10 | [10-codemod-fs-core.md](10-codemod-fs-core.md) | Run the fs/temp codemod on `src/core`, `src/quarto-core`, `src/config` (56 files) | 09, 08, 22 | Sonnet | L | planned | | 11 | [11-codemod-fs-command.md](11-codemod-fs-command.md) | Codemod on `src/command` (43 files) | 09, 08, 22 | Sonnet | L | planned | diff --git a/dev-docs/runtime-migration/_TEMPLATE.md b/dev-docs/runtime-migration/_TEMPLATE.md index 70647a5308..25a5bb8c36 100644 --- a/dev-docs/runtime-migration/_TEMPLATE.md +++ b/dev-docs/runtime-migration/_TEMPLATE.md @@ -63,7 +63,10 @@ Commands to run before opening the PR, with expected results: - a manual render where relevant: `package/dist/bin/quarto render ` ## Review checklist -What the reviewer verifies, as a checklist. +What the reviewer verifies, as a checklist. Every plan ends it with these code-quality items (README "How to execute a plan" has the commands): +- [ ] No new helper duplicates an existing one (`tests/utils.ts`, `tests/verify.ts`, `tests/unit/**/utils.ts`, `src/`); a helper needed by two new test files lives in one shared file. +- [ ] No code outside `src/deno_ral/` reaches `Deno` indirectly (through `globalThis`, `self` or `window`, by destructuring, or through an alias such as `const d = Deno`) to stay out of the ratchet count; a needed `Deno.` access is written as such and the baseline increase is accepted. +- [ ] Comments in `src/` and `tests/` state the current reason only: no plan or PR numbers ("until PR 25"), no description of what a later PR will change. ## Risks and rollback What can break, how it shows up, how to revert. diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index db98133f40..febeeeb32b 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -206,3 +206,5 @@ All changes included in 1.11: - ([#15140](https://github.com/quarto-dev/quarto-cli/pull/15140)): Add support for definition lists written as a `definition-list` div holding a nested bullet list, the definition list syntax of Quarto 2. - ([#15180](https://github.com/quarto-dev/quarto-cli/pull/15180)): Fix older Quarto versions failing to render with `RangeError: could not deserialize value` once a Quarto built on a newer Deno has used the same cache. Also fix a project cache that already has entries failing to clear with `Path must be a string`. - ([#15198](https://github.com/quarto-dev/quarto-cli/issues/15198)): Fix `quarto add`, `quarto use template`, `quarto use brand` and `quarto install tinytex` failing to unzip on Windows when a Group Policy blocks running `.bat` files from the temporary folder, and when a path contains an apostrophe. +- ([#15223](https://github.com/quarto-dev/quarto-cli/pull/15223)): Directories are now read in a fixed order (sorted by name in character-code order, not by locale) on every platform. + Extension discovery, the theme list of `quarto capabilities`, and the items of a MECA bundle no longer depend on the file system's listing order; when two extensions share an id, which one wins can differ from earlier versions. diff --git a/src/deno_ral/errors.ts b/src/deno_ral/errors.ts new file mode 100644 index 0000000000..463b86a9f5 --- /dev/null +++ b/src/deno_ral/errors.ts @@ -0,0 +1,99 @@ +/* + * errors.ts + * + * Copyright (C) 2026 Posit Software, PBC + * + * Runtime-neutral error predicates. Application code uses these instead of + * `instanceof Deno.errors.X`. The Deno classes are matched when the Deno + * global exists; Node-style `code` properties are matched everywhere. A + * wrapper error (deno_ral/process.ts exec) is looked through via `cause`. + */ + +function errorCode(e: unknown): string | undefined { + if (typeof e === "object" && e !== null && "code" in e) { + const code = (e as { code: unknown }).code; + return typeof code === "string" ? code : undefined; + } + return undefined; +} + +type DenoErrorName = + | "NotFound" + | "AlreadyExists" + | "PermissionDenied" + | "AddrInUse" + | "ConnectionRefused" + | "BrokenPipe" + | "NotConnected" + | "ConnectionAborted" + | "ConnectionReset"; + +function isDenoError(e: unknown, name: DenoErrorName): boolean { + // deno-lint-ignore no-explicit-any + const errors = (globalThis as any).Deno?.errors; + const cls = errors?.[name]; + return typeof cls === "function" && e instanceof cls; +} + +function matchesOne(e: unknown, names: DenoErrorName[], codes: string[]): boolean { + const code = errorCode(e); + if (code !== undefined && codes.includes(code)) { + return true; + } + return names.some((name) => isDenoError(e, name)); +} + +function matches(e: unknown, names: DenoErrorName[], codes: string[]): boolean { + if (matchesOne(e, names, codes)) { + return true; + } + const cause = e instanceof Error ? e.cause : undefined; + return cause !== undefined && matchesOne(cause, names, codes); +} + +export function isNotFound(e: unknown): boolean { + return matches(e, ["NotFound"], ["ENOENT"]); +} + +export function isAlreadyExists(e: unknown): boolean { + return matches(e, ["AlreadyExists"], ["EEXIST"]); +} + +export function isPermissionDenied(e: unknown): boolean { + return matches(e, ["PermissionDenied"], ["EACCES", "EPERM"]); +} + +export function isAddrInUse(e: unknown): boolean { + return matches(e, ["AddrInUse"], ["EADDRINUSE"]); +} + +export function isConnectionRefused(e: unknown): boolean { + return matches(e, ["ConnectionRefused"], ["ECONNREFUSED"]); +} + +/** Errors a server sees when a client goes away mid-response. */ +export function isConnectionError(e: unknown): boolean { + return matches( + e, + [ + "BrokenPipe", + "NotConnected", + "ConnectionAborted", + "ConnectionReset", + "ConnectionRefused", + ], + ["EPIPE", "ENOTCONN", "ECONNABORTED", "ECONNRESET", "ECONNREFUSED"], + ); +} + +/** + * Append the offending path to an error message (idempotent): calling it + * again with the same path leaves the message unchanged. + */ +export function errorWithPath(e: Error, path: string): Error { + const suffix = "\nPath: " + path; + if (e.message && !e.message.endsWith(suffix)) { + e.message = e.message + suffix; + } + return e; +} diff --git a/src/deno_ral/fs.ts b/src/deno_ral/fs.ts index 06844051ca..3df869b6e6 100644 --- a/src/deno_ral/fs.ts +++ b/src/deno_ral/fs.ts @@ -1,5 +1,5 @@ /* - * encoding.ts + * fs.ts * * Copyright (C) 2020-2024 Posit Software, PBC */ @@ -10,6 +10,7 @@ import { copySync } from "fs/copy"; import { existsSync } from "fs/exists"; import { originalRealPathSync } from "./original-real-path.ts"; import { debug } from "./log.ts"; +import { errorWithPath } from "./errors.ts"; export { ensureDir, ensureDirSync } from "fs/ensure-dir"; export { existsSync } from "fs/exists"; @@ -27,7 +28,7 @@ export type { WalkEntry } from "fs/walk"; // from https://jsr.io/@std/fs/1.0.3/_get_file_info_type.ts export type PathType = "file" | "dir" | "symlink"; -export function getFileInfoType(fileInfo: Deno.FileInfo): PathType | undefined { +export function getFileInfoType(fileInfo: FileInfo): PathType | undefined { return fileInfo.isFile ? "file" : fileInfo.isDirectory @@ -113,7 +114,7 @@ export function safeMoveSync( export function safeRemoveSync( file: string, - options: Deno.RemoveOptions = {}, + options: { recursive?: boolean } = {}, ) { try { Deno.removeSync(file, options); @@ -144,21 +145,14 @@ export function safeRemoveDirSync( path: string, boundary: string, ) { - // Resolve symlinks to ensure consistent path comparison. - // This is needed because external tools (like knitr) may resolve symlinks - // while project.dir preserves them. - // - // We use the original Deno.realPathSync (saved before monkey-patching) - // because the monkey-patch replaces it with normalizePath which doesn't - // resolve symlinks. - // - // Note: The UNC path bug that motivated the monkey-patch was fixed in - // Deno v1.16 (see denoland/deno#12243), so this is safe on all platforms. + // Resolve symlinks so both sides compare the same physical path. External + // tools (knitr, for example) may resolve symlinks while project.dir + // preserves them. let resolvedPath = path; let resolvedBoundary = boundary; try { - resolvedPath = originalRealPathSync(path); - resolvedBoundary = originalRealPathSync(boundary); + resolvedPath = canonicalPathSync(path); + resolvedBoundary = canonicalPathSync(boundary); } catch { // If resolution fails (e.g., path doesn't exist), use original paths } @@ -213,3 +207,305 @@ export function ensureUserWritable(path: string): void { safeChmodSync(path, mode | 0o200); } } + +// --------------------------------------------------------------------------- +// Plain filesystem calls, named after their Deno.* counterparts. +// +// Deno.* is looked up at call time on purpose: core/performance/metrics.ts +// reassigns Deno.readTextFileSync to record file reads. +// --------------------------------------------------------------------------- + +export interface FileInfo { + isFile: boolean; + isDirectory: boolean; + isSymlink: boolean; + size: number; + mtime: Date | null; + atime: Date | null; + birthtime: Date | null; + mode: number | null; +} + +export interface DirEntry { + name: string; + isFile: boolean; + isDirectory: boolean; + isSymlink: boolean; +} + +export interface FsFile { + readonly readable: ReadableStream; + readonly writable: WritableStream; + read(p: Uint8Array): Promise; + write(p: Uint8Array): Promise; + writeSync(p: Uint8Array): number; + close(): void; +} + +export interface OpenOptions { + read?: boolean; + write?: boolean; + append?: boolean; + create?: boolean; + truncate?: boolean; + mode?: number; +} + +export interface WriteOptions { + append?: boolean; + create?: boolean; + mode?: number; +} + +function toFileInfo(info: Deno.FileInfo): FileInfo { + return { + isFile: info.isFile, + isDirectory: info.isDirectory, + isSymlink: info.isSymlink, + size: info.size, + mtime: info.mtime, + atime: info.atime, + birthtime: info.birthtime, + mode: info.mode, + }; +} + +function toDirEntry(entry: Deno.DirEntry): DirEntry { + return { + name: entry.name, + isFile: entry.isFile, + isDirectory: entry.isDirectory, + isSymlink: entry.isSymlink, + }; +} + +function byName(a: DirEntry, b: DirEntry): number { + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; +} + +// String(path), not toPathString: a URL is annotated as its href (what +// the caller passed); the missing-file URL cases in +// tests/unit/ral/fs.test.ts pin this form. +function annotate(e: unknown, path: string | URL): unknown { + return e instanceof Error ? errorWithPath(e, String(path)) : e; +} + +export function readTextFileSync(path: string | URL): string { + try { + return Deno.readTextFileSync(path); + } catch (e) { + throw annotate(e, path); + } +} + +export async function readTextFile(path: string | URL): Promise { + try { + return await Deno.readTextFile(path); + } catch (e) { + throw annotate(e, path); + } +} + +export function readFileSync(path: string | URL): Uint8Array { + try { + return Deno.readFileSync(path); + } catch (e) { + throw annotate(e, path); + } +} + +export async function readFile(path: string | URL): Promise { + try { + return await Deno.readFile(path); + } catch (e) { + throw annotate(e, path); + } +} + +export function writeTextFileSync( + path: string | URL, + data: string, + options?: WriteOptions, +): void { + Deno.writeTextFileSync(path, data, options); +} + +export function writeTextFile( + path: string | URL, + data: string, + options?: WriteOptions, +): Promise { + return Deno.writeTextFile(path, data, options); +} + +export function writeFileSync( + path: string | URL, + data: Uint8Array, + options?: WriteOptions, +): void { + Deno.writeFileSync(path, data, options); +} + +export function writeFile( + path: string | URL, + data: Uint8Array, + options?: WriteOptions, +): Promise { + return Deno.writeFile(path, data, options); +} + +export function statSync(path: string | URL): FileInfo { + return toFileInfo(Deno.statSync(path)); +} + +export async function stat(path: string | URL): Promise { + return toFileInfo(await Deno.stat(path)); +} + +export function lstatSync(path: string | URL): FileInfo { + return toFileInfo(Deno.lstatSync(path)); +} + +export async function lstat(path: string | URL): Promise { + return toFileInfo(await Deno.lstat(path)); +} + +export function mkdirSync( + path: string | URL, + options?: { recursive?: boolean; mode?: number }, +): void { + Deno.mkdirSync(path, options); +} + +export function mkdir( + path: string | URL, + options?: { recursive?: boolean; mode?: number }, +): Promise { + return Deno.mkdir(path, options); +} + +export function removeSync( + path: string | URL, + options?: { recursive?: boolean }, +): void { + Deno.removeSync(path, options); +} + +export function remove( + path: string | URL, + options?: { recursive?: boolean }, +): Promise { + return Deno.remove(path, options); +} + +export function renameSync(oldPath: string | URL, newPath: string | URL): void { + Deno.renameSync(oldPath, newPath); +} + +export function rename( + oldPath: string | URL, + newPath: string | URL, +): Promise { + return Deno.rename(oldPath, newPath); +} + +export function copyFileSync(from: string | URL, to: string | URL): void { + Deno.copyFileSync(from, to); +} + +export function copyFile(from: string | URL, to: string | URL): Promise { + return Deno.copyFile(from, to); +} + +/** Directory entries sorted by name (code-unit order). */ +export function readDirSync(path: string | URL): DirEntry[] { + return Array.from(Deno.readDirSync(path), toDirEntry).sort(byName); +} + +/** Directory entries sorted by name (code-unit order). */ +export async function* readDir(path: string | URL): AsyncIterable { + const entries: DirEntry[] = []; + for await (const entry of Deno.readDir(path)) { + entries.push(toDirEntry(entry)); + } + entries.sort(byName); + yield* entries; +} + +export function symlinkSync( + target: string | URL, + path: string | URL, + options?: { type?: "file" | "dir" }, +): void { + // Deno.SymlinkOptions.type is required in Deno 2's types; pass options only + // when a type was given so a bare `symlinkSync(a, b)` stays options-free. + Deno.symlinkSync( + target, + path, + options?.type !== undefined ? { type: options.type } : undefined, + ); +} + +export function symlink( + target: string | URL, + path: string | URL, + options?: { type?: "file" | "dir" }, +): Promise { + return Deno.symlink( + target, + path, + options?.type !== undefined ? { type: options.type } : undefined, + ); +} + +export function readLinkSync(path: string | URL): string { + return Deno.readLinkSync(path); +} + +/** Resolves symlinks (the un-patched Deno.realPath behaviour). */ +export function realPath(path: string | URL): Promise { + return Deno.realPath(path); +} + +/** + * The canonical absolute path: symlinks resolved, and no Windows `\\?\` prefix + * (Deno strips it itself, so nothing is sliced here). Throws when the path + * does not exist. core/deno/monkey-patch.ts patches Deno.realPathSync to + * normalizePath, so the saved original is called. + */ +export function canonicalPathSync(path: string | URL): string { + return originalRealPathSync(path); +} + +export function chmodSync(path: string | URL, mode: number): void { + Deno.chmodSync(path, mode); +} + +export function chmod(path: string | URL, mode: number): Promise { + return Deno.chmod(path, mode); +} + +export function utimeSync( + path: string | URL, + atime: number | Date, + mtime: number | Date, +): void { + Deno.utimeSync(path, atime, mtime); +} + +export function truncateSync(path: string | URL, len?: number): void { + // Deno.truncateSync's types take string only (no URL). + Deno.truncateSync(toPathString(path), len); +} + +export function open(path: string | URL, options?: OpenOptions): Promise { + return Deno.open(path, options); +} + +export function openSync(path: string | URL, options?: OpenOptions): FsFile { + return Deno.openSync(path, options); +} + +export function create(path: string | URL): Promise { + return Deno.create(path); +} diff --git a/src/deno_ral/temp.ts b/src/deno_ral/temp.ts new file mode 100644 index 0000000000..3b9c4e92df --- /dev/null +++ b/src/deno_ral/temp.ts @@ -0,0 +1,72 @@ +/* + * temp.ts + * + * Copyright (C) 2026 Posit Software, PBC + * + * Temporary files and directories. Carries the retry that + * core/deno/monkey-patch.ts installs on Deno.makeTemp* (rare EEXIST races, + * https://github.com/denoland/deno/issues/17781), narrowed to AlreadyExists: + * any other error (a missing parent directory, a permission problem) is + * reported at once. The monkey patch's own loop runs inside this one; that is + * harmless. + */ + +import { isAlreadyExists } from "./errors.ts"; +import { debug } from "./log.ts"; + +export interface TempOptions { + dir?: string; + prefix?: string; + suffix?: string; +} + +const kMaxAttempts = 5; + +/** Call fn up to `attempts` times while it throws an AlreadyExists error. */ +export function retryOnAlreadyExists( + fn: () => T, + attempts = kMaxAttempts, +): T { + for (let i = 1; ; i++) { + try { + return fn(); + } catch (err) { + if (!isAlreadyExists(err) || i >= attempts) { + throw err; + } + debug(`Temp path already exists, retrying (${i} of ${attempts - 1})`); + } + } +} + +async function retryOnAlreadyExistsAsync( + fn: () => Promise, + attempts = kMaxAttempts, +): Promise { + for (let i = 1; ; i++) { + try { + return await fn(); + } catch (err) { + if (!isAlreadyExists(err) || i >= attempts) { + throw err; + } + debug(`Temp path already exists, retrying (${i} of ${attempts - 1})`); + } + } +} + +export function makeTempDirSync(options?: TempOptions): string { + return retryOnAlreadyExists(() => Deno.makeTempDirSync(options)); +} + +export function makeTempDir(options?: TempOptions): Promise { + return retryOnAlreadyExistsAsync(() => Deno.makeTempDir(options)); +} + +export function makeTempFileSync(options?: TempOptions): string { + return retryOnAlreadyExists(() => Deno.makeTempFileSync(options)); +} + +export function makeTempFile(options?: TempOptions): Promise { + return retryOnAlreadyExistsAsync(() => Deno.makeTempFile(options)); +} diff --git a/tests/unit/ral/errors.test.ts b/tests/unit/ral/errors.test.ts new file mode 100644 index 0000000000..b41b982282 --- /dev/null +++ b/tests/unit/ral/errors.test.ts @@ -0,0 +1,73 @@ +/* + * errors.test.ts + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { assert, assertEquals } from "../../asserts.ts"; +import { + errorWithPath, + isAddrInUse, + isAlreadyExists, + isConnectionError, + isConnectionRefused, + isNotFound, + isPermissionDenied, +} from "../../../src/deno_ral/errors.ts"; +import { errorWithCode } from "./utils.ts"; + +// deno-lint-ignore require-await +unitTest("errors - predicates recognise the runtime's error classes", async () => { + if (typeof Deno === "undefined") return; // Node: covered by the code-based case below + const errors = Deno.errors; + const e = (name: keyof typeof errors) => new errors[name]("x"); + assert(isNotFound(e("NotFound"))); + assert(isAlreadyExists(e("AlreadyExists"))); + assert(isPermissionDenied(e("PermissionDenied"))); + assert(isAddrInUse(e("AddrInUse"))); + assert(isConnectionRefused(e("ConnectionRefused"))); + assert(isConnectionError(e("BrokenPipe"))); + assert(isConnectionError(e("ConnectionReset"))); + assert(isConnectionError(e("ConnectionRefused"))); + assert(!isNotFound(e("AlreadyExists"))); + assert(!isConnectionError(e("NotFound"))); +}); + +// deno-lint-ignore require-await +unitTest("errors - predicates recognise Node-style error codes", async () => { + assert(isNotFound(errorWithCode("ENOENT"))); + assert(isAlreadyExists(errorWithCode("EEXIST"))); + assert(isPermissionDenied(errorWithCode("EACCES"))); + assert(isPermissionDenied(errorWithCode("EPERM"))); + assert(isAddrInUse(errorWithCode("EADDRINUSE"))); + assert(isConnectionRefused(errorWithCode("ECONNREFUSED"))); + assert(isConnectionError(errorWithCode("EPIPE"))); + assert(isConnectionError(errorWithCode("ECONNRESET"))); + assert(!isNotFound(errorWithCode("EEXIST"))); + assert(!isNotFound(new Error("x"))); + assert(!isNotFound(undefined)); + assert(!isNotFound("ENOENT")); +}); + +// deno-lint-ignore require-await +unitTest("errors - predicates look through cause (wrapped exec errors)", async () => { + const wrapped = new Error("Error executing 'x': not found", { + cause: errorWithCode("ENOENT"), + }); + assert(isNotFound(wrapped)); + assert(!isAlreadyExists(wrapped)); + const plain = new Error("outer", { cause: new Error("inner") }); + assert(!isNotFound(plain)); +}); + +// deno-lint-ignore require-await +unitTest("errors - errorWithPath appends the path exactly once", async () => { + const e = new Error("boom"); + errorWithPath(e, "/tmp/a.txt"); + errorWithPath(e, "/tmp/a.txt"); + assertEquals(e.message, "boom\nPath: /tmp/a.txt"); + const empty = new Error(""); + errorWithPath(empty, "/tmp/b.txt"); + assertEquals(empty.message, ""); +}); diff --git a/tests/unit/ral/fs.test.ts b/tests/unit/ral/fs.test.ts new file mode 100644 index 0000000000..3a5563f4d5 --- /dev/null +++ b/tests/unit/ral/fs.test.ts @@ -0,0 +1,216 @@ +/* + * fs.test.ts + * + * Compatibility contract for the plain filesystem calls in + * src/deno_ral/fs.ts: every runtime implementation must keep it green. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { withTempDir } from "../../utils.ts"; +import { + assert, + assertEquals, + assertStringIncludes, + assertThrows, +} from "../../asserts.ts"; +import { isWindows } from "../../../src/deno_ral/platform.ts"; +import { isNotFound } from "../../../src/deno_ral/errors.ts"; +import { join, toFileUrl } from "../../../src/deno_ral/path.ts"; +import { + canonicalPathSync, + chmodSync, + copyFileSync, + create, + existsSync, + getFileInfoType, + lstatSync, + mkdirSync, + open, + readDir, + readDirSync, + readFileSync, + readLinkSync, + readTextFile, + readTextFileSync, + removeSync, + renameSync, + statSync, + symlinkSync, + truncateSync, + utimeSync, + writeFileSync, + writeTextFileSync, +} from "../../../src/deno_ral/fs.ts"; + +unitTest("fs - text round trip and append", () => withTempDir((dir) => { + const file = join(dir, "a.txt"); + writeTextFileSync(file, "hello"); + writeTextFileSync(file, " world", { append: true }); + assertEquals(readTextFileSync(file), "hello world"); +})); + +unitTest("fs - read errors carry the path once and satisfy isNotFound", () => withTempDir(async (dir) => { + const missing = join(dir, "missing.txt"); + const e = assertThrows(() => readTextFileSync(missing)); + assert(isNotFound(e)); + assertStringIncludes((e as Error).message, "Path: " + missing); + assertEquals((e as Error).message.split("Path: ").length, 2, "path appended once"); + let asyncErr: unknown; + try { + await readTextFile(missing); + } catch (err) { + asyncErr = err; + } + assert(isNotFound(asyncErr)); + assertStringIncludes((asyncErr as Error).message, "Path: " + missing); + // Missing file as a file URL: the annotation must use the same + // representation as core/deno/monkey-patch.ts (`"Path: " + path`, i.e. + // the URL href) so it is appended once even with the patch active. + const missingUrl = toFileUrl(missing); + const urlErr = assertThrows(() => readTextFileSync(missingUrl)); + assert(isNotFound(urlErr)); + assertStringIncludes((urlErr as Error).message, "Path: " + missingUrl); + assertEquals( + (urlErr as Error).message.split("Path: ").length, + 2, + "URL path appended once", + ); + let asyncUrlErr: unknown; + try { + await readTextFile(missingUrl); + } catch (err) { + asyncUrlErr = err; + } + assert(isNotFound(asyncUrlErr)); + assertStringIncludes((asyncUrlErr as Error).message, "Path: " + missingUrl); + assertEquals( + (asyncUrlErr as Error).message.split("Path: ").length, + 2, + "URL path appended once", + ); +})); + +unitTest("fs - bytes round trip", () => withTempDir((dir) => { + const file = join(dir, "b.bin"); + writeFileSync(file, new Uint8Array([1, 2, 3])); + assertEquals([...readFileSync(file)], [1, 2, 3]); +})); + +unitTest("fs - statSync and lstatSync", () => withTempDir((dir) => { + const file = join(dir, "s.txt"); + writeTextFileSync(file, "12345"); + const info = statSync(file); + assertEquals(info.isFile, true); + assertEquals(info.isDirectory, false); + assertEquals(info.size, 5); + assert(info.mtime instanceof Date); + assertEquals(statSync(dir).isDirectory, true); + if (!isWindows) { + const link = join(dir, "s-link"); + symlinkSync(file, link); + assertEquals(lstatSync(link).isSymlink, true); + assertEquals(statSync(link).isSymlink, false); + assertEquals(readLinkSync(link), file); + } +})); + +unitTest("fs - mkdirSync recursive and removeSync recursive", () => withTempDir((dir) => { + const nested = join(dir, "x", "y", "z"); + mkdirSync(nested, { recursive: true }); + assert(existsSync(nested)); + removeSync(join(dir, "x"), { recursive: true }); + assert(!existsSync(join(dir, "x"))); +})); + +unitTest("fs - renameSync and copyFileSync", () => withTempDir((dir) => { + const a = join(dir, "a.txt"); + const b = join(dir, "b.txt"); + const c = join(dir, "c.txt"); + writeTextFileSync(a, "A"); + renameSync(a, b); + assert(!existsSync(a)); + copyFileSync(b, c); + assertEquals(readTextFileSync(c), "A"); + assertEquals(readTextFileSync(b), "A"); +})); + +unitTest("fs - readDirSync and readDir are sorted by name", () => withTempDir(async (dir) => { + for (const name of ["c.txt", "a.txt", "b.txt"]) { + writeTextFileSync(join(dir, name), name); + } + mkdirSync(join(dir, "B-dir")); + const sync = readDirSync(dir).map((e) => e.name); + assertEquals(sync, ["B-dir", "a.txt", "b.txt", "c.txt"]); + const entry = readDirSync(dir).find((e) => e.name === "B-dir")!; + assertEquals(entry.isDirectory, true); + assertEquals(entry.isFile, false); + const async: string[] = []; + for await (const e of readDir(dir)) { + async.push(e.name); + } + assertEquals(async, sync); +})); + +unitTest("fs - canonicalPathSync resolves symlinks and throws when missing", () => withTempDir((dir) => { + const real = join(dir, "real"); + mkdirSync(real); + const canonicalReal = canonicalPathSync(real); + assertEquals(canonicalPathSync(join(real, "..", "real")), canonicalReal); + assert(!canonicalReal.startsWith("\\\\?\\"), "no Windows verbatim prefix"); + if (!isWindows) { + const link = join(dir, "link"); + symlinkSync(real, link); + assertEquals(canonicalPathSync(link), canonicalReal, "symlinks are resolved"); + } + const e = assertThrows(() => canonicalPathSync(join(dir, "missing"))); + assert(isNotFound(e)); +})); + +unitTest("fs - getFileInfoType takes the layer's FileInfo", () => withTempDir((dir) => { + const file = join(dir, "f.txt"); + writeTextFileSync(file, "x"); + assertEquals(getFileInfoType(lstatSync(file)), "file"); + assertEquals(getFileInfoType(lstatSync(dir)), "dir"); + if (!isWindows) { + const link = join(dir, "f-link"); + symlinkSync(file, link); + assertEquals(getFileInfoType(lstatSync(link)), "symlink"); + } +})); + +unitTest("fs - utimeSync and truncateSync", () => withTempDir((dir) => { + const file = join(dir, "u.txt"); + writeTextFileSync(file, "0123456789"); + const when = new Date("2020-01-02T03:04:05Z"); + utimeSync(file, when, when); + assertEquals(statSync(file).mtime?.getTime(), when.getTime()); + truncateSync(file, 4); + assertEquals(readTextFileSync(file), "0123"); + truncateSync(file); + assertEquals(readTextFileSync(file), ""); +})); + +unitTest("fs - chmodSync (POSIX)", () => withTempDir((dir) => { + if (isWindows) return; + const file = join(dir, "m.txt"); + writeTextFileSync(file, "x"); + chmodSync(file, 0o600); + assertEquals((statSync(file).mode ?? 0) & 0o777, 0o600); +})); + +unitTest("fs - open/create expose streams and close", () => withTempDir(async (dir) => { + const file = join(dir, "o.txt"); + const out = await create(file); + const writer = out.writable.getWriter(); + await writer.write(new TextEncoder().encode("line1\nline2\n")); + await writer.close(); + const inp = await open(file, { read: true }); + const text = await new Response(inp.readable).text(); + assertEquals(text, "line1\nline2\n"); + const appender = await open(file, { append: true }); + appender.writeSync(new TextEncoder().encode("line3\n")); + appender.close(); + assertEquals(readTextFileSync(file), "line1\nline2\nline3\n"); +})); diff --git a/tests/unit/ral/temp.test.ts b/tests/unit/ral/temp.test.ts new file mode 100644 index 0000000000..cc0b2eb7e7 --- /dev/null +++ b/tests/unit/ral/temp.test.ts @@ -0,0 +1,103 @@ +/* + * temp.test.ts + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { assert, assertEquals, assertRejects, assertThrows } from "../../asserts.ts"; +import { + makeTempDir, + makeTempDirSync, + makeTempFile, + makeTempFileSync, + retryOnAlreadyExists, +} from "../../../src/deno_ral/temp.ts"; +import { isAlreadyExists, isNotFound } from "../../../src/deno_ral/errors.ts"; +import { existsSync, safeRemoveSync } from "../../../src/deno_ral/fs.ts"; +import { basename, dirname, join } from "../../../src/deno_ral/path.ts"; +import { errorWithCode } from "./utils.ts"; + +// deno-lint-ignore require-await +unitTest("temp - makeTempDirSync honours dir, prefix and suffix", async () => { + const root = makeTempDirSync({ prefix: "ral-temp-" }); + try { + const dir = makeTempDirSync({ dir: root, prefix: "pre-", suffix: "-suf" }); + assertEquals(dirname(dir), root); + assert(basename(dir).startsWith("pre-")); + assert(basename(dir).endsWith("-suf")); + assert(existsSync(dir)); + } finally { + safeRemoveSync(root, { recursive: true }); + } +}); + +// deno-lint-ignore require-await +unitTest("temp - makeTempFileSync creates a file", async () => { + const root = makeTempDirSync({ prefix: "ral-temp-" }); + try { + const file = makeTempFileSync({ dir: root, suffix: ".json" }); + assertEquals(dirname(file), root); + assert(file.endsWith(".json")); + assert(existsSync(file)); + } finally { + safeRemoveSync(root, { recursive: true }); + } +}); + +unitTest("temp - async variants", async () => { + const root = await makeTempDir({ prefix: "ral-temp-" }); + try { + const dir = await makeTempDir({ dir: root }); + const file = await makeTempFile({ dir: root, prefix: "f-" }); + assert(existsSync(dir)); + assert(existsSync(file)); + assert(basename(file).startsWith("f-")); + } finally { + safeRemoveSync(root, { recursive: true }); + } +}); + +unitTest("temp - a missing parent directory fails with NotFound", async () => { + const root = makeTempDirSync({ prefix: "ral-temp-" }); + const missing = join(root, "nope", "deeper"); + try { + const e = assertThrows(() => makeTempDirSync({ dir: missing })); + assert(isNotFound(e), `expected NotFound, got ${e}`); + const e2 = await assertRejects(() => makeTempFile({ dir: missing })); + assert(isNotFound(e2), `expected NotFound, got ${e2}`); + } finally { + safeRemoveSync(root, { recursive: true }); + } +}); + +// deno-lint-ignore require-await +unitTest("temp - retries only AlreadyExists, at most 5 times", async () => { + let calls = 0; + const flaky = () => { + calls++; + if (calls < 3) throw errorWithCode("EEXIST"); + return "ok"; + }; + assertEquals(retryOnAlreadyExists(flaky), "ok"); + assertEquals(calls, 3); + + calls = 0; + const e = assertThrows(() => + retryOnAlreadyExists(() => { + calls++; + throw errorWithCode("EEXIST"); + }) + ); + assert(isAlreadyExists(e)); + assertEquals(calls, 5); + + calls = 0; + assertThrows(() => + retryOnAlreadyExists(() => { + calls++; + throw errorWithCode("ENOENT"); + }) + ); + assertEquals(calls, 1, "other errors are not retried"); +}); diff --git a/tests/unit/ral/utils.ts b/tests/unit/ral/utils.ts new file mode 100644 index 0000000000..aa11e450c7 --- /dev/null +++ b/tests/unit/ral/utils.ts @@ -0,0 +1,14 @@ +/* + * utils.ts + * + * Test utilities for the deno_ral unit tests + * + * Copyright (C) 2026 Posit Software, PBC + */ + +/** An error carrying a Node-style `code` (ENOENT, EEXIST, ...). */ +export function errorWithCode(code: string): Error { + const e = new Error(code); + (e as Error & { code: string }).code = code; + return e; +} diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts new file mode 100644 index 0000000000..8b20e8a444 --- /dev/null +++ b/tools/codemod-deno-ral.ts @@ -0,0 +1,869 @@ +/* + * codemod-deno-ral.ts + * + * Copyright (C) 2026 Posit Software, PBC + * + * Rewrites `Deno.` uses to the equivalent export of src/deno_ral/.ts + * and adds or merges the import; with --reverse, rewrites layer names back to + * `Deno.*` (used by dev-docs/checklist-backport-a-pr.md). Dev tooling: run with + * + * quarto run --dev tools/codemod-deno-ral.ts --rules fs,temp --dir src/core [--dry-run] + * + * See dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md for the rules. + * This script lives outside the runtime boundary and may use Deno.* itself. + */ + +import { + type Identifier, + type ImportDeclaration, + type ImportSpecifier, + Node, + Project, + type SourceFile, + SyntaxKind, + ts, +} from "npm:ts-morph@28.0.0"; +import { dirname, join, relative, resolve } from "../src/deno_ral/path.ts"; +import { walkSync } from "../src/deno_ral/fs.ts"; +import { kSrcExclusions } from "./check-runtime-boundary.ts"; + +export type RuleName = "fs" | "temp" | "env" | "process" | "platform"; + +interface Member { + to: string; + callAfter?: boolean; // Deno.args -> args() + reverseHint?: string; // never reversed; reported in `left` with this hint +} + +interface Rule { + module: string; + members: Record; // Deno. -> export + types: Record; // Deno. (type position) -> export + nested: Record; // Deno.. -> export +} + +const kFsNames = [ + "readTextFileSync", "readTextFile", "readFileSync", "readFile", + "writeTextFileSync", "writeTextFile", "writeFileSync", "writeFile", + "statSync", "stat", "lstatSync", "lstat", "mkdirSync", "mkdir", + "removeSync", "remove", "renameSync", "rename", "copyFileSync", "copyFile", + "readDirSync", "readDir", "symlinkSync", "symlink", "readLinkSync", + "realPath", "chmodSync", "chmod", "utimeSync", "truncateSync", + "open", "openSync", "create", +]; + +const same = (names: string[]): Record => + Object.fromEntries(names.map((n) => [n, { to: n }])); + +export const kCanonicalReverseHint = + "is not Deno.realPathSync on branches that load core/deno/monkey-patch.ts; use originalRealPathSync from deno_ral/original-real-path.ts"; + +export const RULES: Record = { + fs: { + module: "fs.ts", + members: { + ...same(kFsNames), + realPathSync: { to: "canonicalPathSync", reverseHint: kCanonicalReverseHint }, + }, + types: { FileInfo: "FileInfo", DirEntry: "DirEntry", FsFile: "FsFile" }, + nested: {}, + }, + temp: { + module: "temp.ts", + members: same(["makeTempDirSync", "makeTempDir", "makeTempFileSync", "makeTempFile"]), + types: { MakeTempOptions: "TempOptions" }, + nested: {}, + }, + env: { + module: "env.ts", + members: { env: { to: "env" } }, + types: {}, + nested: {}, + }, + process: { + module: "process.ts", + members: { + ...same(["exit", "cwd", "chdir", "execPath"]), + args: { to: "args", callAfter: true }, + pid: { to: "pid", callAfter: true }, + }, + types: {}, + nested: {}, + }, + platform: { + module: "platform.ts", + members: {}, + types: {}, + nested: { "build.os": "os", "build.arch": "arch" }, + }, +}; + +/** Paths (relative to the repository root) the codemod never walks into. */ +export const kCodemodExclusions: readonly string[] = [ + ...kSrcExclusions, + // bundled for the browser; must not import deno_ral (src/core/lib/errors.ts:27-29) + "src/core/lib", + // run by `deno run` from share/tools without the import map; PR 25 allow-lists it + "src/resources/tools/ast-diagram", +]; + +/** Repo-relative (`/`-separated) paths of files known to patch `Deno`, skipped + * after the write guard as a backstop (the guard is best-effort and frozen). */ +export const kCodemodPatchFiles: readonly string[] = [ + // patches Deno.realPathSync and the read functions; PR 25 deletes the file + "src/core/deno/monkey-patch.ts", + // patches Deno.readTextFileSync to record file reads; PR 20b moves it to `onFileRead` + "src/core/performance/metrics.ts", +]; + +export interface Replacement { + line: number; + from: string; + to: string; +} +export interface Collision { + name: string; + line: number; + module: string; + hint?: string; +} +export interface CodemodResult { + file: string; + replacements: Replacement[]; + collisions: Collision[]; + importsAdded: Record; + importsRemoved: Record; + left: Collision[]; + skipped?: string; +} + +interface Edit { + start: number; + end: number; + text: string; +} + +export function collectFiles(dir: string, root = Deno.cwd()): string[] { + const files: string[] = []; + for (const entry of walkSync(dir, { includeDirs: false, exts: [".ts"], skip: [/node_modules/] })) { + const rel = relative(root, resolve(entry.path)).replaceAll("\\", "/"); + if (kCodemodExclusions.some((e) => rel === e || rel.startsWith(e + "/"))) continue; + if (rel.split("/").includes("node_modules")) continue; + if (entry.name.endsWith(".d.ts") || entry.name.endsWith(".ejs.ts")) continue; + files.push(entry.path); + } + return files.sort(); +} + +function isDenoIdentifier(node: Node | undefined): boolean { + return node !== undefined && Node.isIdentifier(node) && node.getText() === "Deno"; +} + +// How `Deno` is looked up at a location: `Deno.x` in an expression resolves +// the value; the left of a qualified type name (`Deno.FileInfo`) resolves the +// namespace. A binding of the other meaning does not shadow (a value-only +// parameter named Deno leaves `Deno.FileInfo` global; a local `type Deno` +// leaves `Deno.readTextFileSync()` global). Alias is in both: an import named +// Deno may carry either meaning, so it conservatively shadows. +const kValueMeaning = ts.SymbolFlags.Value | ts.SymbolFlags.Alias; +const kNamespaceMeaning = ts.SymbolFlags.Namespace | ts.SymbolFlags.Alias; + +// The global `Deno` has no declaration under noLib (see newProject), so a +// `Deno` that resolves to a binding in scope with the given meaning shadows +// the global. getSymbolAtLocation is not enough: for the left of a qualified +// name it returns a transient symbol with no declarations even when a local +// `Deno` binding exists. +function denoBoundAt(node: Node, meaning: ts.SymbolFlags, checker: ts.TypeChecker): boolean { + // excludeGlobals: a lib-provided global `Deno` must not count as a binding. + return checker.resolveName("Deno", node.compilerNode, meaning, /*excludeGlobals*/ true) !== + undefined; +} + +function isGlobalDeno( + node: Node | undefined, + meaning: ts.SymbolFlags, + checker: ts.TypeChecker, +): boolean { + return isDenoIdentifier(node) && !denoBoundAt(node as Identifier, meaning, checker); +} + +// A binding or a reference in scope, as opposed to a property name. +function isBindingOrReference(id: Node): boolean { + const parent = id.getParent(); + if (!parent) return true; + if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === id) return false; + if (Node.isPropertyAssignment(parent) && parent.getNameNode() === id) return false; + if (Node.isPropertySignature(parent) || Node.isMethodSignature(parent)) return false; + if (Node.isPropertyDeclaration(parent) || Node.isMethodDeclaration(parent)) { + return parent.getNameNode() !== id; + } + if (Node.isQualifiedName(parent) && parent.getRight() === id) return false; + if (Node.isEnumMember(parent)) return false; + // `a` in `import { a as b }` names the other module's export, not a binding here + if (Node.isImportSpecifier(parent) && parent.getAliasNode() !== undefined && + parent.getNameNode() === id) return false; + return true; +} + +// Wrappers that leave the wrapped expression the same assignment target or +// receiver: `(x)`, `x as T`, `x`, `x!`, `x satisfies T`. +function isTransparentWrapper(node: Node): boolean { + return Node.isParenthesizedExpression(node) || Node.isAsExpression(node) || + Node.isTypeAssertion(node) || Node.isNonNullExpression(node) || + Node.isSatisfiesExpression(node); +} + +function outermostWrapper(node: Node): Node { + let outer = node; + for (let p = outer.getParent(); p && isTransparentWrapper(p); p = p.getParent()) outer = p; + return outer; +} + +// The expression inside any transparent wrappers: `Object.assign` for +// `(Object.assign)`. +function unwrap(node: Node): Node { + while (isTransparentWrapper(node)) { + node = (node as Node & { getExpression(): Node }).getExpression(); + } + return node; +} + +// The `.x` or `[x]` access whose receiver is `node`, possibly wrapped +// (`(Deno as any).x`). +function accessOn(node: Node): Node | undefined { + const receiver = outermostWrapper(node); + const access = receiver.getParent(); + if ( + (Node.isPropertyAccessExpression(access) || Node.isElementAccessExpression(access)) && + access.getExpression() === receiver + ) return access; + return undefined; +} + +// A use of `Deno` that is neither `Deno.x` nor a read of its type: +// `const d = Deno`, `f(Deno)`, `{ ...Deno }`, `const { x } = Deno`. What +// happens to Deno past such a use cannot be followed. +function isDenoEscape(id: Node): boolean { + if (accessOn(id)) return false; + const parent = outermostWrapper(id).getParent(); + if (!parent) return false; + // `{ Deno }` is a use, although its name is also the property's name. + if (Node.isShorthandPropertyAssignment(parent)) return true; + // The name of a declaration (`type Deno = ...`) is not a use. + if ( + !Node.isExpression(parent) && + ts.getNameOfDeclaration(parent.compilerNode as ts.Declaration) === id.compilerNode + ) return false; + return !Node.isTypeOfExpression(parent) && !Node.isQualifiedName(parent) && + !Node.isTypeQuery(parent) && !Node.isTypeReference(parent); +} + +// The object and member name of `o.x`, `o["x"]` or o[`x`], the key possibly +// wrapped (`o[("x")]`). +function memberOf(node: Node | undefined): { object: Node; name: string } | undefined { + if (Node.isPropertyAccessExpression(node)) { + return { object: node.getExpression(), name: node.getName() }; + } + if (!Node.isElementAccessExpression(node)) return undefined; + const key = node.getArgumentExpression(); + const arg = key && unwrap(key); + return Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg) + ? { object: node.getExpression(), name: arg.getLiteralText() } + : undefined; +} + +// `globalThis.Deno`, `self["Deno"]`, ... when `id` is the global object. +const kGlobalObjects = ["globalThis", "self", "window"]; +function globalObjectDeno(id: Node): Node | undefined { + if (!kGlobalObjects.includes(id.getText())) return undefined; + const access = accessOn(id); + return memberOf(access)?.name === "Deno" ? access : undefined; +} + +// Calls that replace or remove members of their first argument. A Map, so +// that an owner such as `constructor` finds no inherited entry. +const kMutators = new Map([ + ["Object", ["assign", "defineProperty", "defineProperties"]], + ["Reflect", ["set", "defineProperty", "deleteProperty"]], +]); + +// `Object.assign` when `node`, possibly wrapped, is the first argument of +// such a call (`Object.assign(Deno.build, ...)`), the callee and its owner +// possibly wrapped too (`(Object.assign)(...)`) and the callee possibly an +// element access (`Object["assign"](...)`). +function mutatorOf(node: Node): string | undefined { + const arg = outermostWrapper(node); + const call = arg.getParent(); + if (!Node.isCallExpression(call) || call.getArguments()[0] !== arg) return undefined; + const callee = memberOf(unwrap(call.getExpression())); + if (!callee) return undefined; + const owner = unwrap(callee.object); + if (!Node.isIdentifier(owner) || !kMutators.get(owner.getText())?.includes(callee.name)) { + return undefined; + } + return call.getExpression().getText(); +} + +// The target of an assignment (`=`, `+=`, `&&=`, ...), `++`/`--`, `delete`, +// or a `for (x of/in ...)` head, directly or inside a destructuring pattern +// (`({ a: x } = o)`, `[x] = a`): rewriting it to an imported binding would +// not be valid. +function isWriteTarget(node: Node): boolean { + let target = node; + let parent = node.getParent(); + let inPattern = false; + // Climb transparent wrappers and the literals that a destructuring pattern + // is written with; whether the outermost literal is a pattern is decided below. + while (parent) { + if (Node.isArrayLiteralExpression(parent) || Node.isObjectLiteralExpression(parent)) { + inPattern = true; + } else if ( + !isTransparentWrapper(parent) && !Node.isSpreadElement(parent) && + !Node.isSpreadAssignment(parent) && + !(Node.isPropertyAssignment(parent) && parent.getInitializer() === target) + ) break; + target = parent; + parent = parent.getParent(); + } + if (!parent) return false; + if ((Node.isForOfStatement(parent) || Node.isForInStatement(parent)) && + parent.getInitializer() === target) return true; + if (inPattern) { + return Node.isBinaryExpression(parent) && parent.getLeft() === target && + parent.getOperatorToken().getKind() === SyntaxKind.EqualsToken; + } + if (Node.isBinaryExpression(parent)) { + const op = parent.getOperatorToken().getKind(); + return parent.getLeft() === target && + op >= SyntaxKind.FirstAssignment && op <= SyntaxKind.LastAssignment; + } + if (Node.isPrefixUnaryExpression(parent) || Node.isPostfixUnaryExpression(parent)) { + const op = parent.getOperatorToken(); + return op === SyntaxKind.PlusPlusToken || op === SyntaxKind.MinusMinusToken; + } + return Node.isDeleteExpression(parent); +} + +function resolvesToModule( + sourceFile: SourceFile, + specifier: string, + ralDir: string, + module: string, +): boolean { + if (!specifier.startsWith(".")) return false; + const target = resolve(join(resolve(ralDir), module)).replaceAll("\\", "/"); + const actual = resolve(join(dirname(sourceFile.getFilePath()), specifier)) + .replaceAll("\\", "/"); + return target === actual; +} + +function importSpecifierFor(file: string, ralDir: string, module: string): string { + let rel = relative(dirname(resolve(file)), join(resolve(ralDir), module)); + rel = rel.replaceAll("\\", "/"); + if (!rel.startsWith(".")) rel = "./" + rel; + return rel; +} + +function byName(a: string, b: string): number { + return a.localeCompare(b, "en"); +} + +// The symbol an identifier refers to (the value symbol for `{ x }` shorthand). +function referencedDeclarations(id: Identifier, checker: ts.TypeChecker): ts.Declaration[] { + const parent = id.getParent(); + const symbol = parent && Node.isShorthandPropertyAssignment(parent) + ? checker.getShorthandAssignmentValueSymbol(parent.compilerNode) + : checker.getSymbolAtLocation(id.compilerNode); + return symbol?.declarations ?? []; +} + +function specifierExport(spec: ts.ImportSpecifier): string { + return (spec.propertyName ?? spec.name).text; +} + +function newProject(): Project { + return new Project({ + skipAddingFilesFromTsConfig: true, + skipFileDependencyResolution: true, + // noLib: no lib.d.ts per file (speed); identifiers that resolve only to a + // global then count as collisions, which is the conservative answer. + compilerOptions: { allowImportingTsExtensions: true, noEmit: true, noLib: true }, + }); +} + +function emptyResult(file: string): CodemodResult { + return { file, replacements: [], collisions: [], importsAdded: {}, importsRemoved: {}, left: [] }; +} + +function applyEdits(text: string, edits: Edit[]): string { + edits.sort((a, b) => b.start - a.start || b.end - a.end); + let out = text; + for (const e of edits) { + out = out.slice(0, e.start) + e.text + out.slice(e.end); + } + return out; +} + +function renderImport(decl: ImportDeclaration, specifiers: string[], typeOnly: boolean): string { + const def = decl.getDefaultImport()?.getText(); + const head = typeOnly ? "import type " : "import "; + const named = specifiers.length > 0 ? `{ ${specifiers.join(", ")} }` : ""; + const clause = [def, named].filter((x) => x).join(", "); + return `${head}${clause} from "${decl.getModuleSpecifierValue()}";`; +} + +export function codemodFile( + file: string, + rules: RuleName[], + options: { ralDir: string; dryRun: boolean; reverse?: boolean }, +): CodemodResult { + // Fast path: most files have nothing to rewrite; skip the type checker. + const raw = Deno.readTextFileSync(file); + if (!raw.includes(options.reverse ? "deno_ral/" : "Deno.")) { + return emptyResult(file); + } + const project = newProject(); + const sourceFile = project.addSourceFileAtPath(file); + const checker = project.getTypeChecker().compilerObject; + const result = options.reverse + ? reverseFile(sourceFile, rules, options.ralDir, checker) + : forwardFile(sourceFile, rules, options.ralDir, checker); + if (result.edits.length > 0 && !options.dryRun) { + Deno.writeTextFileSync(file, applyEdits(sourceFile.getFullText(), result.edits)); + } + return result.result; +} + +function forwardFile( + sourceFile: SourceFile, + rules: RuleName[], + ralDir: string, + checker: ts.TypeChecker, +): { result: CodemodResult; edits: Edit[] } { + const file = sourceFile.getFilePath(); + const result = emptyResult(file); + const edits: Edit[] = []; + const text = sourceFile.getFullText(); + + // 1. Files that write to Deno.* (monkey patches, instrumentation) are left + // alone. The written node may be `Deno.x` or, for nested rules, `Deno.a.b`, + // with the receiver wrapped or not (`(Deno as any).x`, `Deno["x"]`). A + // write through a wrapped receiver counts too: the file's plain reads + // would otherwise become layer calls into the patched function. A file + // that uses Deno other than as `Deno.x` (an alias, an argument such as + // `Object.defineProperty(Deno, ...)`, `globalThis.Deno`) is left alone + // too, since writes past that use cannot be followed. + // The guard is best-effort and frozen: it does not see a write through + // `Reflect.get(globalThis, "Deno")`, a computed key `globalThis[k]`, or a + // comma-wrapped nested receiver `Object.assign((0, Deno.build), …)`. A + // file that patches `Deno` with a spelling the guard misses belongs on + // `kCodemodPatchFiles`, not in a wider guard. + const skip = (reason: string, node: Node) => { + result.skipped = `${reason} at line ${node.getStartLineNumber()}`; + return { result, edits: [] }; + }; + for (const id of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) { + const viaGlobal = globalObjectDeno(id); + if (viaGlobal) return skip(`reaches Deno through ${viaGlobal.getText()}`, viaGlobal); + if (!isBindingOrReference(id) || !isGlobalDeno(id, kValueMeaning, checker)) continue; + if (isDenoEscape(id)) return skip("uses Deno other than as Deno.x", id); + const access = accessOn(id); + if (!access) continue; + const nested = accessOn(access); + const written = [access, ...(nested ? [nested] : [])].find(isWriteTarget); + if (written) return skip(`assigns to ${written.getText()}`, written); + const mutator = mutatorOf(access); + if (mutator) return skip(`assigns to ${access.getText()} via ${mutator}`, access); + } + + // Backstop, after the guard so a caught file keeps the guard's reason: skip + // the files known to patch `Deno` even when the guard misses the write. + const rel = relative(resolve(ralDir, "..", ".."), file).replaceAll("\\", "/"); + if (kCodemodPatchFiles.includes(rel)) { + result.skipped = "listed in kCodemodPatchFiles"; + return { result, edits: [] }; + } + + const importDecls = sourceFile.getImportDeclarations(); + const declsFor = (module: string) => + importDecls.filter((d) => + resolvesToModule(sourceFile, d.getModuleSpecifierValue(), ralDir, module) + ); + + // 2. Existing local name for an export of a layer module (alias reuse). + const existingLocal = (module: string, exportName: string, forType: boolean) => { + for (const d of declsFor(module)) { + for (const s of d.getNamedImports()) { + if (s.getName() !== exportName) continue; + if (!forType && (d.isTypeOnly() || s.isTypeOnly())) continue; + return s.getAliasNode()?.getText() ?? exportName; + } + } + return undefined; + }; + + // 3. Symbol-based collisions: does `local` refer to anything other than + // the layer export it would name? + const identifiers = sourceFile.getDescendantsOfKind(SyntaxKind.Identifier); + const isLayerBinding = (decl: ts.Declaration, module: string, exportName: string, forType: boolean) => { + if (!ts.isImportSpecifier(decl) || specifierExport(decl) !== exportName) return false; + const importDecl = decl.parent.parent.parent; + if ( + !ts.isImportDeclaration(importDecl) || + !ts.isStringLiteral(importDecl.moduleSpecifier) || + !resolvesToModule(sourceFile, importDecl.moduleSpecifier.text, ralDir, module) + ) return false; + // A type-only binding cannot back a value rewrite; merging would add a + // second binding of the same name, so it conflicts instead of reusing. + return forType || (!decl.isTypeOnly && !importDecl.importClause?.isTypeOnly); + }; + const collisionCache = new Map(); + const blocked = (local: string, module: string, exportName: string, forType: boolean): boolean => { + const key = `${module}:${exportName}:${local}:${forType}`; + const cached = collisionCache.get(key); + if (cached !== undefined) return cached; + let collision: Collision | undefined; + for (const id of identifiers) { + if (id.getText() !== local || !isBindingOrReference(id)) continue; + const decls = referencedDeclarations(id, checker); + const ok = decls.length > 0 && decls.every((d) => isLayerBinding(d, module, exportName, forType)); + if (!ok) { + collision = { name: local, line: id.getStartLineNumber(), module }; + break; + } + } + if (collision) result.collisions.push(collision); + collisionCache.set(key, collision !== undefined); + return collision !== undefined; + }; + + const wanted = new Map; types: Set }>(); + const want = (module: string, name: string, isType: boolean) => { + const w = wanted.get(module) ?? { values: new Set(), types: new Set() }; + (isType ? w.types : w.values).add(name); + wanted.set(module, w); + }; + const replace = (node: Node, exportName: string, module: string, isType: boolean, suffix = "") => { + const existing = existingLocal(module, exportName, isType); + const local = existing ?? exportName; + if (blocked(local, module, exportName, isType)) return false; + const to = local + suffix; + edits.push({ start: node.getStart(), end: node.getEnd(), text: to }); + result.replacements.push({ line: node.getStartLineNumber(), from: node.getText(), to }); + if (!existing) want(module, exportName, isType); + return true; + }; + + // 4. Value positions: Deno. and Deno... + for (const pae of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) { + if (!isGlobalDeno(pae.getExpression(), kValueMeaning, checker)) continue; + const name = pae.getName(); + const parent = pae.getParent(); + for (const ruleName of rules) { + const rule = RULES[ruleName]; + if (Node.isPropertyAccessExpression(parent) && parent.getExpression() === pae) { + const to = rule.nested[`${name}.${parent.getName()}`]; + if (to) { + replace(parent, to, rule.module, false); + break; + } + } + const member = rule.members[name]; + if (member) { + replace(pae, member.to, rule.module, false, member.callAfter ? "()" : ""); + break; + } + } + } + + // 5. Type positions: Deno.. + for (const ref of sourceFile.getDescendantsOfKind(SyntaxKind.TypeReference)) { + const typeName = ref.getTypeName(); + if ( + !Node.isQualifiedName(typeName) || + !isGlobalDeno(typeName.getLeft(), kNamespaceMeaning, checker) + ) continue; + const name = typeName.getRight().getText(); + for (const ruleName of rules) { + const rule = RULES[ruleName]; + const to = rule.types[name]; + if (!to) continue; + replace(typeName, to, rule.module, true); + break; + } + } + + if (edits.length === 0) { + return { result, edits }; + } + + // 6. Imports: merge into an existing declaration or insert a new one. + const newDecls: string[] = []; + for (const [module, names] of wanted) { + const decls = declsFor(module); + const valueDecl = decls.find((d) => !d.isTypeOnly() && d.getNamespaceImport() === undefined); + // Namespace imports (`import type * as fs`) are not merge targets: + // rewriting one to named imports would break the existing `fs.X` uses. + const typeDecl = decls.find((d) => d.isTypeOnly() && d.getNamespaceImport() === undefined); + const values = [...names.values].sort(byName); + const types = [...names.types].sort(byName); + result.importsAdded[module] = [...values, ...types].sort(byName); + const render = (existing: string[], add: string[]) => + [...existing, ...add].sort((a, b) => + byName(a.replace(/^type /, "").split(" as ")[0], b.replace(/^type /, "").split(" as ")[0]) + ); + if (values.length > 0 || (types.length > 0 && valueDecl && !typeDecl)) { + const add = [...values, ...types.map((t) => `type ${t}`)]; + if (valueDecl) { + const existing = valueDecl.getNamedImports().map((s) => s.getText()); + edits.push({ + start: valueDecl.getStart(), + end: valueDecl.getEnd(), + text: renderImport(valueDecl, render(existing, add), false), + }); + } else { + const spec = importSpecifierFor(sourceFile.getFilePath(), ralDir, module); + newDecls.push(`import { ${render([], add).join(", ")} } from "${spec}";`); + } + } else if (types.length > 0) { + if (typeDecl) { + const existing = typeDecl.getNamedImports().map((s) => s.getText()); + edits.push({ + start: typeDecl.getStart(), + end: typeDecl.getEnd(), + text: renderImport(typeDecl, render(existing, types), true), + }); + } else { + const spec = importSpecifierFor(sourceFile.getFilePath(), ralDir, module); + newDecls.push(`import type { ${types.join(", ")} } from "${spec}";`); + } + } + } + if (newDecls.length > 0) { + if (importDecls.length > 0) { + const last = importDecls[importDecls.length - 1]; + edits.push({ start: last.getEnd(), end: last.getEnd(), text: "\n" + newDecls.join("\n") }); + } else { + const first = sourceFile.getStatements()[0]; + const pos = first ? first.getStart() : text.length; + edits.push({ start: pos, end: pos, text: newDecls.join("\n") + "\n" }); + } + } + return { result, edits }; +} + +interface ReverseTarget { + value?: string; // replacement for a value reference + call?: string; // replacement for `name()` (callAfter members) + type?: string; // replacement for a type reference + hint?: string; // never reversed; reported in `left` with this hint +} + +function reverseTable(rule: Rule): Record { + const table: Record = {}; + for (const [deno, m] of Object.entries(rule.members)) { + table[m.to] = m.reverseHint + ? { hint: m.reverseHint } + : m.callAfter + ? { call: `Deno.${deno}` } + : { value: `Deno.${deno}` }; + } + for (const [deno, to] of Object.entries(rule.types)) { + table[to] = { ...table[to], type: `Deno.${deno}` }; + } + for (const [deno, to] of Object.entries(rule.nested)) { + table[to] = { value: `Deno.${deno}` }; + } + return table; +} + +function reverseFile( + sourceFile: SourceFile, + rules: RuleName[], + ralDir: string, + checker: ts.TypeChecker, +): { result: CodemodResult; edits: Edit[] } { + const result = emptyResult(sourceFile.getFilePath()); + const edits: Edit[] = []; + const text = sourceFile.getFullText(); + const identifiers = sourceFile.getDescendantsOfKind(SyntaxKind.Identifier); + const localExports = sourceFile.getExportDeclarations() + .filter((d) => !d.hasModuleSpecifier()) + .flatMap((d) => d.getNamedExports()); + + for (const decl of sourceFile.getImportDeclarations()) { + const ruleName = rules.find((r) => + resolvesToModule(sourceFile, decl.getModuleSpecifierValue(), ralDir, RULES[r].module) + ); + if (!ruleName) continue; + const rule = RULES[ruleName]; + const table = reverseTable(rule); + const removed: ImportSpecifier[] = []; + for (const spec of decl.getNamedImports()) { + const target = table[spec.getName()]; + if (!target) continue; + const local = spec.getAliasNode()?.getText() ?? spec.getName(); + const refs = identifiers.filter((id) => + id.getText() === local && isBindingOrReference(id) && + id !== spec.getNameNode() && id !== spec.getAliasNode() && + referencedDeclarations(id, checker).includes(spec.compilerNode) + ); + // `export { x }` / `export { x as y }` re-exports the binding itself and + // has no `Deno.*` spelling; keep the import (reported in `left`). + const reexport = localExports.find((e) => + checker.getExportSpecifierLocalTargetSymbol(e.compilerNode)?.declarations + ?.includes(spec.compilerNode) + ); + if (target.hint) { + // Never reversed (the `Deno.*` spelling means something else on some + // branches); keep the import and report in `left` with the hint. The + // line is the first use of the binding, counting a re-export as a + // use, or the specifier's line when nothing uses it. + const uses = [...(reexport ? [reexport] : []), ...refs]; + const line = uses.length + ? Math.min(...uses.map((n) => n.getStartLineNumber())) + : spec.getStartLineNumber(); + result.left.push({ name: local, line, module: rule.module, hint: target.hint }); + continue; + } + if (reexport) { + result.left.push({ name: local, line: reexport.getStartLineNumber(), module: rule.module }); + continue; + } + const specEdits: Edit[] = []; + const specReplacements: Replacement[] = []; + let complete = true; + for (const id of refs) { + const parent = id.getParent()!; + let node: Node = id; + let to: string | undefined; + const isType = Node.isTypeReference(parent) && parent.getTypeName() === id; + if (denoBoundAt(id, isType ? kNamespaceMeaning : kValueMeaning, checker)) { + // The introduced `Deno.*` would name the local binding, not the + // runtime global; keep the import (reported in `left`). + to = undefined; + } else if (isType) { + to = target.type; + } else if (target.call) { + if (Node.isCallExpression(parent) && parent.getExpression() === id && + parent.getArguments().length === 0) { + node = parent; + to = target.call; + } + } else { + to = target.value; + } + if (to === undefined) { + complete = false; + result.left.push({ name: local, line: id.getStartLineNumber(), module: rule.module }); + break; + } + // A shorthand property (`{ os }`) cannot take a dotted value; expand + // it to an explicit assignment, keeping the property name. + if (Node.isShorthandPropertyAssignment(parent)) { + node = parent; + to = `${id.getText()}: ${to}`; + } + specEdits.push({ start: node.getStart(), end: node.getEnd(), text: to }); + specReplacements.push({ line: node.getStartLineNumber(), from: node.getText(), to }); + } + if (complete) { + edits.push(...specEdits); + result.replacements.push(...specReplacements); + removed.push(spec); + } + } + if (removed.length === 0) continue; + result.importsRemoved[rule.module] = removed.map((s) => s.getName()).sort(byName); + const keep = decl.getNamedImports().filter((s) => !removed.includes(s)); + if (keep.length === 0 && !decl.getDefaultImport() && !decl.getNamespaceImport()) { + let end = decl.getEnd(); + if (text.startsWith("\r\n", end)) end += 2; + else if (text.startsWith("\n", end)) end += 1; + edits.push({ start: decl.getStart(), end, text: "" }); + } else { + edits.push({ + start: decl.getStart(), + end: decl.getEnd(), + text: renderImport(decl, keep.map((s) => s.getText()), decl.isTypeOnly()), + }); + } + } + return { result, edits }; +} + +function parseArgs(argv: string[]) { + const opts = { + rules: [] as RuleName[], + dirs: [] as string[], + files: [] as string[], + dryRun: false, + reverse: false, + ralDir: join(Deno.cwd(), "src", "deno_ral"), + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--dry-run") opts.dryRun = true; + else if (a === "--reverse") opts.reverse = true; + else if (a === "--rules") opts.rules = argv[++i].split(",") as RuleName[]; + else if (a === "--dir") opts.dirs.push(argv[++i]); + else if (a === "--file") opts.files.push(argv[++i]); + else if (a === "--ral-dir") opts.ralDir = resolve(argv[++i]); + else throw new Error(`unknown argument: ${a}`); + } + if (opts.rules.length === 0) throw new Error("--rules is required"); + for (const r of opts.rules) { + if (!(r in RULES)) { + throw new Error(`unknown rule: ${r} (expected ${Object.keys(RULES).join(", ")})`); + } + } + if (opts.dirs.length + opts.files.length === 0) { + throw new Error("--dir or --file is required"); + } + return opts; +} + +if (import.meta.main) { + const opts = parseArgs(Deno.args); + const files = [...opts.files, ...opts.dirs.flatMap((d) => collectFiles(d))]; + let rewritten = 0, replacements = 0, withCollisions = 0, skipped = 0, left = 0; + for (const file of files) { + const r = codemodFile(file, opts.rules, { + ralDir: opts.ralDir, + dryRun: opts.dryRun, + reverse: opts.reverse, + }); + const rel = relative(Deno.cwd(), file).replaceAll("\\", "/"); + if (r.skipped) { + skipped++; + console.log(`${rel}: skipped: ${r.skipped}`); + continue; + } + for (const c of r.collisions) { + console.log( + `${rel}:${c.line}: collision: local binding '${c.name}' conflicts with deno_ral/${c.module}; left as Deno.*`, + ); + } + for (const c of r.left) { + console.log( + `${rel}:${c.line}: left: '${c.name}' (deno_ral/${c.module}) ${c.hint ?? "has a use --reverse cannot express"}; edit by hand`, + ); + } + if (r.collisions.length > 0) withCollisions++; + left += r.left.length; + if (r.replacements.length > 0) { + rewritten++; + replacements += r.replacements.length; + const changes = Object.entries(opts.reverse ? r.importsRemoved : r.importsAdded) + .map(([m, n]) => `${m}: ${n.join(", ")}`).join("; "); + const verb = opts.reverse ? "import removed" : "import"; + console.log(`${rel}: ${r.replacements.length} replacement(s)${changes ? `, ${verb} ${changes}` : ""}`); + if (opts.dryRun) { + for (const rep of r.replacements) console.log(` ${rel}:${rep.line}: ${rep.from} → ${rep.to}`); + } + } + } + console.log( + `Summary: ${files.length} files scanned, ${rewritten} ${opts.dryRun ? "would be " : ""}rewritten, ${replacements} replacements, ${withCollisions} with collisions, ${skipped} skipped${opts.reverse ? `, ${left} left` : ""}`, + ); +} diff --git a/tools/codemod/codemod-deno-ral.test.ts b/tools/codemod/codemod-deno-ral.test.ts new file mode 100644 index 0000000000..284c0cbda1 --- /dev/null +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -0,0 +1,366 @@ +/* + * codemod-deno-ral.test.ts + * + * Copyright (C) 2026 Posit Software, PBC + * + * Contract for tools/codemod-deno-ral.ts. Lives under tools/, not tests/, so + * that run-tests.sh never loads npm:ts-morph; CI runs it in the + * runtime-boundary workflow: + * package/dist/bin/tools/x86_64/deno test --allow-all --import-map=src/import_map.json tools/codemod/ + */ + +import { assert, assertEquals } from "testing/asserts"; +import { dirname, fromFileUrl, join, relative } from "../../src/deno_ral/path.ts"; +import { + codemodFile, + type CodemodResult, + collectFiles, + kCanonicalReverseHint, + type RuleName, +} from "../codemod-deno-ral.ts"; + +const fixtures = join(dirname(fromFileUrl(import.meta.url)), "fixtures"); +const read = (name: string) => Deno.readTextFileSync(join(fixtures, name)); + +// Copy a fixture to /src/core/.ts so that the import path to the +// (fake) /src/deno_ral is "../deno_ral/.ts", then run the codemod. +function run( + name: string, + rules: RuleName[], + opts: { reverse?: boolean; input?: string; path?: string } = {}, +): { result: CodemodResult; output: string } { + const tmp = Deno.makeTempDirSync({ prefix: "codemod-" }); + try { + const target = join(tmp, ...(opts.path ?? `src/core/${name}.ts`).split("/")); + Deno.mkdirSync(dirname(target), { recursive: true }); + Deno.copyFileSync(join(fixtures, opts.input ?? `${name}.input.ts`), target); + const result = codemodFile(target, rules, { + ralDir: join(tmp, "src", "deno_ral"), + dryRun: false, + reverse: opts.reverse, + }); + return { result, output: Deno.readTextFileSync(target) }; + } finally { + Deno.removeSync(tmp, { recursive: true }); + } +} + +Deno.test("codemod - fs calls and types with a new import", () => { + const { result, output } = run("fs-basic", ["fs"]); + assertEquals(output, read("fs-basic.expected.ts")); + assertEquals(result.replacements.length, 4); + assertEquals(result.collisions, []); + assertEquals(result.importsAdded, { + "fs.ts": ["FileInfo", "lstatSync", "readTextFileSync", "statSync"], + }); +}); + +Deno.test("codemod - a local binding blocks only that name", () => { + const { result, output } = run("fs-collision", ["fs"]); + assertEquals(output, read("fs-collision.expected.ts")); + assertEquals(result.collisions, [{ name: "remove", line: 3, module: "fs.ts" }]); + assertEquals(result.replacements.length, 1); +}); + +Deno.test("codemod - merges into an existing deno_ral import", () => { + const { result, output } = run("existing-import", ["fs"]); + assertEquals(output, read("existing-import.expected.ts")); + assertEquals(result.importsAdded, { "fs.ts": ["statSync"] }); +}); + +Deno.test("codemod - keeps an existing alias and reuses it", () => { + const { result, output } = run("existing-alias", ["platform"]); + assertEquals(output, read("existing-alias.expected.ts")); + assertEquals(result.replacements.length, 2); + assertEquals(result.importsAdded, { "platform.ts": ["arch"] }); +}); + +Deno.test("codemod - leaves an import type declaration type-only", () => { + const { output } = run("type-only-existing", ["fs"]); + assertEquals(output, read("type-only-existing.expected.ts")); +}); + +Deno.test("codemod - an existing layer import is not a collision (idempotent)", () => { + const { result, output } = run("already-migrated", ["fs"]); + assertEquals(output, read("already-migrated.expected.ts")); + assertEquals(result.collisions, []); + assertEquals(result.importsAdded, {}); + const again = run("already-migrated", ["fs"], { input: "already-migrated.expected.ts" }); + assertEquals(again.result.replacements, []); + assertEquals(again.output, read("already-migrated.expected.ts")); +}); + +Deno.test("codemod - Deno.realPathSync becomes canonicalPathSync", () => { + const { output } = run("realpath", ["fs"]); + assertEquals(output, read("realpath.expected.ts")); +}); + +Deno.test("codemod - inserts after the header comment when there are no imports", () => { + const { output } = run("no-imports", ["fs"]); + assertEquals(output, read("no-imports.expected.ts")); +}); + +Deno.test("codemod - temp rule maps MakeTempOptions to TempOptions", () => { + const { output } = run("temp-basic", ["temp"]); + assertEquals(output, read("temp-basic.expected.ts")); +}); + +Deno.test("codemod - env rule rewrites the Deno.env expression", () => { + const { result, output } = run("env-basic", ["env"]); + assertEquals(output, read("env-basic.expected.ts")); + assertEquals(result.replacements.length, 3); +}); + +Deno.test("codemod - env rule reports a local env binding", () => { + const { result, output } = run("env-collision", ["env"]); + assertEquals(output, read("env-collision.expected.ts")); + assertEquals(result.collisions, [{ name: "env", line: 2, module: "env.ts" }]); + assertEquals(result.replacements, []); +}); + +Deno.test("codemod - process rule turns args/pid into calls", () => { + const { output } = run("process-basic", ["process"]); + assertEquals(output, read("process-basic.expected.ts")); +}); + +Deno.test("codemod - platform rule rewrites Deno.build.os/arch", () => { + const { output } = run("platform-basic", ["platform"]); + assertEquals(output, read("platform-basic.expected.ts")); +}); + +Deno.test("codemod - a locally bound Deno receiver is left alone", () => { + // Shadowing is per meaning: a value-only `Deno` parameter does not hide the + // global namespace in `Deno.FileInfo`, and a local `type Deno` does not hide + // the global value in `Deno.readTextFileSync(...)`. + const { result, output } = run("shadowed-deno", ["fs"]); + assertEquals(output, read("shadowed-deno.expected.ts")); + assertEquals(result.replacements.length, 4); + assertEquals(result.collisions, []); +}); + +Deno.test("codemod - a type-only namespace import is not overwritten", () => { + const { result, output } = run("type-ns-import", ["fs"]); + assertEquals(output, read("type-ns-import.expected.ts")); + assertEquals(result.importsAdded, { "fs.ts": ["DirEntry"] }); +}); + +Deno.test("codemod - a type-only import conflicts with a value rewrite", () => { + const { result, output } = run("type-value-conflict", ["fs"]); + // Both the inline `type statSync` and the declaration-level + // `import type { lstatSync }` block the value rewrite; the file is unchanged. + assertEquals(output, read("type-value-conflict.input.ts")); + assertEquals(result.replacements, []); + assertEquals(result.collisions, [ + { name: "statSync", line: 1, module: "fs.ts" }, + { name: "lstatSync", line: 2, module: "fs.ts" }, + ]); +}); + +Deno.test("codemod - a file assigning to Deno.* is skipped whole", () => { + const { result, output } = run("assignment-skip", ["fs"]); + assertEquals(result.skipped, "assigns to Deno.readTextFileSync at line 2"); + assertEquals(result.replacements, []); + assertEquals(output, read("assignment-skip.input.ts")); +}); + +Deno.test("codemod - every form of write to Deno.* skips the file", () => { + const cases: [string, RuleName[], string][] = [ + ["write-compound", ["fs"], "assigns to Deno.readTextFileSync at line 2"], + ["write-nested", ["env", "platform"], "assigns to Deno.build.os at line 2"], + ["write-delete", ["env"], "assigns to Deno.env at line 2"], + ["write-destructure-object", ["fs"], "assigns to Deno.readTextFileSync at line 2"], + ["write-destructure-array", ["platform"], "assigns to Deno.build.os at line 2"], + ["write-for-of", ["platform"], "assigns to Deno.build.os at line 2"], + ["write-asserted-target", ["fs"], "assigns to Deno.readTextFileSync at line 2"], + ["write-non-null", ["platform"], "assigns to Deno.build.os at line 2"], + // A patch through a wrapped receiver: rewriting `original` to the layer + // wrapper would make the patched function call itself. + [ + "write-asserted-receiver", + ["fs"], + "assigns to (Deno as any).readTextFileSync at line 2", + ], + ["write-asserted-nested", ["platform"], "assigns to ( Deno.build).os at line 2"], + ["write-element-access", ["fs"], 'assigns to Deno["readTextFileSync"] at line 2'], + // Deno used other than as `Deno.x`: what is done with it cannot be followed. + ["write-define-property", ["fs"], "uses Deno other than as Deno.x at line 2"], + ["write-define-property-paren", ["fs"], "uses Deno other than as Deno.x at line 2"], + ["write-alias", ["fs"], "uses Deno other than as Deno.x at line 1"], + ["write-shorthand", ["fs"], "uses Deno other than as Deno.x at line 1"], + ["write-global-this", ["fs"], "reaches Deno through globalThis.Deno at line 2"], + ["write-global-this-element", ["fs"], 'reaches Deno through self["Deno"] at line 2'], + [ + "write-global-this-template", + ["fs"], + "reaches Deno through globalThis[`Deno`] at line 2", + ], + [ + "write-global-this-paren-key", + ["fs"], + 'reaches Deno through globalThis[("Deno")] at line 2', + ], + ["write-object-assign", ["platform"], "assigns to Deno.build via Object.assign at line 2"], + [ + "write-object-assign-paren", + ["platform"], + "assigns to Deno.build via (Object.assign) at line 2", + ], + [ + "write-object-assign-element", + ["platform"], + 'assigns to Deno.build via Object["assign"] at line 2', + ], + [ + "write-object-assign-template", + ["platform"], + "assigns to Deno.build via Object[`assign`] at line 2", + ], + [ + "write-object-assign-paren-key", + ["platform"], + 'assigns to Deno.build via Object[("assign")] at line 2', + ], + ]; + for (const [name, rules, skipped] of cases) { + const { result, output } = run(name, rules); + assertEquals(result.skipped, skipped, name); + assertEquals(result.replacements, [], name); + assertEquals(output, read(`${name}.input.ts`), name); + } +}); + +Deno.test("codemod - a listed patch file is skipped even when the guard misses its write", () => { + for (const path of ["src/core/deno/monkey-patch.ts", "src/core/performance/metrics.ts"]) { + const { result, output } = run("patch-file-backstop", ["fs"], { path }); + assertEquals(result.skipped, "listed in kCodemodPatchFiles", path); + assertEquals(result.replacements, [], path); + assertEquals(output, read("patch-file-backstop.input.ts"), path); + } +}); + +Deno.test("codemod - Deno.* inside object and array literals that are not written is rewritten", () => { + const { result, output } = run("literal-not-write", ["fs"]); + assertEquals(result.skipped, undefined); + assertEquals(output, read("literal-not-write.expected.ts")); +}); + +Deno.test("codemod - Deno.x as a later argument and typeof Deno are not writes", () => { + const { result, output } = run("mutator-not-write", ["fs"]); + assertEquals(result.skipped, undefined); + assertEquals(output, read("mutator-not-write.expected.ts")); +}); + +Deno.test("codemod - a call on an owner named like an Object.prototype key is not a mutator", () => { + const { result, output } = run("mutator-inherited-key", ["fs"]); + assertEquals(result.skipped, undefined); + assertEquals(output, read("mutator-inherited-key.expected.ts")); +}); + +Deno.test("codemod --reverse - round-trips the basic fixtures", () => { + const cases: [string, RuleName[]][] = [ + ["fs-basic", ["fs"]], + ["no-imports", ["fs"]], + ["temp-basic", ["temp"]], + ["env-basic", ["env"]], + ["process-basic", ["process"]], + ["platform-basic", ["platform"]], + ]; + for (const [name, rules] of cases) { + const { output } = run(name, rules, { reverse: true, input: `${name}.expected.ts` }); + assertEquals(output, read(`${name}.input.ts`), name); + } +}); + +Deno.test("codemod --reverse - expands shorthand properties to explicit assignments", () => { + const { result, output } = run("reverse-shorthand", ["platform"], { reverse: true }); + assertEquals(output, read("reverse-shorthand.expected.ts")); + assertEquals(result.importsRemoved, { "platform.ts": ["arch", "os"] }); +}); + +Deno.test("codemod --reverse - keeps an import whose Deno.* would be shadowed", () => { + const { result, output } = run("reverse-shadowed-deno", ["fs", "platform"], { reverse: true }); + assertEquals(output, read("reverse-shadowed-deno.expected.ts")); + assertEquals(result.left, [ + { name: "statSync", line: 4, module: "fs.ts" }, + { name: "os", line: 7, module: "platform.ts" }, + ]); + assertEquals(result.importsRemoved, { "fs.ts": ["FileInfo"] }); +}); + +Deno.test("codemod --reverse - keeps an import that the file re-exports", () => { + const { result, output } = run("reverse-reexport", ["platform", "process"], { reverse: true }); + assertEquals(output, read("reverse-reexport.expected.ts")); + assertEquals(result.left, [ + { name: "os", line: 3, module: "platform.ts" }, + { name: "cwd", line: 4, module: "process.ts" }, + ]); + assertEquals(result.importsRemoved, { "platform.ts": ["arch"] }); +}); + +Deno.test("codemod --reverse - follows aliases and reports what it cannot express", () => { + const alias = run("existing-alias", ["platform"], { + reverse: true, + input: "existing-alias.expected.ts", + }); + assertEquals(alias.output, read("reverse-alias.expected.ts")); + const left = run("reverse-left", ["process"], { reverse: true }); + assertEquals(left.output, read("reverse-left.expected.ts")); + assertEquals(left.result.left, [{ name: "args", line: 3, module: "process.ts" }]); + assertEquals(left.result.importsRemoved, { "process.ts": ["exit"] }); +}); + +Deno.test("codemod --reverse - reports canonicalPathSync instead of reversing it", () => { + const { result, output } = run("reverse-realpath", ["fs"], { reverse: true }); + assertEquals(output, read("reverse-realpath.expected.ts")); + assertEquals(result.left, [ + { name: "canonicalPathSync", line: 2, module: "fs.ts", hint: kCanonicalReverseHint }, + ]); + assertEquals(result.importsRemoved, { "fs.ts": ["statSync"] }); + assertEquals(result.replacements.length, 1); + + // A re-export keeps the import and still carries the hint. + const reexport = run("reverse-realpath-reexport", ["fs"], { reverse: true }); + assertEquals(reexport.output, read("reverse-realpath-reexport.input.ts")); + assertEquals(reexport.result.left, [ + { name: "canonicalPathSync", line: 2, module: "fs.ts", hint: kCanonicalReverseHint }, + ]); + assertEquals(reexport.result.replacements, []); +}); + +Deno.test("codemod - collectFiles applies the skip list", () => { + const tmp = Deno.makeTempDirSync({ prefix: "codemod-walk-" }); + try { + for ( + const rel of [ + "src/core/a.ts", + "src/core/b.d.ts", + "src/core/t.ejs.ts", + "src/core/node_modules/x/index.ts", + "src/core/lib/l.ts", + "src/deno_ral/fs.ts", + "src/vendor/v.ts", + "src/resources/extension-subtrees/julia-engine/src/j.ts", + "src/resources/scripts/s.ts", + "src/resources/create/c.ts", + "src/resources/tools/ast-diagram/main.ts", + "src/webui/quarto-preview/build.ts", + "src/webui/quarto-preview/src/x.ts", + "src/command/c.ts", + ] + ) { + const p = join(tmp, ...rel.split("/")); + Deno.mkdirSync(dirname(p), { recursive: true }); + Deno.writeTextFileSync(p, ""); + } + const files = collectFiles(join(tmp, "src"), tmp) + .map((f) => relative(tmp, f).replaceAll("\\", "/")); + assertEquals(files, [ + "src/command/c.ts", + "src/core/a.ts", + "src/webui/quarto-preview/src/x.ts", + ]); + assert(!files.some((f) => f.startsWith("src/core/lib/"))); + } finally { + Deno.removeSync(tmp, { recursive: true }); + } +}); diff --git a/tools/codemod/fixtures/already-migrated.expected.ts b/tools/codemod/fixtures/already-migrated.expected.ts new file mode 100644 index 0000000000..f0cbb89b90 --- /dev/null +++ b/tools/codemod/fixtures/already-migrated.expected.ts @@ -0,0 +1,6 @@ +import { stat as fsStat, statSync } from "../deno_ral/fs.ts"; + +export async function sizes(a: string, b: string): Promise { + const stat = await fsStat(a); + return stat.size + statSync(a).size + statSync(b).size + (await fsStat(b)).size; +} diff --git a/tools/codemod/fixtures/already-migrated.input.ts b/tools/codemod/fixtures/already-migrated.input.ts new file mode 100644 index 0000000000..dddded9d3e --- /dev/null +++ b/tools/codemod/fixtures/already-migrated.input.ts @@ -0,0 +1,6 @@ +import { stat as fsStat, statSync } from "../deno_ral/fs.ts"; + +export async function sizes(a: string, b: string): Promise { + const stat = await Deno.stat(a); + return stat.size + statSync(a).size + Deno.statSync(b).size + (await fsStat(b)).size; +} diff --git a/tools/codemod/fixtures/assignment-skip.input.ts b/tools/codemod/fixtures/assignment-skip.input.ts new file mode 100644 index 0000000000..dc157d97a0 --- /dev/null +++ b/tools/codemod/fixtures/assignment-skip.input.ts @@ -0,0 +1,3 @@ +const original = Deno.readTextFileSync; +Deno.readTextFileSync = (path: string | URL) => original(path); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/env-basic.expected.ts b/tools/codemod/fixtures/env-basic.expected.ts new file mode 100644 index 0000000000..88b9c4b2f2 --- /dev/null +++ b/tools/codemod/fixtures/env-basic.expected.ts @@ -0,0 +1,6 @@ +import { env } from "../deno_ral/env.ts"; +export function home(): string { + env.set("QUARTO_X", "1"); + const all = env.toObject(); + return env.get("HOME") ?? all["USERPROFILE"] ?? ""; +} diff --git a/tools/codemod/fixtures/env-basic.input.ts b/tools/codemod/fixtures/env-basic.input.ts new file mode 100644 index 0000000000..879bf251d3 --- /dev/null +++ b/tools/codemod/fixtures/env-basic.input.ts @@ -0,0 +1,5 @@ +export function home(): string { + Deno.env.set("QUARTO_X", "1"); + const all = Deno.env.toObject(); + return Deno.env.get("HOME") ?? all["USERPROFILE"] ?? ""; +} diff --git a/tools/codemod/fixtures/env-collision.expected.ts b/tools/codemod/fixtures/env-collision.expected.ts new file mode 100644 index 0000000000..60baf29462 --- /dev/null +++ b/tools/codemod/fixtures/env-collision.expected.ts @@ -0,0 +1,4 @@ +export function mode(): string { + const env = Deno.env.get("MODE"); + return env ?? "production"; +} diff --git a/tools/codemod/fixtures/env-collision.input.ts b/tools/codemod/fixtures/env-collision.input.ts new file mode 100644 index 0000000000..60baf29462 --- /dev/null +++ b/tools/codemod/fixtures/env-collision.input.ts @@ -0,0 +1,4 @@ +export function mode(): string { + const env = Deno.env.get("MODE"); + return env ?? "production"; +} diff --git a/tools/codemod/fixtures/existing-alias.expected.ts b/tools/codemod/fixtures/existing-alias.expected.ts new file mode 100644 index 0000000000..d9e4674b32 --- /dev/null +++ b/tools/codemod/fixtures/existing-alias.expected.ts @@ -0,0 +1,9 @@ +import { join } from "../deno_ral/path.ts"; +import { arch, os as platformOs } from "../deno_ral/platform.ts"; + +export function binDir(base: string): string { + if (platformOs === "linux") { + return join(base, `bin/${arch}-linux`); + } + return platformOs; +} diff --git a/tools/codemod/fixtures/existing-alias.input.ts b/tools/codemod/fixtures/existing-alias.input.ts new file mode 100644 index 0000000000..a285675f2d --- /dev/null +++ b/tools/codemod/fixtures/existing-alias.input.ts @@ -0,0 +1,9 @@ +import { join } from "../deno_ral/path.ts"; +import { os as platformOs } from "../deno_ral/platform.ts"; + +export function binDir(base: string): string { + if (Deno.build.os === "linux") { + return join(base, `bin/${Deno.build.arch}-linux`); + } + return platformOs; +} diff --git a/tools/codemod/fixtures/existing-import.expected.ts b/tools/codemod/fixtures/existing-import.expected.ts new file mode 100644 index 0000000000..d9629d4192 --- /dev/null +++ b/tools/codemod/fixtures/existing-import.expected.ts @@ -0,0 +1,8 @@ +import { existsSync, statSync } from "../deno_ral/fs.ts"; + +export function sizeOf(path: string): number { + if (!existsSync(path)) { + return 0; + } + return statSync(path).size; +} diff --git a/tools/codemod/fixtures/existing-import.input.ts b/tools/codemod/fixtures/existing-import.input.ts new file mode 100644 index 0000000000..b207602c2a --- /dev/null +++ b/tools/codemod/fixtures/existing-import.input.ts @@ -0,0 +1,8 @@ +import { existsSync } from "../deno_ral/fs.ts"; + +export function sizeOf(path: string): number { + if (!existsSync(path)) { + return 0; + } + return Deno.statSync(path).size; +} diff --git a/tools/codemod/fixtures/fs-basic.expected.ts b/tools/codemod/fixtures/fs-basic.expected.ts new file mode 100644 index 0000000000..6b35fffdc6 --- /dev/null +++ b/tools/codemod/fixtures/fs-basic.expected.ts @@ -0,0 +1,17 @@ +/* + * fixture + */ +import { join } from "../deno_ral/path.ts"; +import { type FileInfo, lstatSync, readTextFileSync, statSync } from "../deno_ral/fs.ts"; + +export function readIt(dir: string): string { + const info = statSync(dir); + if (!info.isDirectory) { + throw new Error("not a dir"); + } + return readTextFileSync(join(dir, "a.txt")); +} + +export function infoOf(path: string): FileInfo { + return lstatSync(path); +} diff --git a/tools/codemod/fixtures/fs-basic.input.ts b/tools/codemod/fixtures/fs-basic.input.ts new file mode 100644 index 0000000000..8ffefc82ed --- /dev/null +++ b/tools/codemod/fixtures/fs-basic.input.ts @@ -0,0 +1,16 @@ +/* + * fixture + */ +import { join } from "../deno_ral/path.ts"; + +export function readIt(dir: string): string { + const info = Deno.statSync(dir); + if (!info.isDirectory) { + throw new Error("not a dir"); + } + return Deno.readTextFileSync(join(dir, "a.txt")); +} + +export function infoOf(path: string): Deno.FileInfo { + return Deno.lstatSync(path); +} diff --git a/tools/codemod/fixtures/fs-collision.expected.ts b/tools/codemod/fixtures/fs-collision.expected.ts new file mode 100644 index 0000000000..08939e8fd8 --- /dev/null +++ b/tools/codemod/fixtures/fs-collision.expected.ts @@ -0,0 +1,11 @@ +import { join } from "../deno_ral/path.ts"; +import { readTextFileSync } from "../deno_ral/fs.ts"; + +function remove(path: string) { + return Deno.remove(path); +} + +export function readIt(dir: string): string { + remove(dir); + return readTextFileSync(join(dir, "a.txt")); +} diff --git a/tools/codemod/fixtures/fs-collision.input.ts b/tools/codemod/fixtures/fs-collision.input.ts new file mode 100644 index 0000000000..5217897bb5 --- /dev/null +++ b/tools/codemod/fixtures/fs-collision.input.ts @@ -0,0 +1,10 @@ +import { join } from "../deno_ral/path.ts"; + +function remove(path: string) { + return Deno.remove(path); +} + +export function readIt(dir: string): string { + remove(dir); + return Deno.readTextFileSync(join(dir, "a.txt")); +} diff --git a/tools/codemod/fixtures/literal-not-write.expected.ts b/tools/codemod/fixtures/literal-not-write.expected.ts new file mode 100644 index 0000000000..179267ff6a --- /dev/null +++ b/tools/codemod/fixtures/literal-not-write.expected.ts @@ -0,0 +1,3 @@ +import { lstatSync, readFileSync, readTextFileSync, statSync } from "../deno_ral/fs.ts"; +export const fns = { read: readTextFileSync, list: [readFileSync] }; +export const all = [...[statSync], { ...{ s: lstatSync } }]; diff --git a/tools/codemod/fixtures/literal-not-write.input.ts b/tools/codemod/fixtures/literal-not-write.input.ts new file mode 100644 index 0000000000..db64aa6134 --- /dev/null +++ b/tools/codemod/fixtures/literal-not-write.input.ts @@ -0,0 +1,2 @@ +export const fns = { read: Deno.readTextFileSync, list: [Deno.readFileSync] }; +export const all = [...[Deno.statSync], { ...{ s: Deno.lstatSync } }]; diff --git a/tools/codemod/fixtures/mutator-inherited-key.expected.ts b/tools/codemod/fixtures/mutator-inherited-key.expected.ts new file mode 100644 index 0000000000..a5ad211a8d --- /dev/null +++ b/tools/codemod/fixtures/mutator-inherited-key.expected.ts @@ -0,0 +1,4 @@ +import { readTextFileSync } from "../deno_ral/fs.ts"; +const constructor = { inspect: (f: unknown) => String(f) }; +export const shown = constructor.inspect(readTextFileSync); +export const text = readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/mutator-inherited-key.input.ts b/tools/codemod/fixtures/mutator-inherited-key.input.ts new file mode 100644 index 0000000000..24e3932ab9 --- /dev/null +++ b/tools/codemod/fixtures/mutator-inherited-key.input.ts @@ -0,0 +1,3 @@ +const constructor = { inspect: (f: unknown) => String(f) }; +export const shown = constructor.inspect(Deno.readTextFileSync); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/mutator-not-write.expected.ts b/tools/codemod/fixtures/mutator-not-write.expected.ts new file mode 100644 index 0000000000..85e2d93b49 --- /dev/null +++ b/tools/codemod/fixtures/mutator-not-write.expected.ts @@ -0,0 +1,4 @@ +import { readTextFileSync } from "../deno_ral/fs.ts"; +export const copy = Object.assign({}, { read: readTextFileSync }); +export const hasDeno = typeof Deno !== "undefined"; +export const text = readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/mutator-not-write.input.ts b/tools/codemod/fixtures/mutator-not-write.input.ts new file mode 100644 index 0000000000..c19ec95785 --- /dev/null +++ b/tools/codemod/fixtures/mutator-not-write.input.ts @@ -0,0 +1,3 @@ +export const copy = Object.assign({}, { read: Deno.readTextFileSync }); +export const hasDeno = typeof Deno !== "undefined"; +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/no-imports.expected.ts b/tools/codemod/fixtures/no-imports.expected.ts new file mode 100644 index 0000000000..05fd22e9f2 --- /dev/null +++ b/tools/codemod/fixtures/no-imports.expected.ts @@ -0,0 +1,6 @@ +/* + * no imports at all + */ + +import { readTextFileSync } from "../deno_ral/fs.ts"; +export const text = readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/no-imports.input.ts b/tools/codemod/fixtures/no-imports.input.ts new file mode 100644 index 0000000000..9d1cbd8857 --- /dev/null +++ b/tools/codemod/fixtures/no-imports.input.ts @@ -0,0 +1,5 @@ +/* + * no imports at all + */ + +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/patch-file-backstop.input.ts b/tools/codemod/fixtures/patch-file-backstop.input.ts new file mode 100644 index 0000000000..1f9dddbba1 --- /dev/null +++ b/tools/codemod/fixtures/patch-file-backstop.input.ts @@ -0,0 +1,3 @@ +const original = Deno.readTextFileSync; +const D = Reflect.get(globalThis, "Deno"); +D.readTextFileSync = (p: string) => "patched:" + original(p); diff --git a/tools/codemod/fixtures/platform-basic.expected.ts b/tools/codemod/fixtures/platform-basic.expected.ts new file mode 100644 index 0000000000..0fcb36bb98 --- /dev/null +++ b/tools/codemod/fixtures/platform-basic.expected.ts @@ -0,0 +1,3 @@ +import { arch, os } from "../deno_ral/platform.ts"; +export const sep = os === "windows" ? "\\" : "/"; +export const bits = arch; diff --git a/tools/codemod/fixtures/platform-basic.input.ts b/tools/codemod/fixtures/platform-basic.input.ts new file mode 100644 index 0000000000..a70b7fff16 --- /dev/null +++ b/tools/codemod/fixtures/platform-basic.input.ts @@ -0,0 +1,2 @@ +export const sep = Deno.build.os === "windows" ? "\\" : "/"; +export const bits = Deno.build.arch; diff --git a/tools/codemod/fixtures/process-basic.expected.ts b/tools/codemod/fixtures/process-basic.expected.ts new file mode 100644 index 0000000000..c86340296d --- /dev/null +++ b/tools/codemod/fixtures/process-basic.expected.ts @@ -0,0 +1,7 @@ +import { args, cwd, execPath, exit, pid } from "../deno_ral/process.ts"; +export function main() { + if (args().length === 0) { + console.log(cwd(), execPath(), pid()); + exit(1); + } +} diff --git a/tools/codemod/fixtures/process-basic.input.ts b/tools/codemod/fixtures/process-basic.input.ts new file mode 100644 index 0000000000..0928e86277 --- /dev/null +++ b/tools/codemod/fixtures/process-basic.input.ts @@ -0,0 +1,6 @@ +export function main() { + if (Deno.args.length === 0) { + console.log(Deno.cwd(), Deno.execPath(), Deno.pid); + Deno.exit(1); + } +} diff --git a/tools/codemod/fixtures/realpath.expected.ts b/tools/codemod/fixtures/realpath.expected.ts new file mode 100644 index 0000000000..b55a673c9e --- /dev/null +++ b/tools/codemod/fixtures/realpath.expected.ts @@ -0,0 +1,2 @@ +import { canonicalPathSync } from "../deno_ral/fs.ts"; +export const canonical = (p: string) => canonicalPathSync(p); diff --git a/tools/codemod/fixtures/realpath.input.ts b/tools/codemod/fixtures/realpath.input.ts new file mode 100644 index 0000000000..bf8eaccc2b --- /dev/null +++ b/tools/codemod/fixtures/realpath.input.ts @@ -0,0 +1 @@ +export const canonical = (p: string) => Deno.realPathSync(p); diff --git a/tools/codemod/fixtures/reverse-alias.expected.ts b/tools/codemod/fixtures/reverse-alias.expected.ts new file mode 100644 index 0000000000..e96c4757f1 --- /dev/null +++ b/tools/codemod/fixtures/reverse-alias.expected.ts @@ -0,0 +1,8 @@ +import { join } from "../deno_ral/path.ts"; + +export function binDir(base: string): string { + if (Deno.build.os === "linux") { + return join(base, `bin/${Deno.build.arch}-linux`); + } + return Deno.build.os; +} diff --git a/tools/codemod/fixtures/reverse-left.expected.ts b/tools/codemod/fixtures/reverse-left.expected.ts new file mode 100644 index 0000000000..854c4bcc02 --- /dev/null +++ b/tools/codemod/fixtures/reverse-left.expected.ts @@ -0,0 +1,6 @@ +import { args } from "../deno_ral/process.ts"; + +export const argv = args; +export function stop() { + Deno.exit(1); +} diff --git a/tools/codemod/fixtures/reverse-left.input.ts b/tools/codemod/fixtures/reverse-left.input.ts new file mode 100644 index 0000000000..4b1cd96ab7 --- /dev/null +++ b/tools/codemod/fixtures/reverse-left.input.ts @@ -0,0 +1,6 @@ +import { args, exit } from "../deno_ral/process.ts"; + +export const argv = args; +export function stop() { + exit(1); +} diff --git a/tools/codemod/fixtures/reverse-realpath-reexport.input.ts b/tools/codemod/fixtures/reverse-realpath-reexport.input.ts new file mode 100644 index 0000000000..ffa9f0b64d --- /dev/null +++ b/tools/codemod/fixtures/reverse-realpath-reexport.input.ts @@ -0,0 +1,2 @@ +import { canonicalPathSync } from "../deno_ral/fs.ts"; +export { canonicalPathSync }; diff --git a/tools/codemod/fixtures/reverse-realpath.expected.ts b/tools/codemod/fixtures/reverse-realpath.expected.ts new file mode 100644 index 0000000000..3de2cb13a5 --- /dev/null +++ b/tools/codemod/fixtures/reverse-realpath.expected.ts @@ -0,0 +1,2 @@ +import { canonicalPathSync } from "../deno_ral/fs.ts"; +export const info = (p: string) => Deno.statSync(canonicalPathSync(p)); diff --git a/tools/codemod/fixtures/reverse-realpath.input.ts b/tools/codemod/fixtures/reverse-realpath.input.ts new file mode 100644 index 0000000000..8a00820107 --- /dev/null +++ b/tools/codemod/fixtures/reverse-realpath.input.ts @@ -0,0 +1,2 @@ +import { canonicalPathSync, statSync } from "../deno_ral/fs.ts"; +export const info = (p: string) => statSync(canonicalPathSync(p)); diff --git a/tools/codemod/fixtures/reverse-reexport.expected.ts b/tools/codemod/fixtures/reverse-reexport.expected.ts new file mode 100644 index 0000000000..f6e43cf30e --- /dev/null +++ b/tools/codemod/fixtures/reverse-reexport.expected.ts @@ -0,0 +1,5 @@ +import { os } from "../deno_ral/platform.ts"; +import { cwd } from "../deno_ral/process.ts"; +export { os }; +export { cwd as currentDir }; +export const a = Deno.build.arch; diff --git a/tools/codemod/fixtures/reverse-reexport.input.ts b/tools/codemod/fixtures/reverse-reexport.input.ts new file mode 100644 index 0000000000..05797e2061 --- /dev/null +++ b/tools/codemod/fixtures/reverse-reexport.input.ts @@ -0,0 +1,5 @@ +import { arch, os } from "../deno_ral/platform.ts"; +import { cwd } from "../deno_ral/process.ts"; +export { os }; +export { cwd as currentDir }; +export const a = arch; diff --git a/tools/codemod/fixtures/reverse-shadowed-deno.expected.ts b/tools/codemod/fixtures/reverse-shadowed-deno.expected.ts new file mode 100644 index 0000000000..5fc3248a14 --- /dev/null +++ b/tools/codemod/fixtures/reverse-shadowed-deno.expected.ts @@ -0,0 +1,11 @@ +import { statSync } from "../deno_ral/fs.ts"; +import { os } from "../deno_ral/platform.ts"; +export function viaParam(Deno: { statSync(p: string): unknown }, p: string) { + return statSync(p); +} +export function osVia(Deno: unknown) { + return { os }; +} +export function info(Deno: unknown, p: string): Deno.FileInfo { + return statSync(p) as Deno.FileInfo; +} diff --git a/tools/codemod/fixtures/reverse-shadowed-deno.input.ts b/tools/codemod/fixtures/reverse-shadowed-deno.input.ts new file mode 100644 index 0000000000..cd9d197727 --- /dev/null +++ b/tools/codemod/fixtures/reverse-shadowed-deno.input.ts @@ -0,0 +1,11 @@ +import { type FileInfo, statSync } from "../deno_ral/fs.ts"; +import { os } from "../deno_ral/platform.ts"; +export function viaParam(Deno: { statSync(p: string): unknown }, p: string) { + return statSync(p); +} +export function osVia(Deno: unknown) { + return { os }; +} +export function info(Deno: unknown, p: string): FileInfo { + return statSync(p) as FileInfo; +} diff --git a/tools/codemod/fixtures/reverse-shorthand.expected.ts b/tools/codemod/fixtures/reverse-shorthand.expected.ts new file mode 100644 index 0000000000..7f479e0687 --- /dev/null +++ b/tools/codemod/fixtures/reverse-shorthand.expected.ts @@ -0,0 +1,3 @@ +export function detectChromePlatform(platform: string) { + return { platform, os: Deno.build.os, arch: Deno.build.arch }; +} diff --git a/tools/codemod/fixtures/reverse-shorthand.input.ts b/tools/codemod/fixtures/reverse-shorthand.input.ts new file mode 100644 index 0000000000..b513305b3c --- /dev/null +++ b/tools/codemod/fixtures/reverse-shorthand.input.ts @@ -0,0 +1,4 @@ +import { arch, os } from "../deno_ral/platform.ts"; +export function detectChromePlatform(platform: string) { + return { platform, os, arch }; +} diff --git a/tools/codemod/fixtures/shadowed-deno.expected.ts b/tools/codemod/fixtures/shadowed-deno.expected.ts new file mode 100644 index 0000000000..13342b5026 --- /dev/null +++ b/tools/codemod/fixtures/shadowed-deno.expected.ts @@ -0,0 +1,24 @@ +import { type FileInfo, readTextFileSync } from "../deno_ral/fs.ts"; +export function readVia( + Deno: { readTextFileSync(path: string): string }, + path: string, +): string { + return Deno.readTextFileSync(path); +} + +export function info(Deno: { FileInfo: unknown }, f: FileInfo): unknown { + return f; +} + +export function readReal(path: string): string { + return readTextFileSync(path); +} + +export function realInfo(f: FileInfo): unknown { + return f; +} + +export function typeOnly(path: string): string { + type Deno = { readTextFileSync: unknown }; + return readTextFileSync(path); +} diff --git a/tools/codemod/fixtures/shadowed-deno.input.ts b/tools/codemod/fixtures/shadowed-deno.input.ts new file mode 100644 index 0000000000..003ea30f46 --- /dev/null +++ b/tools/codemod/fixtures/shadowed-deno.input.ts @@ -0,0 +1,23 @@ +export function readVia( + Deno: { readTextFileSync(path: string): string }, + path: string, +): string { + return Deno.readTextFileSync(path); +} + +export function info(Deno: { FileInfo: unknown }, f: Deno.FileInfo): unknown { + return f; +} + +export function readReal(path: string): string { + return Deno.readTextFileSync(path); +} + +export function realInfo(f: Deno.FileInfo): unknown { + return f; +} + +export function typeOnly(path: string): string { + type Deno = { readTextFileSync: unknown }; + return Deno.readTextFileSync(path); +} diff --git a/tools/codemod/fixtures/temp-basic.expected.ts b/tools/codemod/fixtures/temp-basic.expected.ts new file mode 100644 index 0000000000..7df10962f8 --- /dev/null +++ b/tools/codemod/fixtures/temp-basic.expected.ts @@ -0,0 +1,4 @@ +import { makeTempDirSync, type TempOptions } from "../deno_ral/temp.ts"; +export function scratch(options?: TempOptions): string { + return makeTempDirSync({ prefix: "q-", ...options }); +} diff --git a/tools/codemod/fixtures/temp-basic.input.ts b/tools/codemod/fixtures/temp-basic.input.ts new file mode 100644 index 0000000000..bdbf2f6c72 --- /dev/null +++ b/tools/codemod/fixtures/temp-basic.input.ts @@ -0,0 +1,3 @@ +export function scratch(options?: Deno.MakeTempOptions): string { + return Deno.makeTempDirSync({ prefix: "q-", ...options }); +} diff --git a/tools/codemod/fixtures/type-ns-import.expected.ts b/tools/codemod/fixtures/type-ns-import.expected.ts new file mode 100644 index 0000000000..bde726037f --- /dev/null +++ b/tools/codemod/fixtures/type-ns-import.expected.ts @@ -0,0 +1,6 @@ +import type * as fs from "../deno_ral/fs.ts"; +import type { DirEntry } from "../deno_ral/fs.ts"; + +export function isDir(info: fs.FileInfo, entry: DirEntry): boolean { + return info.isDirectory && entry.isDirectory; +} diff --git a/tools/codemod/fixtures/type-ns-import.input.ts b/tools/codemod/fixtures/type-ns-import.input.ts new file mode 100644 index 0000000000..b879035c8f --- /dev/null +++ b/tools/codemod/fixtures/type-ns-import.input.ts @@ -0,0 +1,5 @@ +import type * as fs from "../deno_ral/fs.ts"; + +export function isDir(info: fs.FileInfo, entry: Deno.DirEntry): boolean { + return info.isDirectory && entry.isDirectory; +} diff --git a/tools/codemod/fixtures/type-only-existing.expected.ts b/tools/codemod/fixtures/type-only-existing.expected.ts new file mode 100644 index 0000000000..e188efa515 --- /dev/null +++ b/tools/codemod/fixtures/type-only-existing.expected.ts @@ -0,0 +1,6 @@ +import type { FileInfo } from "../deno_ral/fs.ts"; +import { statSync } from "../deno_ral/fs.ts"; + +export function info(path: string): FileInfo { + return statSync(path); +} diff --git a/tools/codemod/fixtures/type-only-existing.input.ts b/tools/codemod/fixtures/type-only-existing.input.ts new file mode 100644 index 0000000000..360e8bf6fd --- /dev/null +++ b/tools/codemod/fixtures/type-only-existing.input.ts @@ -0,0 +1,5 @@ +import type { FileInfo } from "../deno_ral/fs.ts"; + +export function info(path: string): FileInfo { + return Deno.statSync(path); +} diff --git a/tools/codemod/fixtures/type-value-conflict.input.ts b/tools/codemod/fixtures/type-value-conflict.input.ts new file mode 100644 index 0000000000..3ac55723a7 --- /dev/null +++ b/tools/codemod/fixtures/type-value-conflict.input.ts @@ -0,0 +1,6 @@ +import { type statSync } from "../deno_ral/fs.ts"; +import type { lstatSync } from "../deno_ral/fs.ts"; + +export function sizes(path: string) { + return [Deno.statSync(path).size, Deno.lstatSync(path).size]; +} diff --git a/tools/codemod/fixtures/write-alias.input.ts b/tools/codemod/fixtures/write-alias.input.ts new file mode 100644 index 0000000000..969255b3e2 --- /dev/null +++ b/tools/codemod/fixtures/write-alias.input.ts @@ -0,0 +1,4 @@ +const D = Deno; +const original = Deno.readTextFileSync; +D.readTextFileSync = (path: string | URL) => original(path); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/write-asserted-nested.input.ts b/tools/codemod/fixtures/write-asserted-nested.input.ts new file mode 100644 index 0000000000..7872c6425e --- /dev/null +++ b/tools/codemod/fixtures/write-asserted-nested.input.ts @@ -0,0 +1,2 @@ +export const isWin = Deno.build.os === "windows"; +( Deno.build).os = "linux"; diff --git a/tools/codemod/fixtures/write-asserted-receiver.input.ts b/tools/codemod/fixtures/write-asserted-receiver.input.ts new file mode 100644 index 0000000000..4bf51b6a54 --- /dev/null +++ b/tools/codemod/fixtures/write-asserted-receiver.input.ts @@ -0,0 +1,3 @@ +const original = Deno.readTextFileSync; +(Deno as any).readTextFileSync = (path: string | URL) => original(path); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/write-asserted-target.input.ts b/tools/codemod/fixtures/write-asserted-target.input.ts new file mode 100644 index 0000000000..ac8e32a84b --- /dev/null +++ b/tools/codemod/fixtures/write-asserted-target.input.ts @@ -0,0 +1,3 @@ +const replacement = (_path: string | URL) => ""; +(Deno.readTextFileSync as any) = replacement; +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/write-compound.input.ts b/tools/codemod/fixtures/write-compound.input.ts new file mode 100644 index 0000000000..f91a31bafe --- /dev/null +++ b/tools/codemod/fixtures/write-compound.input.ts @@ -0,0 +1,3 @@ +const instrument = (f: T): T => f; +Deno.readTextFileSync &&= instrument(Deno.readTextFileSync); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/write-define-property-paren.input.ts b/tools/codemod/fixtures/write-define-property-paren.input.ts new file mode 100644 index 0000000000..f4d0ab8837 --- /dev/null +++ b/tools/codemod/fixtures/write-define-property-paren.input.ts @@ -0,0 +1,3 @@ +const original = Deno.readTextFileSync; +(Object.defineProperty)(Deno, "readTextFileSync", { value: (p: string) => original(p) }); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/write-define-property.input.ts b/tools/codemod/fixtures/write-define-property.input.ts new file mode 100644 index 0000000000..dc513a13bc --- /dev/null +++ b/tools/codemod/fixtures/write-define-property.input.ts @@ -0,0 +1,3 @@ +const original = Deno.readTextFileSync; +Object.defineProperty(Deno, "readTextFileSync", { value: (p: string) => original(p) }); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/write-delete.input.ts b/tools/codemod/fixtures/write-delete.input.ts new file mode 100644 index 0000000000..7bc1e56389 --- /dev/null +++ b/tools/codemod/fixtures/write-delete.input.ts @@ -0,0 +1,4 @@ +export function reset() { + delete Deno.env; +} +export const home = Deno.env.get("HOME"); diff --git a/tools/codemod/fixtures/write-destructure-array.input.ts b/tools/codemod/fixtures/write-destructure-array.input.ts new file mode 100644 index 0000000000..f4ddbd7ff6 --- /dev/null +++ b/tools/codemod/fixtures/write-destructure-array.input.ts @@ -0,0 +1,2 @@ +export const isWin = Deno.build.os === "windows"; +[Deno.build.os] = ["linux"]; diff --git a/tools/codemod/fixtures/write-destructure-object.input.ts b/tools/codemod/fixtures/write-destructure-object.input.ts new file mode 100644 index 0000000000..660ea5e8c0 --- /dev/null +++ b/tools/codemod/fixtures/write-destructure-object.input.ts @@ -0,0 +1,3 @@ +const instrumented = { readTextFileSync: Deno.readTextFileSync }; +({ readTextFileSync: Deno.readTextFileSync } = instrumented); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/write-element-access.input.ts b/tools/codemod/fixtures/write-element-access.input.ts new file mode 100644 index 0000000000..b790b7357e --- /dev/null +++ b/tools/codemod/fixtures/write-element-access.input.ts @@ -0,0 +1,3 @@ +const original = Deno.readTextFileSync; +Deno["readTextFileSync"] = (path: string | URL) => original(path); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/write-for-of.input.ts b/tools/codemod/fixtures/write-for-of.input.ts new file mode 100644 index 0000000000..7a5902b572 --- /dev/null +++ b/tools/codemod/fixtures/write-for-of.input.ts @@ -0,0 +1,2 @@ +export const isWin = Deno.build.os === "windows"; +for (Deno.build.os of ["linux"]) break; diff --git a/tools/codemod/fixtures/write-global-this-element.input.ts b/tools/codemod/fixtures/write-global-this-element.input.ts new file mode 100644 index 0000000000..600fe218d5 --- /dev/null +++ b/tools/codemod/fixtures/write-global-this-element.input.ts @@ -0,0 +1,3 @@ +const original = Deno.readTextFileSync; +self["Deno"].readTextFileSync = (path: string | URL) => original(path); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/write-global-this-paren-key.input.ts b/tools/codemod/fixtures/write-global-this-paren-key.input.ts new file mode 100644 index 0000000000..524e02a06a --- /dev/null +++ b/tools/codemod/fixtures/write-global-this-paren-key.input.ts @@ -0,0 +1,3 @@ +const original = Deno.readTextFileSync; +globalThis[("Deno")].readTextFileSync = (path: string | URL) => original(path); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/write-global-this-template.input.ts b/tools/codemod/fixtures/write-global-this-template.input.ts new file mode 100644 index 0000000000..d8a0eaa025 --- /dev/null +++ b/tools/codemod/fixtures/write-global-this-template.input.ts @@ -0,0 +1,3 @@ +const original = Deno.readTextFileSync; +globalThis[`Deno`].readTextFileSync = (path: string | URL) => original(path); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/write-global-this.input.ts b/tools/codemod/fixtures/write-global-this.input.ts new file mode 100644 index 0000000000..6c0d8fa9f6 --- /dev/null +++ b/tools/codemod/fixtures/write-global-this.input.ts @@ -0,0 +1,3 @@ +const original = Deno.readTextFileSync; +globalThis.Deno.readTextFileSync = (path: string | URL) => original(path); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/codemod/fixtures/write-nested.input.ts b/tools/codemod/fixtures/write-nested.input.ts new file mode 100644 index 0000000000..bf08ba6689 --- /dev/null +++ b/tools/codemod/fixtures/write-nested.input.ts @@ -0,0 +1,4 @@ +if (Deno.env.get("FAKE_OS")) { + Deno.build.os = "linux"; +} +export const isWin = Deno.build.os === "windows"; diff --git a/tools/codemod/fixtures/write-non-null.input.ts b/tools/codemod/fixtures/write-non-null.input.ts new file mode 100644 index 0000000000..ca8de70d2d --- /dev/null +++ b/tools/codemod/fixtures/write-non-null.input.ts @@ -0,0 +1,2 @@ +export const isWin = Deno.build.os === "windows"; +Deno.build.os! = "linux"; diff --git a/tools/codemod/fixtures/write-object-assign-element.input.ts b/tools/codemod/fixtures/write-object-assign-element.input.ts new file mode 100644 index 0000000000..c89bfdd30c --- /dev/null +++ b/tools/codemod/fixtures/write-object-assign-element.input.ts @@ -0,0 +1,2 @@ +export const isWin = Deno.build.os === "windows"; +Object["assign"](Deno.build, { os: "linux" }); diff --git a/tools/codemod/fixtures/write-object-assign-paren-key.input.ts b/tools/codemod/fixtures/write-object-assign-paren-key.input.ts new file mode 100644 index 0000000000..40576b618b --- /dev/null +++ b/tools/codemod/fixtures/write-object-assign-paren-key.input.ts @@ -0,0 +1,2 @@ +export const isWin = Deno.build.os === "windows"; +Object[("assign")](Deno.build, { os: "linux" }); diff --git a/tools/codemod/fixtures/write-object-assign-paren.input.ts b/tools/codemod/fixtures/write-object-assign-paren.input.ts new file mode 100644 index 0000000000..1ea1bf484d --- /dev/null +++ b/tools/codemod/fixtures/write-object-assign-paren.input.ts @@ -0,0 +1,2 @@ +export const isWin = Deno.build.os === "windows"; +(Object.assign)(Deno.build, { os: "linux" }); diff --git a/tools/codemod/fixtures/write-object-assign-template.input.ts b/tools/codemod/fixtures/write-object-assign-template.input.ts new file mode 100644 index 0000000000..c84a84ff55 --- /dev/null +++ b/tools/codemod/fixtures/write-object-assign-template.input.ts @@ -0,0 +1,2 @@ +export const isWin = Deno.build.os === "windows"; +Object[`assign`](Deno.build, { os: "linux" }); diff --git a/tools/codemod/fixtures/write-object-assign.input.ts b/tools/codemod/fixtures/write-object-assign.input.ts new file mode 100644 index 0000000000..3c6a334d71 --- /dev/null +++ b/tools/codemod/fixtures/write-object-assign.input.ts @@ -0,0 +1,2 @@ +export const isWin = Deno.build.os === "windows"; +Object.assign(Deno.build, { os: "linux" }); diff --git a/tools/codemod/fixtures/write-shorthand.input.ts b/tools/codemod/fixtures/write-shorthand.input.ts new file mode 100644 index 0000000000..cb1e42c529 --- /dev/null +++ b/tools/codemod/fixtures/write-shorthand.input.ts @@ -0,0 +1,4 @@ +const holder = { Deno }; +const original = Deno.readTextFileSync; +holder.Deno.readTextFileSync = (path: string | URL) => original(path); +export const text = Deno.readTextFileSync("x.txt"); diff --git a/tools/runtime-boundary-baseline.json b/tools/runtime-boundary-baseline.json index 0607c709f1..729d34db7b 100644 --- a/tools/runtime-boundary-baseline.json +++ b/tools/runtime-boundary-baseline.json @@ -16,7 +16,7 @@ "stdImportFiles": 3 }, "tests": { - "denoCalls": 874, + "denoCalls": 875, "stdImportFiles": 176 } }