From 940908ad73e1749303cf9b9e21ea36cc1aa5500d Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 15:17:15 +0100 Subject: [PATCH 01/45] deno_ral/errors: runtime-neutral error predicates --- src/deno_ral/errors.ts | 100 ++++++++++++++++++++++++++++++++++ tests/unit/ral/errors.test.ts | 84 ++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 src/deno_ral/errors.ts create mode 100644 tests/unit/ral/errors.test.ts diff --git a/src/deno_ral/errors.ts b/src/deno_ral/errors.ts new file mode 100644 index 0000000000..9b5a59188b --- /dev/null +++ b/src/deno_ral/errors.ts @@ -0,0 +1,100 @@ +/* + * 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). Until PR 25, + * core/deno/monkey-patch.ts annotates Deno.readTextFile* errors the same way; + * the idempotence keeps the path from being appended twice. + */ +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/tests/unit/ral/errors.test.ts b/tests/unit/ral/errors.test.ts new file mode 100644 index 0000000000..e6b5cf561c --- /dev/null +++ b/tests/unit/ral/errors.test.ts @@ -0,0 +1,84 @@ +/* + * 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"; + +function withCode(code: string): Error { + const e = new Error(code); + (e as Error & { code: string }).code = code; + return e; +} + +// The runtime's own error classes, when the runtime has them (Deno). Looked up +// through globalThis so this file stays free of the Deno namespace. +// deno-lint-ignore no-explicit-any +const runtimeErrors = (globalThis as any)["Deno"]?.errors as + | Record Error> + | undefined; + +// deno-lint-ignore require-await +unitTest("errors - predicates recognise the runtime's error classes", async () => { + if (!runtimeErrors) return; // Node: covered by the code-based case below + const e = (name: string) => new runtimeErrors[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(withCode("ENOENT"))); + assert(isAlreadyExists(withCode("EEXIST"))); + assert(isPermissionDenied(withCode("EACCES"))); + assert(isPermissionDenied(withCode("EPERM"))); + assert(isAddrInUse(withCode("EADDRINUSE"))); + assert(isConnectionRefused(withCode("ECONNREFUSED"))); + assert(isConnectionError(withCode("EPIPE"))); + assert(isConnectionError(withCode("ECONNRESET"))); + assert(!isNotFound(withCode("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: withCode("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, ""); +}); From fbd24a660ab0a2ebd28d5b96703e2252c8b9930c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 15:23:39 +0100 Subject: [PATCH 02/45] deno_ral/temp: makeTemp* retrying on AlreadyExists only --- src/deno_ral/temp.ts | 72 ++++++++++++++++++++++++ tests/unit/ral/temp.test.ts | 108 ++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 src/deno_ral/temp.ts create mode 100644 tests/unit/ral/temp.test.ts diff --git a/src/deno_ral/temp.ts b/src/deno_ral/temp.ts new file mode 100644 index 0000000000..99a78a782e --- /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. Until PR 25 removes the monkey patch, its 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/temp.test.ts b/tests/unit/ral/temp.test.ts new file mode 100644 index 0000000000..a45b91885c --- /dev/null +++ b/tests/unit/ral/temp.test.ts @@ -0,0 +1,108 @@ +/* + * 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"; + +function codeError(code: string): Error { + const e = new Error(code); + (e as Error & { code: string }).code = code; + return e; +} + +// 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 codeError("EEXIST"); + return "ok"; + }; + assertEquals(retryOnAlreadyExists(flaky), "ok"); + assertEquals(calls, 3); + + calls = 0; + const e = assertThrows(() => + retryOnAlreadyExists(() => { + calls++; + throw codeError("EEXIST"); + }) + ); + assert(isAlreadyExists(e)); + assertEquals(calls, 5); + + calls = 0; + assertThrows(() => + retryOnAlreadyExists(() => { + calls++; + throw codeError("ENOENT"); + }) + ); + assertEquals(calls, 1, "other errors are not retried"); +}); From 810227f9eb962c051bf70abd41a05671d6e1105a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 15:25:25 +0100 Subject: [PATCH 03/45] runtime-migration docs: execution-log routine, PR 09 in progress, tasks 1-2 logged The executor now records per-task progress in three places (decided with Chris during PR 09's run): an "Execution log" section at the end of the plan file with commands and quoted output, ticked plan checkboxes folded into each task's commit, and br comments on the PR's issue. The README's "How to execute a plan" section documents the routine; the PR 09 row moves to in progress. --- .../09-ral-fs-and-temp-layer.md | 62 ++++++++++++++++--- dev-docs/runtime-migration/README.md | 10 ++- 2 files changed, 61 insertions(+), 11 deletions(-) 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..4f2f5fc5d2 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 @@ -263,7 +263,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 +352,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 +461,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 +478,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 +593,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 +674,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 @@ -2569,3 +2569,45 @@ 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. +- Beads: `quarto-cli-81ld.13` open with both `blocks` dependencies closed (PR 02 #15135, PR 02c #15153). `quarto-cli-81ld.50` (plan defect: contract tests imported `testing/asserts`) is closed: 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): `QUARTO_TESTS_NO_CONFIG=true pwsh -File run-tests.ps1 --agent unit/ral/errors.test.ts` → + + ``` + TS2307 [ERROR]: Cannot find module 'file:///C:/Users/chris/Documents/DEV_R/quarto-cli.worktrees/rt-09-ral-fs-and-temp-layer/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): `QUARTO_TESTS_NO_CONFIG=true pwsh -File run-tests.ps1 --agent unit/ral/temp.test.ts` → + + ``` + TS2307 [ERROR]: Cannot find module 'file:///C:/Users/chris/Documents/DEV_R/quarto-cli.worktrees/rt-09-ral-fs-and-temp-layer/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). diff --git a/dev-docs/runtime-migration/README.md b/dev-docs/runtime-migration/README.md index 08a39930e2..62472e80a7 100644 --- a/dev-docs/runtime-migration/README.md +++ b/dev-docs/runtime-migration/README.md @@ -188,6 +188,14 @@ 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. +- The beads issue for the PR is set to `in_progress` when execution starts; a `br comments add` one-liner records each finished task. + Beads state lives in the main checkout's `.beads/` (the worktree has a `redirect` file); it is never committed from a feature branch. + The issue closes when the PR merges, not before. + ## The sequence Status values: `planned`, `in progress`, `merged`. @@ -219,7 +227,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 | From 04df3bcf53a3d72891165ec9e5277bc8641bbd4a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 15:28:00 +0100 Subject: [PATCH 04/45] runtime-migration README: multi-session handoff at task boundaries The execution-log routine already makes the plan file self-sufficient; this says when to use it (hand off at a commit point, resume per the checkboxes) and names it the fallback for executors without sub-agent tooling. --- dev-docs/runtime-migration/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dev-docs/runtime-migration/README.md b/dev-docs/runtime-migration/README.md index 62472e80a7..aa0b5ad27e 100644 --- a/dev-docs/runtime-migration/README.md +++ b/dev-docs/runtime-migration/README.md @@ -195,6 +195,9 @@ Progress tracking while executing (decided 2026-10-28, from PR 09's run): - The beads issue for the PR is set to `in_progress` when execution starts; a `br comments add` one-liner records each finished task. Beads state lives in the main checkout's `.beads/` (the worktree has a `redirect` file); it is never committed from a feature branch. The issue closes when the PR merges, not before. +- 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, the beads comments 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 From 1f4fa7dd42d297e4dc32bac8669c09465971464e Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 15:34:23 +0100 Subject: [PATCH 05/45] rt-09 plan: fix shell syntax in execution log commands The log header states the commands ran under PowerShell, but the two recorded commands used the Bash env-prefix form (QUARTO_TESTS_NO_CONFIG=true pwsh ...), which fails before the test runner starts when replayed in pwsh. Record the actual PowerShell syntax ($env:QUARTO_TESTS_NO_CONFIG="true"; ...) per tests/README.md. --- dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 4f2f5fc5d2..219339a5ec 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 @@ -2584,7 +2584,7 @@ Executed 2026-10-28 on branch `rt/09-ral-fs-and-temp-layer` (Windows 11, pwsh + ### Task 1: `src/deno_ral/errors.ts` -- Step 2 (RED): `QUARTO_TESTS_NO_CONFIG=true pwsh -File run-tests.ps1 --agent unit/ral/errors.test.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:///C:/Users/chris/Documents/DEV_R/quarto-cli.worktrees/rt-09-ral-fs-and-temp-layer/src/deno_ral/errors.ts'. @@ -2599,7 +2599,7 @@ Executed 2026-10-28 on branch `rt/09-ral-fs-and-temp-layer` (Windows 11, pwsh + ### Task 2: `src/deno_ral/temp.ts` -- Step 2 (RED): `QUARTO_TESTS_NO_CONFIG=true pwsh -File run-tests.ps1 --agent unit/ral/temp.test.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:///C:/Users/chris/Documents/DEV_R/quarto-cli.worktrees/rt-09-ral-fs-and-temp-layer/src/deno_ral/temp.ts'. From f014366700d0eb608f3b895db3d4017bcd137f05 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 15:39:07 +0100 Subject: [PATCH 06/45] deno_ral/fs: plain filesystem calls, canonicalPathSync, layer-typed helpers Add the fs section of the runtime layer (PR 09): read/write/stat/mkdir/ remove/rename/copyFile/readDir/symlink/readLink/realPath/chmod/utime/ truncate/open wrappers behind the layer's own FileInfo, DirEntry, FsFile, OpenOptions and WriteOptions types. readDir[Sync] return entries sorted by name in code-unit order; read errors are annotated with "\nPath: " via deno_ral/errors.ts; canonicalPathSync calls the saved original realPathSync so it keeps resolving symlinks while core/deno/monkey-patch.ts is alive (until PR 25). Retype getFileInfoType and safeRemoveSync to the layer types so PR 10's rewrites type-check, move safeRemoveDirSync from originalRealPathSync to canonicalPathSync, and fix the header comment (encoding.ts copy-paste). Two adaptations from the plan's verbatim block, required by the vendored Deno 2.7.14 types: SymlinkOptions.type is required, so symlink[Sync] forwards an options object only when a type is given; truncateSync takes string only, so a URL path goes through toPathString first. --- src/deno_ral/fs.ts | 329 ++++++++++++++++++++++++++++++++++++-- tests/unit/ral/fs.test.ts | 202 +++++++++++++++++++++++ 2 files changed, 516 insertions(+), 15 deletions(-) create mode 100644 tests/unit/ral/fs.test.ts diff --git a/src/deno_ral/fs.ts b/src/deno_ral/fs.ts index 06844051ca..7729b9aaba 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,308 @@ export function ensureUserWritable(path: string): void { safeChmodSync(path, mode | 0o200); } } + +// --------------------------------------------------------------------------- +// Plain filesystem calls (PR 09). Deno's names are kept so that the codemod is +// `Deno.x(` → `x(` plus an import. Implemented on Deno here; PR 27 moves this +// section to node:fs behind the same signatures and the same unit test +// (tests/unit/ral/fs.test.ts). +// +// Deno.* is looked up at call time on purpose: core/performance/metrics.ts +// reassigns Deno.readTextFileSync to record file reads until PR 20b replaces +// that with the onFileRead hook in this module. +// --------------------------------------------------------------------------- + +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; +} + +function annotate(e: unknown, path: string | URL): unknown { + return e instanceof Error ? errorWithPath(e, toPathString(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. Uses the runtime's own realPathSync: until PR 25 deletes + * core/deno/monkey-patch.ts, Deno.realPathSync is patched to normalizePath, + * so the saved original is called. PR 25 switches this to Deno.realPathSync; + * PR 27 to fs.realpathSync.native. + */ +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/tests/unit/ral/fs.test.ts b/tests/unit/ral/fs.test.ts new file mode 100644 index 0000000000..ba75635feb --- /dev/null +++ b/tests/unit/ral/fs.test.ts @@ -0,0 +1,202 @@ +/* + * fs.test.ts + * + * Compatibility contract for the plain filesystem calls in + * src/deno_ral/fs.ts. PR 27 must keep this file green on node:fs. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.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 { makeTempDirSync } from "../../../src/deno_ral/temp.ts"; +import { join } 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"; + +function withDir(fn: (dir: string) => void | Promise) { + return async () => { + const dir = makeTempDirSync({ prefix: "ral-fs-" }); + try { + await fn(dir); + } finally { + removeSync(dir, { recursive: true }); + } + }; +} + +unitTest("fs - text round trip and append", withDir((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", withDir(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); +})); + +unitTest("fs - bytes round trip", withDir((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", withDir((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", withDir((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", withDir((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", withDir(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", withDir((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", withDir((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", withDir((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)", withDir((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", withDir(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"); +})); From 373e6389f14380bc78c6a6ac6be8938d0abf950c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 15:42:01 +0100 Subject: [PATCH 07/45] runtime-migration docs: PR 09 task 3 logged, Deno 2.7 check/symlink findings recorded --- .../09-ral-fs-and-temp-layer.md | 24 +++++++++++++++---- dev-docs/runtime-migration/README.md | 2 ++ 2 files changed, 21 insertions(+), 5 deletions(-) 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 219339a5ec..2d17c5461b 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 @@ -691,7 +691,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 +898,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 +1225,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 @@ -2611,3 +2611,17 @@ Executed 2026-10-28 on branch `rt/09-ral-fs-and-temp-layer` (Windows 11, pwsh + 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): `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): `QUARTO_TESTS_NO_CONFIG=true pwsh -File run-tests.ps1 --agent ` → + + ``` + 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). diff --git a/dev-docs/runtime-migration/README.md b/dev-docs/runtime-migration/README.md index aa0b5ad27e..6fbc80a6f8 100644 --- a/dev-docs/runtime-migration/README.md +++ b/dev-docs/runtime-migration/README.md @@ -134,6 +134,8 @@ The rows below are the ones that still need a named person or an external answer | 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 From 034aef91d9cb01d38645eabe6a640bc3239c95fb Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 15:54:14 +0100 Subject: [PATCH 08/45] deno_ral/fs: annotate read errors with String(path), not toPathString core/deno/monkey-patch.ts annotates Deno.readTextFile* errors with "Path: " + path, which for a file URL is the href. annotate() passed toPathString(path) (the filesystem path) to errorWithPath, so the suffix check never matched for URLs and a second "Path:" line was appended whenever the monkey patch was active. Use String(path) so both annotations are byte-identical and errorWithPath's idempotence holds; add missing-file URL cases to the annotation test (fails on the old code: two "Path:" lines). --- src/deno_ral/fs.ts | 5 ++++- tests/unit/ral/fs.test.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/deno_ral/fs.ts b/src/deno_ral/fs.ts index 7729b9aaba..392871ebe2 100644 --- a/src/deno_ral/fs.ts +++ b/src/deno_ral/fs.ts @@ -287,8 +287,11 @@ function byName(a: DirEntry, b: DirEntry): number { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; } +// String(path) (the URL href for URLs) matches what core/deno/monkey-patch.ts +// appends (`"Path: " + path`), so errorWithPath stays idempotent when the +// monkey patch already annotated the error. function annotate(e: unknown, path: string | URL): unknown { - return e instanceof Error ? errorWithPath(e, toPathString(path)) : e; + return e instanceof Error ? errorWithPath(e, String(path)) : e; } export function readTextFileSync(path: string | URL): string { diff --git a/tests/unit/ral/fs.test.ts b/tests/unit/ral/fs.test.ts index ba75635feb..d684287957 100644 --- a/tests/unit/ral/fs.test.ts +++ b/tests/unit/ral/fs.test.ts @@ -17,7 +17,7 @@ import { import { isWindows } from "../../../src/deno_ral/platform.ts"; import { isNotFound } from "../../../src/deno_ral/errors.ts"; import { makeTempDirSync } from "../../../src/deno_ral/temp.ts"; -import { join } from "../../../src/deno_ral/path.ts"; +import { join, toFileUrl } from "../../../src/deno_ral/path.ts"; import { canonicalPathSync, chmodSync, @@ -76,6 +76,31 @@ unitTest("fs - read errors carry the path once and satisfy isNotFound", withDir( } 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", withDir((dir) => { From e70f0033c431d7531934839d8efd326091d80b13 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 15:54:19 +0100 Subject: [PATCH 09/45] rt-09 plan: fix Task 3 shell syntax in execution log commands Same defect fixed for Tasks 1-2 in 0c0886331: the Task 3 RED and GREEN entries recorded the Bash env-prefix form, and GREEN also carried a non-executable placeholder, so neither command replays in the pwsh session the log header states. Record the PowerShell form and the actual glob command (pwsh passes the glob through; Deno expands it), verified to reproduce the logged 23 passed / 1 ignored result. --- dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 2d17c5461b..84b8fe2a1d 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 @@ -2614,9 +2614,9 @@ Executed 2026-10-28 on branch `rt/09-ral-fs-and-temp-layer` (Windows 11, pwsh + ### Task 3: `src/deno_ral/fs.ts` additions -- Step 2 (RED): `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 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): `QUARTO_TESTS_NO_CONFIG=true pwsh -File run-tests.ps1 --agent ` → +- 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) From 0d3ef3a8473626543fad351e94fd857f22ac70c1 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 16:03:10 +0100 Subject: [PATCH 10/45] deno_ral tests: shared helpers, direct Deno.errors, present-tense comments fs.test.ts had its own copy of tests/utils.ts withTempDir, and temp.test.ts and errors.test.ts each defined the same code-carrying error helper; both now use the shared versions (errorWithCode lives in tests/unit/ral/utils.ts). errors.test.ts reached Deno.errors through (globalThis as any)["Deno"], which only kept the access out of the runtime-boundary ratchet's `Deno.` count. It now reads Deno.errors directly behind a typeof guard, so the test stays skippable off Deno, and the tests baseline takes the one honest increase. Comments in src/deno_ral cited migration plan PR numbers ("until PR 25", "PR 27 moves this"), which go stale as soon as those PRs land. They now state only the current reason, e.g. that Deno.* is looked up at call time because core/performance/metrics.ts reassigns Deno.readTextFileSync. --- src/deno_ral/errors.ts | 2 +- src/deno_ral/fs.ts | 14 +++------- src/deno_ral/temp.ts | 4 +-- tests/unit/ral/errors.test.ts | 39 ++++++++++------------------ tests/unit/ral/fs.test.ts | 39 ++++++++++------------------ tests/unit/ral/temp.test.ts | 13 +++------- tests/unit/ral/utils.ts | 14 ++++++++++ tools/runtime-boundary-baseline.json | 2 +- 8 files changed, 54 insertions(+), 73 deletions(-) create mode 100644 tests/unit/ral/utils.ts diff --git a/src/deno_ral/errors.ts b/src/deno_ral/errors.ts index 9b5a59188b..4bf7d7c3e5 100644 --- a/src/deno_ral/errors.ts +++ b/src/deno_ral/errors.ts @@ -87,7 +87,7 @@ export function isConnectionError(e: unknown): boolean { } /** - * Append the offending path to an error message (idempotent). Until PR 25, + * Append the offending path to an error message (idempotent). * core/deno/monkey-patch.ts annotates Deno.readTextFile* errors the same way; * the idempotence keeps the path from being appended twice. */ diff --git a/src/deno_ral/fs.ts b/src/deno_ral/fs.ts index 392871ebe2..e11d0a53cb 100644 --- a/src/deno_ral/fs.ts +++ b/src/deno_ral/fs.ts @@ -209,14 +209,10 @@ export function ensureUserWritable(path: string): void { } // --------------------------------------------------------------------------- -// Plain filesystem calls (PR 09). Deno's names are kept so that the codemod is -// `Deno.x(` → `x(` plus an import. Implemented on Deno here; PR 27 moves this -// section to node:fs behind the same signatures and the same unit test -// (tests/unit/ral/fs.test.ts). +// 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 until PR 20b replaces -// that with the onFileRead hook in this module. +// reassigns Deno.readTextFileSync to record file reads. // --------------------------------------------------------------------------- export interface FileInfo { @@ -474,10 +470,8 @@ export function realPath(path: string | URL): Promise { /** * 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. Uses the runtime's own realPathSync: until PR 25 deletes - * core/deno/monkey-patch.ts, Deno.realPathSync is patched to normalizePath, - * so the saved original is called. PR 25 switches this to Deno.realPathSync; - * PR 27 to fs.realpathSync.native. + * 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); diff --git a/src/deno_ral/temp.ts b/src/deno_ral/temp.ts index 99a78a782e..3b9c4e92df 100644 --- a/src/deno_ral/temp.ts +++ b/src/deno_ral/temp.ts @@ -7,8 +7,8 @@ * 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. Until PR 25 removes the monkey patch, its own loop runs - * inside this one; that is harmless. + * reported at once. The monkey patch's own loop runs inside this one; that is + * harmless. */ import { isAlreadyExists } from "./errors.ts"; diff --git a/tests/unit/ral/errors.test.ts b/tests/unit/ral/errors.test.ts index e6b5cf561c..b41b982282 100644 --- a/tests/unit/ral/errors.test.ts +++ b/tests/unit/ral/errors.test.ts @@ -15,24 +15,13 @@ import { isNotFound, isPermissionDenied, } from "../../../src/deno_ral/errors.ts"; - -function withCode(code: string): Error { - const e = new Error(code); - (e as Error & { code: string }).code = code; - return e; -} - -// The runtime's own error classes, when the runtime has them (Deno). Looked up -// through globalThis so this file stays free of the Deno namespace. -// deno-lint-ignore no-explicit-any -const runtimeErrors = (globalThis as any)["Deno"]?.errors as - | Record Error> - | undefined; +import { errorWithCode } from "./utils.ts"; // deno-lint-ignore require-await unitTest("errors - predicates recognise the runtime's error classes", async () => { - if (!runtimeErrors) return; // Node: covered by the code-based case below - const e = (name: string) => new runtimeErrors[name]("x"); + 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"))); @@ -47,15 +36,15 @@ unitTest("errors - predicates recognise the runtime's error classes", async () = // deno-lint-ignore require-await unitTest("errors - predicates recognise Node-style error codes", async () => { - assert(isNotFound(withCode("ENOENT"))); - assert(isAlreadyExists(withCode("EEXIST"))); - assert(isPermissionDenied(withCode("EACCES"))); - assert(isPermissionDenied(withCode("EPERM"))); - assert(isAddrInUse(withCode("EADDRINUSE"))); - assert(isConnectionRefused(withCode("ECONNREFUSED"))); - assert(isConnectionError(withCode("EPIPE"))); - assert(isConnectionError(withCode("ECONNRESET"))); - assert(!isNotFound(withCode("EEXIST"))); + 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")); @@ -64,7 +53,7 @@ unitTest("errors - predicates recognise Node-style error codes", async () => { // 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: withCode("ENOENT"), + cause: errorWithCode("ENOENT"), }); assert(isNotFound(wrapped)); assert(!isAlreadyExists(wrapped)); diff --git a/tests/unit/ral/fs.test.ts b/tests/unit/ral/fs.test.ts index d684287957..3a5563f4d5 100644 --- a/tests/unit/ral/fs.test.ts +++ b/tests/unit/ral/fs.test.ts @@ -2,12 +2,13 @@ * fs.test.ts * * Compatibility contract for the plain filesystem calls in - * src/deno_ral/fs.ts. PR 27 must keep this file green on node:fs. + * 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, @@ -16,7 +17,6 @@ import { } from "../../asserts.ts"; import { isWindows } from "../../../src/deno_ral/platform.ts"; import { isNotFound } from "../../../src/deno_ral/errors.ts"; -import { makeTempDirSync } from "../../../src/deno_ral/temp.ts"; import { join, toFileUrl } from "../../../src/deno_ral/path.ts"; import { canonicalPathSync, @@ -44,25 +44,14 @@ import { writeTextFileSync, } from "../../../src/deno_ral/fs.ts"; -function withDir(fn: (dir: string) => void | Promise) { - return async () => { - const dir = makeTempDirSync({ prefix: "ral-fs-" }); - try { - await fn(dir); - } finally { - removeSync(dir, { recursive: true }); - } - }; -} - -unitTest("fs - text round trip and append", withDir((dir) => { +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", withDir(async (dir) => { +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)); @@ -103,13 +92,13 @@ unitTest("fs - read errors carry the path once and satisfy isNotFound", withDir( ); })); -unitTest("fs - bytes round trip", withDir((dir) => { +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", withDir((dir) => { +unitTest("fs - statSync and lstatSync", () => withTempDir((dir) => { const file = join(dir, "s.txt"); writeTextFileSync(file, "12345"); const info = statSync(file); @@ -127,7 +116,7 @@ unitTest("fs - statSync and lstatSync", withDir((dir) => { } })); -unitTest("fs - mkdirSync recursive and removeSync recursive", withDir((dir) => { +unitTest("fs - mkdirSync recursive and removeSync recursive", () => withTempDir((dir) => { const nested = join(dir, "x", "y", "z"); mkdirSync(nested, { recursive: true }); assert(existsSync(nested)); @@ -135,7 +124,7 @@ unitTest("fs - mkdirSync recursive and removeSync recursive", withDir((dir) => { assert(!existsSync(join(dir, "x"))); })); -unitTest("fs - renameSync and copyFileSync", withDir((dir) => { +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"); @@ -147,7 +136,7 @@ unitTest("fs - renameSync and copyFileSync", withDir((dir) => { assertEquals(readTextFileSync(b), "A"); })); -unitTest("fs - readDirSync and readDir are sorted by name", withDir(async (dir) => { +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); } @@ -164,7 +153,7 @@ unitTest("fs - readDirSync and readDir are sorted by name", withDir(async (dir) assertEquals(async, sync); })); -unitTest("fs - canonicalPathSync resolves symlinks and throws when missing", withDir((dir) => { +unitTest("fs - canonicalPathSync resolves symlinks and throws when missing", () => withTempDir((dir) => { const real = join(dir, "real"); mkdirSync(real); const canonicalReal = canonicalPathSync(real); @@ -179,7 +168,7 @@ unitTest("fs - canonicalPathSync resolves symlinks and throws when missing", wit assert(isNotFound(e)); })); -unitTest("fs - getFileInfoType takes the layer's FileInfo", withDir((dir) => { +unitTest("fs - getFileInfoType takes the layer's FileInfo", () => withTempDir((dir) => { const file = join(dir, "f.txt"); writeTextFileSync(file, "x"); assertEquals(getFileInfoType(lstatSync(file)), "file"); @@ -191,7 +180,7 @@ unitTest("fs - getFileInfoType takes the layer's FileInfo", withDir((dir) => { } })); -unitTest("fs - utimeSync and truncateSync", withDir((dir) => { +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"); @@ -203,7 +192,7 @@ unitTest("fs - utimeSync and truncateSync", withDir((dir) => { assertEquals(readTextFileSync(file), ""); })); -unitTest("fs - chmodSync (POSIX)", withDir((dir) => { +unitTest("fs - chmodSync (POSIX)", () => withTempDir((dir) => { if (isWindows) return; const file = join(dir, "m.txt"); writeTextFileSync(file, "x"); @@ -211,7 +200,7 @@ unitTest("fs - chmodSync (POSIX)", withDir((dir) => { assertEquals((statSync(file).mode ?? 0) & 0o777, 0o600); })); -unitTest("fs - open/create expose streams and close", withDir(async (dir) => { +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(); diff --git a/tests/unit/ral/temp.test.ts b/tests/unit/ral/temp.test.ts index a45b91885c..cc0b2eb7e7 100644 --- a/tests/unit/ral/temp.test.ts +++ b/tests/unit/ral/temp.test.ts @@ -16,12 +16,7 @@ import { 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"; - -function codeError(code: string): Error { - const e = new Error(code); - (e as Error & { code: string }).code = code; - return e; -} +import { errorWithCode } from "./utils.ts"; // deno-lint-ignore require-await unitTest("temp - makeTempDirSync honours dir, prefix and suffix", async () => { @@ -81,7 +76,7 @@ unitTest("temp - retries only AlreadyExists, at most 5 times", async () => { let calls = 0; const flaky = () => { calls++; - if (calls < 3) throw codeError("EEXIST"); + if (calls < 3) throw errorWithCode("EEXIST"); return "ok"; }; assertEquals(retryOnAlreadyExists(flaky), "ok"); @@ -91,7 +86,7 @@ unitTest("temp - retries only AlreadyExists, at most 5 times", async () => { const e = assertThrows(() => retryOnAlreadyExists(() => { calls++; - throw codeError("EEXIST"); + throw errorWithCode("EEXIST"); }) ); assert(isAlreadyExists(e)); @@ -101,7 +96,7 @@ unitTest("temp - retries only AlreadyExists, at most 5 times", async () => { assertThrows(() => retryOnAlreadyExists(() => { calls++; - throw codeError("ENOENT"); + 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/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 } } From 790c04bd2af25f7bf02d80dbd8764c555275b1f4 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 16:03:16 +0100 Subject: [PATCH 11/45] runtime-migration docs: PR 09 review round, comment and Deno.errors conventions --- dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md | 9 ++++++++- dev-docs/runtime-migration/README.md | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) 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 84b8fe2a1d..f661a8cd94 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 @@ -255,7 +255,7 @@ Names outside the tables (`existsSync`, `safeRemoveSync`, `isNotFound`, …) are ## 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` @@ -2625,3 +2625,10 @@ Executed 2026-10-28 on branch `rt/09-ral-fs-and-temp-layer` (Windows 11, pwsh + 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`. diff --git a/dev-docs/runtime-migration/README.md b/dev-docs/runtime-migration/README.md index 6fbc80a6f8..56a01f0451 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')`. From ce21943737d792e2d7b85147511092456cef17c9 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 16:17:48 +0100 Subject: [PATCH 12/45] runtime-migration docs: code-quality audit in Step 0, review checklist, per-task pass The executor transcribes plan code blocks verbatim, and the plans' review checklists check behaviour only. PR 09's review found three defects that all came from its plan: a test helper duplicating tests/utils.ts withTempDir, Deno reached through globalThis to stay out of the ratchet count, and code comments citing plan PR numbers. A rough grep shows the same patterns across most remaining plans. Step 0 now audits each plan's code for those three points before execution (just in time, against current main, rather than a one-off sweep of plans that Step 0 re-checks anyway); the template's review checklist carries them as standing items; and the reviewer runs a reuse/simplification pass on each task's diff, with three greps for the whole branch before the PR opens. src/deno_ral is exempt from the globalThis rule, as it is from the ratchet. --- dev-docs/runtime-migration/README.md | 16 ++++++++++++++++ dev-docs/runtime-migration/_TEMPLATE.md | 5 ++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/dev-docs/runtime-migration/README.md b/dev-docs/runtime-migration/README.md index 56a01f0451..c68477f964 100644 --- a/dev-docs/runtime-migration/README.md +++ b/dev-docs/runtime-migration/README.md @@ -171,6 +171,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` through `globalThis` to stay out of the ratchet count (`rg -n 'globalThis.*Deno' `); 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 -n '^\+.*globalThis.*Deno' # the layer itself is exempt +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 diff --git a/dev-docs/runtime-migration/_TEMPLATE.md b/dev-docs/runtime-migration/_TEMPLATE.md index 70647a5308..574de24dc7 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 (`globalThis.Deno`, `globalThis["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. From d9daa273168db84fd9b14376f94cdac31fb1ff6f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 17:03:42 +0100 Subject: [PATCH 13/45] deno_ral: drop wrapper-file references from two comments The errorWithPath docstring and the annotate comment named the file that installs the read-annotation wrapper. The new wording holds while that wrapper is active and after its removal, so the removal-time grep gate in the migration plan gains two fewer comment hits. Behavior is unchanged: annotate still passes String(path), byte-identical to the wrapper's own annotation, so errorWithPath's idempotence is unaffected. The PR 09 execution log records this and the annotate URL fix (4bdd5f4ca). --- dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md | 2 ++ src/deno_ral/errors.ts | 5 ++--- src/deno_ral/fs.ts | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) 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 f661a8cd94..cd0e49059a 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 @@ -2632,3 +2632,5 @@ Executed 2026-10-28 on branch `rt/09-ral-fs-and-temp-layer` (Windows 11, pwsh + - `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, in `temp.ts` and `fs.test.ts`, are tracked in beads). `String(path)` is unchanged, so idempotence with the active wrapper is unaffected. diff --git a/src/deno_ral/errors.ts b/src/deno_ral/errors.ts index 4bf7d7c3e5..463b86a9f5 100644 --- a/src/deno_ral/errors.ts +++ b/src/deno_ral/errors.ts @@ -87,9 +87,8 @@ export function isConnectionError(e: unknown): boolean { } /** - * Append the offending path to an error message (idempotent). - * core/deno/monkey-patch.ts annotates Deno.readTextFile* errors the same way; - * the idempotence keeps the path from being appended twice. + * 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; diff --git a/src/deno_ral/fs.ts b/src/deno_ral/fs.ts index e11d0a53cb..3df869b6e6 100644 --- a/src/deno_ral/fs.ts +++ b/src/deno_ral/fs.ts @@ -283,9 +283,9 @@ function byName(a: DirEntry, b: DirEntry): number { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; } -// String(path) (the URL href for URLs) matches what core/deno/monkey-patch.ts -// appends (`"Path: " + path`), so errorWithPath stays idempotent when the -// monkey patch already annotated the error. +// 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; } From e0c67984b6259457c67be25ef0dc81980024591c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 17:36:24 +0100 Subject: [PATCH 14/45] runtime-migration docs: review rule covers every indirect Deno spelling PR 02d makes the ratchet count globalThis.Deno and globalThis["Deno"], but leaves destructuring, aliases (`const d = Deno`), multi-word casts and self./window. receivers uncounted. The review rule and its grep named only the spellings the count now catches; `globalThis.*Deno` cannot match `{ Deno } = globalThis` or `const d = Deno`. The grep now flags any added globalThis and any bare `Deno` not followed by an access; `typeof Deno` and prose hits are left to the reviewer. --- dev-docs/runtime-migration/README.md | 4 ++-- dev-docs/runtime-migration/_TEMPLATE.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-docs/runtime-migration/README.md b/dev-docs/runtime-migration/README.md index c68477f964..78feec0237 100644 --- a/dev-docs/runtime-migration/README.md +++ b/dev-docs/runtime-migration/README.md @@ -175,14 +175,14 @@ Step 0 also audits the plan's code blocks, because the executor transcribes them 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` through `globalThis` to stay out of the ratchet count (`rg -n 'globalThis.*Deno' `); a `Deno.` access the code needs is written as such and the baseline increase is accepted. +- 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 -n '^\+.*globalThis.*Deno' # the layer itself is exempt +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/ ``` diff --git a/dev-docs/runtime-migration/_TEMPLATE.md b/dev-docs/runtime-migration/_TEMPLATE.md index 574de24dc7..25a5bb8c36 100644 --- a/dev-docs/runtime-migration/_TEMPLATE.md +++ b/dev-docs/runtime-migration/_TEMPLATE.md @@ -65,7 +65,7 @@ Commands to run before opening the PR, with expected results: ## Review 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 (`globalThis.Deno`, `globalThis["Deno"]`) to stay out of the ratchet count; a needed `Deno.` access is written as such and the baseline increase is accepted. +- [ ] 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 From ca452bbd307cfa29b46eb54c956a8e6566f4043f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 17:46:14 +0100 Subject: [PATCH 15/45] runtime-migration docs: HANDOFF names 09 as in progress --- dev-docs/runtime-migration/HANDOFF.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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. From a8230cd20e04f843d0ab27215c3c9cfd267f754a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 17:49:25 +0100 Subject: [PATCH 16/45] tools: ts-morph codemod for Deno.* <-> deno_ral rewrites, with fixtures --- tools/codemod-deno-ral.ts | 607 ++++++++++++++++++ tools/codemod/codemod-deno-ral.test.ts | 201 ++++++ .../fixtures/already-migrated.expected.ts | 6 + .../fixtures/already-migrated.input.ts | 6 + .../codemod/fixtures/assignment-skip.input.ts | 3 + tools/codemod/fixtures/env-basic.expected.ts | 6 + tools/codemod/fixtures/env-basic.input.ts | 5 + .../fixtures/env-collision.expected.ts | 4 + tools/codemod/fixtures/env-collision.input.ts | 4 + .../fixtures/existing-alias.expected.ts | 9 + .../codemod/fixtures/existing-alias.input.ts | 9 + .../fixtures/existing-import.expected.ts | 8 + .../codemod/fixtures/existing-import.input.ts | 8 + tools/codemod/fixtures/fs-basic.expected.ts | 17 + tools/codemod/fixtures/fs-basic.input.ts | 16 + .../codemod/fixtures/fs-collision.expected.ts | 11 + tools/codemod/fixtures/fs-collision.input.ts | 10 + tools/codemod/fixtures/no-imports.expected.ts | 6 + tools/codemod/fixtures/no-imports.input.ts | 5 + .../fixtures/platform-basic.expected.ts | 3 + .../codemod/fixtures/platform-basic.input.ts | 2 + .../fixtures/process-basic.expected.ts | 7 + tools/codemod/fixtures/process-basic.input.ts | 6 + tools/codemod/fixtures/realpath.expected.ts | 2 + tools/codemod/fixtures/realpath.input.ts | 1 + .../fixtures/reverse-alias.expected.ts | 8 + .../codemod/fixtures/reverse-left.expected.ts | 6 + tools/codemod/fixtures/reverse-left.input.ts | 6 + tools/codemod/fixtures/temp-basic.expected.ts | 4 + tools/codemod/fixtures/temp-basic.input.ts | 3 + .../fixtures/type-only-existing.expected.ts | 6 + .../fixtures/type-only-existing.input.ts | 5 + 32 files changed, 1000 insertions(+) create mode 100644 tools/codemod-deno-ral.ts create mode 100644 tools/codemod/codemod-deno-ral.test.ts create mode 100644 tools/codemod/fixtures/already-migrated.expected.ts create mode 100644 tools/codemod/fixtures/already-migrated.input.ts create mode 100644 tools/codemod/fixtures/assignment-skip.input.ts create mode 100644 tools/codemod/fixtures/env-basic.expected.ts create mode 100644 tools/codemod/fixtures/env-basic.input.ts create mode 100644 tools/codemod/fixtures/env-collision.expected.ts create mode 100644 tools/codemod/fixtures/env-collision.input.ts create mode 100644 tools/codemod/fixtures/existing-alias.expected.ts create mode 100644 tools/codemod/fixtures/existing-alias.input.ts create mode 100644 tools/codemod/fixtures/existing-import.expected.ts create mode 100644 tools/codemod/fixtures/existing-import.input.ts create mode 100644 tools/codemod/fixtures/fs-basic.expected.ts create mode 100644 tools/codemod/fixtures/fs-basic.input.ts create mode 100644 tools/codemod/fixtures/fs-collision.expected.ts create mode 100644 tools/codemod/fixtures/fs-collision.input.ts create mode 100644 tools/codemod/fixtures/no-imports.expected.ts create mode 100644 tools/codemod/fixtures/no-imports.input.ts create mode 100644 tools/codemod/fixtures/platform-basic.expected.ts create mode 100644 tools/codemod/fixtures/platform-basic.input.ts create mode 100644 tools/codemod/fixtures/process-basic.expected.ts create mode 100644 tools/codemod/fixtures/process-basic.input.ts create mode 100644 tools/codemod/fixtures/realpath.expected.ts create mode 100644 tools/codemod/fixtures/realpath.input.ts create mode 100644 tools/codemod/fixtures/reverse-alias.expected.ts create mode 100644 tools/codemod/fixtures/reverse-left.expected.ts create mode 100644 tools/codemod/fixtures/reverse-left.input.ts create mode 100644 tools/codemod/fixtures/temp-basic.expected.ts create mode 100644 tools/codemod/fixtures/temp-basic.input.ts create mode 100644 tools/codemod/fixtures/type-only-existing.expected.ts create mode 100644 tools/codemod/fixtures/type-only-existing.input.ts diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts new file mode 100644 index 0000000000..74285aa40f --- /dev/null +++ b/tools/codemod-deno-ral.ts @@ -0,0 +1,607 @@ +/* + * 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() +} + +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 RULES: Record = { + fs: { + module: "fs.ts", + members: { ...same(kFsNames), realPathSync: { to: "canonicalPathSync" } }, + 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", +]; + +export interface Replacement { + line: number; + from: string; + to: string; +} +export interface Collision { + name: string; + line: number; + module: 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"; +} + +// 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; +} + +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 assign to Deno.* (monkey patches, instrumentation) are left alone. + for (const pae of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) { + if (!isDenoIdentifier(pae.getExpression())) continue; + const parent = pae.getParent(); + if ( + Node.isBinaryExpression(parent) && parent.getLeft() === pae && + parent.getOperatorToken().getKind() === SyntaxKind.EqualsToken + ) { + result.skipped = `assigns to ${pae.getText()} at line ${pae.getStartLineNumber()}`; + 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) => { + if (!ts.isImportSpecifier(decl) || specifierExport(decl) !== exportName) return false; + const importDecl = decl.parent.parent.parent; + return ts.isStringLiteral(importDecl.moduleSpecifier) && + resolvesToModule(sourceFile, importDecl.moduleSpecifier.text, ralDir, module); + }; + const collisionCache = new Map(); + const blocked = (local: string, module: string, exportName: string): boolean => { + const key = `${module}:${exportName}:${local}`; + 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)); + 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)) 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 (!isDenoIdentifier(pae.getExpression())) 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) || !isDenoIdentifier(typeName.getLeft())) 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); + const typeDecl = decls.find((d) => d.isTypeOnly()); + 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 +} + +function reverseTable(rule: Rule): Record { + const table: Record = {}; + for (const [deno, m] of Object.entries(rule.members)) { + table[m.to] = 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); + + 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) + ); + 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; + if (Node.isTypeReference(parent) && parent.getTypeName() === id) { + 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; + } + 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}) 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..73d688f357 --- /dev/null +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -0,0 +1,201 @@ +/* + * 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, + 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 } = {}, +): { result: CodemodResult; output: string } { + const tmp = Deno.makeTempDirSync({ prefix: "codemod-" }); + try { + const target = join(tmp, "src", "core", `${name}.ts`); + 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 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 --reverse - round-trips the basic fixtures", () => { + const cases: [string, RuleName[]][] = [ + ["fs-basic", ["fs"]], + ["no-imports", ["fs"]], + ["realpath", ["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 - 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 - 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/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/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/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-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); +} From 347e68ddf4dd077d1738a41c4bd858ad2d9a6c99 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 18:11:37 +0100 Subject: [PATCH 17/45] runtime-migration docs: HANDOFF lists what rt/09 needs after 02d merged --- dev-docs/runtime-migration/HANDOFF.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dev-docs/runtime-migration/HANDOFF.md b/dev-docs/runtime-migration/HANDOFF.md index 458f2fd9bd..15c624a6f0 100644 --- a/dev-docs/runtime-migration/HANDOFF.md +++ b/dev-docs/runtime-migration/HANDOFF.md @@ -72,6 +72,17 @@ The C-row correction of `aliasTarget()` in PR 36 is subsumed by PR 36 Decision 4 Two notes for whoever continues: the B1 commit carries the maintainer's author identity by mistake (an amend and force-push was refused; the attribution line is in the message); the triage's C rows for PRs 33, 35 and 36 overlapped files that B2, B4, B7 and B8 edited, and were run after the pass landed (all three are done). +## Pending on `rt/09` before its PR opens + +Written 2026-10-28; delete this section once both items are done. + +1. Rebase onto `main`: PR 02d merged as #15218 (`623cd99e8`). + `HANDOFF.md` conflicts on the two status lines both branches edited; resolve them to "02d (#15218) is merged, 09 is in progress". + After the rebase, set the README `02d` row to `merged`. + `tools/runtime-boundary-baseline.json` merges without a conflict; `quarto run --dev tools/check-runtime-boundary.ts` should then report `src` 992, `resources` 12, `tests` 875 and no "rose" message. +2. Task 4 review: the committed codemod (`c5af406bc`) had four defects, each to be fixed with a fixture before Task 5. + Reverse mode turned a shorthand property (`{ os }`) into invalid syntax (`{ Deno.build.os }`); a type-only namespace import (`import type * as fs`) was overwritten with named imports; an existing `import { type statSync }` plus a `Deno.statSync(...)` call produced a duplicate binding; and forward mode rewrote calls on a local binding named `Deno`. + ## How to resume 1. Work from an up-to-date `main`: the plans are there. From 143007e381e8b3ac0114eb00b79c40927b946a61 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 18:16:41 +0100 Subject: [PATCH 18/45] tools: fix four broken rewrite forms in codemod-deno-ral Findings from a code review of the codemod; each now has a regression fixture (21 tests pass, and --reverse on chrome-for-testing.ts produces valid output). - reverse: expand shorthand properties, e.g. `{ os }` becomes `{ os: Deno.build.os }`, instead of splicing a dotted expression into the shorthand position (invalid syntax). - forward: a locally bound `Deno` (parameter, local, import, type) no longer counts as the runtime global. resolveName with excludeGlobals decides; getSymbolAtLocation cannot be used because it returns a transient symbol with no declarations for the left of a qualified name (`Deno.FileInfo`). - forward: `import type * as fs` is no longer a merge target for named type imports; rewriting it to `import type { X }` dropped the namespace and broke existing `fs.X` uses. - forward: an existing type-only binding of a layer export (inline `type x` or declaration-level `import type`) now conflicts with a value rewrite and is reported, instead of the merge adding a second binding of the same name. Reporting matches the codemod's existing collision philosophy and avoids promoting a deliberate type-only import to a runtime import. --- tools/codemod-deno-ral.ts | 54 +++++++++++++++---- tools/codemod/codemod-deno-ral.test.ts | 31 +++++++++++ .../fixtures/reverse-shorthand.expected.ts | 3 ++ .../fixtures/reverse-shorthand.input.ts | 4 ++ .../fixtures/shadowed-deno.expected.ts | 19 +++++++ tools/codemod/fixtures/shadowed-deno.input.ts | 18 +++++++ .../fixtures/type-ns-import.expected.ts | 6 +++ .../codemod/fixtures/type-ns-import.input.ts | 5 ++ .../fixtures/type-value-conflict.input.ts | 6 +++ 9 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 tools/codemod/fixtures/reverse-shorthand.expected.ts create mode 100644 tools/codemod/fixtures/reverse-shorthand.input.ts create mode 100644 tools/codemod/fixtures/shadowed-deno.expected.ts create mode 100644 tools/codemod/fixtures/shadowed-deno.input.ts create mode 100644 tools/codemod/fixtures/type-ns-import.expected.ts create mode 100644 tools/codemod/fixtures/type-ns-import.input.ts create mode 100644 tools/codemod/fixtures/type-value-conflict.input.ts diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts index 74285aa40f..7d2972fd0b 100644 --- a/tools/codemod-deno-ral.ts +++ b/tools/codemod-deno-ral.ts @@ -142,6 +142,24 @@ function isDenoIdentifier(node: Node | undefined): boolean { return node !== undefined && Node.isIdentifier(node) && node.getText() === "Deno"; } +// The global `Deno` has no declaration under noLib (see newProject), so an +// identifier whose name resolves to a binding in scope (parameter, local, +// import, type) shadows the global and its member accesses must be left +// alone. getSymbolAtLocation is not enough: for the left of a qualified name +// (`Deno.FileInfo`) it returns a transient symbol with no declarations even +// when a local `Deno` binding exists. +function isGlobalDeno(node: Node | undefined, checker: ts.TypeChecker): boolean { + if (!isDenoIdentifier(node)) return false; + // excludeGlobals: a lib-provided global `Deno` must not count as a binding. + const resolved = checker.resolveName( + "Deno", + (node as Identifier).compilerNode, + ts.SymbolFlags.Value | ts.SymbolFlags.Namespace | ts.SymbolFlags.Alias | ts.SymbolFlags.Type, + /*excludeGlobals*/ true, + ); + return resolved === undefined; +} + // A binding or a reference in scope, as opposed to a property name. function isBindingOrReference(id: Node): boolean { const parent = id.getParent(); @@ -263,7 +281,7 @@ function forwardFile( // 1. Files that assign to Deno.* (monkey patches, instrumentation) are left alone. for (const pae of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) { - if (!isDenoIdentifier(pae.getExpression())) continue; + if (!isGlobalDeno(pae.getExpression(), checker)) continue; const parent = pae.getParent(); if ( Node.isBinaryExpression(parent) && parent.getLeft() === pae && @@ -295,22 +313,28 @@ function forwardFile( // 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) => { + 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; - return ts.isStringLiteral(importDecl.moduleSpecifier) && - resolvesToModule(sourceFile, importDecl.moduleSpecifier.text, ralDir, module); + 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): boolean => { - const key = `${module}:${exportName}:${local}`; + 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)); + const ok = decls.length > 0 && decls.every((d) => isLayerBinding(d, module, exportName, forType)); if (!ok) { collision = { name: local, line: id.getStartLineNumber(), module }; break; @@ -330,7 +354,7 @@ function forwardFile( 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)) return false; + 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 }); @@ -340,7 +364,7 @@ function forwardFile( // 4. Value positions: Deno. and Deno... for (const pae of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) { - if (!isDenoIdentifier(pae.getExpression())) continue; + if (!isGlobalDeno(pae.getExpression(), checker)) continue; const name = pae.getName(); const parent = pae.getParent(); for (const ruleName of rules) { @@ -363,7 +387,7 @@ function forwardFile( // 5. Type positions: Deno.. for (const ref of sourceFile.getDescendantsOfKind(SyntaxKind.TypeReference)) { const typeName = ref.getTypeName(); - if (!Node.isQualifiedName(typeName) || !isDenoIdentifier(typeName.getLeft())) continue; + if (!Node.isQualifiedName(typeName) || !isGlobalDeno(typeName.getLeft(), checker)) continue; const name = typeName.getRight().getText(); for (const ruleName of rules) { const rule = RULES[ruleName]; @@ -383,7 +407,9 @@ function forwardFile( for (const [module, names] of wanted) { const decls = declsFor(module); const valueDecl = decls.find((d) => !d.isTypeOnly() && d.getNamespaceImport() === undefined); - const typeDecl = decls.find((d) => d.isTypeOnly()); + // 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); @@ -502,6 +528,12 @@ function reverseFile( 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 }); } diff --git a/tools/codemod/codemod-deno-ral.test.ts b/tools/codemod/codemod-deno-ral.test.ts index 73d688f357..a732898706 100644 --- a/tools/codemod/codemod-deno-ral.test.ts +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -127,6 +127,31 @@ Deno.test("codemod - platform rule rewrites Deno.build.os/arch", () => { assertEquals(output, read("platform-basic.expected.ts")); }); +Deno.test("codemod - a locally bound Deno receiver is left alone", () => { + const { result, output } = run("shadowed-deno", ["fs"]); + assertEquals(output, read("shadowed-deno.expected.ts")); + assertEquals(result.replacements.length, 2); + 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"); @@ -150,6 +175,12 @@ Deno.test("codemod --reverse - round-trips the basic fixtures", () => { } }); +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 - follows aliases and reports what it cannot express", () => { const alias = run("existing-alias", ["platform"], { reverse: true, 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..49475c812c --- /dev/null +++ b/tools/codemod/fixtures/shadowed-deno.expected.ts @@ -0,0 +1,19 @@ +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: Deno.FileInfo): unknown { + return f; +} + +export function readReal(path: string): string { + return readTextFileSync(path); +} + +export function realInfo(f: FileInfo): unknown { + return f; +} diff --git a/tools/codemod/fixtures/shadowed-deno.input.ts b/tools/codemod/fixtures/shadowed-deno.input.ts new file mode 100644 index 0000000000..6e5abb6b08 --- /dev/null +++ b/tools/codemod/fixtures/shadowed-deno.input.ts @@ -0,0 +1,18 @@ +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; +} 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-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]; +} From c0cba65930683d931112ee8c2304c6cc7cb4d0c3 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 18:27:03 +0100 Subject: [PATCH 19/45] tools: resolve Deno shadowing per meaning in codemod-deno-ral Follow-up review of the codemod found two shadowing errors, each now covered by a fixture. - reverse: introducing `Deno.*` at a reference did not check whether a local `Deno` captures it. An imported `statSync(p)` inside a function with a `Deno` parameter became `Deno.statSync(p)`, silently calling the parameter. Such a specifier now keeps its import and is reported in `left`, the same treatment as other uses reverse cannot express. - forward: the shadow check looked `Deno` up as value, namespace, type and alias at once, but TypeScript resolves by meaning. A value-only parameter named Deno does not hide the global namespace in `Deno.FileInfo`, and a local `type Deno` does not hide the global value in `Deno.readTextFileSync()`. Expressions now look up the value meaning and qualified type names the namespace meaning; aliases count for both, because an import named Deno may carry either. A forward dry run over src/ gives the same result before and after (816 replacements, 18 files with collisions, 2 skipped). --- tools/codemod-deno-ral.ts | 56 ++++++++++++------- tools/codemod/codemod-deno-ral.test.ts | 15 ++++- .../reverse-shadowed-deno.expected.ts | 11 ++++ .../fixtures/reverse-shadowed-deno.input.ts | 11 ++++ .../fixtures/shadowed-deno.expected.ts | 7 ++- tools/codemod/fixtures/shadowed-deno.input.ts | 5 ++ 6 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 tools/codemod/fixtures/reverse-shadowed-deno.expected.ts create mode 100644 tools/codemod/fixtures/reverse-shadowed-deno.input.ts diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts index 7d2972fd0b..cfe142bb8b 100644 --- a/tools/codemod-deno-ral.ts +++ b/tools/codemod-deno-ral.ts @@ -142,22 +142,32 @@ function isDenoIdentifier(node: Node | undefined): boolean { return node !== undefined && Node.isIdentifier(node) && node.getText() === "Deno"; } -// The global `Deno` has no declaration under noLib (see newProject), so an -// identifier whose name resolves to a binding in scope (parameter, local, -// import, type) shadows the global and its member accesses must be left -// alone. getSymbolAtLocation is not enough: for the left of a qualified name -// (`Deno.FileInfo`) it returns a transient symbol with no declarations even -// when a local `Deno` binding exists. -function isGlobalDeno(node: Node | undefined, checker: ts.TypeChecker): boolean { - if (!isDenoIdentifier(node)) return false; +// 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. - const resolved = checker.resolveName( - "Deno", - (node as Identifier).compilerNode, - ts.SymbolFlags.Value | ts.SymbolFlags.Namespace | ts.SymbolFlags.Alias | ts.SymbolFlags.Type, - /*excludeGlobals*/ true, - ); - return resolved === undefined; + 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. @@ -281,7 +291,7 @@ function forwardFile( // 1. Files that assign to Deno.* (monkey patches, instrumentation) are left alone. for (const pae of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) { - if (!isGlobalDeno(pae.getExpression(), checker)) continue; + if (!isGlobalDeno(pae.getExpression(), kValueMeaning, checker)) continue; const parent = pae.getParent(); if ( Node.isBinaryExpression(parent) && parent.getLeft() === pae && @@ -364,7 +374,7 @@ function forwardFile( // 4. Value positions: Deno. and Deno... for (const pae of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) { - if (!isGlobalDeno(pae.getExpression(), checker)) continue; + if (!isGlobalDeno(pae.getExpression(), kValueMeaning, checker)) continue; const name = pae.getName(); const parent = pae.getParent(); for (const ruleName of rules) { @@ -387,7 +397,10 @@ function forwardFile( // 5. Type positions: Deno.. for (const ref of sourceFile.getDescendantsOfKind(SyntaxKind.TypeReference)) { const typeName = ref.getTypeName(); - if (!Node.isQualifiedName(typeName) || !isGlobalDeno(typeName.getLeft(), checker)) continue; + if ( + !Node.isQualifiedName(typeName) || + !isGlobalDeno(typeName.getLeft(), kNamespaceMeaning, checker) + ) continue; const name = typeName.getRight().getText(); for (const ruleName of rules) { const rule = RULES[ruleName]; @@ -512,7 +525,12 @@ function reverseFile( const parent = id.getParent()!; let node: Node = id; let to: string | undefined; - if (Node.isTypeReference(parent) && parent.getTypeName() === id) { + 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 && diff --git a/tools/codemod/codemod-deno-ral.test.ts b/tools/codemod/codemod-deno-ral.test.ts index a732898706..8d34556774 100644 --- a/tools/codemod/codemod-deno-ral.test.ts +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -128,9 +128,12 @@ Deno.test("codemod - platform rule rewrites Deno.build.os/arch", () => { }); 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, 2); + assertEquals(result.replacements.length, 4); assertEquals(result.collisions, []); }); @@ -181,6 +184,16 @@ Deno.test("codemod --reverse - expands shorthand properties to explicit assignme 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 - follows aliases and reports what it cannot express", () => { const alias = run("existing-alias", ["platform"], { reverse: true, 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/shadowed-deno.expected.ts b/tools/codemod/fixtures/shadowed-deno.expected.ts index 49475c812c..13342b5026 100644 --- a/tools/codemod/fixtures/shadowed-deno.expected.ts +++ b/tools/codemod/fixtures/shadowed-deno.expected.ts @@ -6,7 +6,7 @@ export function readVia( return Deno.readTextFileSync(path); } -export function info(Deno: { FileInfo: unknown }, f: Deno.FileInfo): unknown { +export function info(Deno: { FileInfo: unknown }, f: FileInfo): unknown { return f; } @@ -17,3 +17,8 @@ export function readReal(path: string): string { 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 index 6e5abb6b08..003ea30f46 100644 --- a/tools/codemod/fixtures/shadowed-deno.input.ts +++ b/tools/codemod/fixtures/shadowed-deno.input.ts @@ -16,3 +16,8 @@ export function readReal(path: string): string { export function realInfo(f: Deno.FileInfo): unknown { return f; } + +export function typeOnly(path: string): string { + type Deno = { readTextFileSync: unknown }; + return Deno.readTextFileSync(path); +} From 7793ce966407d0b230f48c76f020e4914de11a93 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 18:47:27 +0100 Subject: [PATCH 20/45] tools: codemod-deno-ral skips every write to Deno.*, keeps re-exported imports A branch review found two more rewrites that produce invalid code, each now covered by a fixture. - forward: the guard that skips files patching Deno.* matched only a plain `=` on `Deno.x`. `Deno.readTextFileSync &&= ...`, a nested `Deno.build.os = ...` (the node the platform rule replaces), and `delete Deno.env` all passed it and became writes to an imported binding. Any assignment operator, `++`/`--`, or `delete` on either node now skips the file. The skip message keeps its "assigns to" wording, which the plan docs quote. - reverse: `export { os }` and `export { cwd as currentDir }` name the imported binding itself; there is no `Deno.*` spelling for them. The import was dropped (leaving `os` unbound) or the specifier rewritten to `export { Deno.cwd as currentDir }`. Such a specifier now keeps its import and is reported in `left`. Forward and reverse dry runs over src/ (all rules) are unchanged. --- tools/codemod-deno-ral.ts | 47 ++++++++++++++++--- tools/codemod/codemod-deno-ral.test.ts | 24 ++++++++++ .../fixtures/reverse-reexport.expected.ts | 5 ++ .../fixtures/reverse-reexport.input.ts | 5 ++ .../codemod/fixtures/write-compound.input.ts | 3 ++ tools/codemod/fixtures/write-delete.input.ts | 4 ++ tools/codemod/fixtures/write-nested.input.ts | 4 ++ 7 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 tools/codemod/fixtures/reverse-reexport.expected.ts create mode 100644 tools/codemod/fixtures/reverse-reexport.input.ts create mode 100644 tools/codemod/fixtures/write-compound.input.ts create mode 100644 tools/codemod/fixtures/write-delete.input.ts create mode 100644 tools/codemod/fixtures/write-nested.input.ts diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts index cfe142bb8b..717cbe112f 100644 --- a/tools/codemod-deno-ral.ts +++ b/tools/codemod-deno-ral.ts @@ -188,6 +188,28 @@ function isBindingOrReference(id: Node): boolean { return true; } +// The target of an assignment (`=`, `+=`, `&&=`, ...), `++`/`--`, or +// `delete`: rewriting it to an imported binding would not be valid. +function isWriteTarget(node: Node): boolean { + let target = node; + let parent = node.getParent(); + while (parent && Node.isParenthesizedExpression(parent)) { + target = parent; + parent = parent.getParent(); + } + if (!parent) return false; + 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, @@ -289,15 +311,15 @@ function forwardFile( const edits: Edit[] = []; const text = sourceFile.getFullText(); - // 1. Files that assign to Deno.* (monkey patches, instrumentation) are left alone. + // 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`. for (const pae of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) { if (!isGlobalDeno(pae.getExpression(), kValueMeaning, checker)) continue; const parent = pae.getParent(); - if ( - Node.isBinaryExpression(parent) && parent.getLeft() === pae && - parent.getOperatorToken().getKind() === SyntaxKind.EqualsToken - ) { - result.skipped = `assigns to ${pae.getText()} at line ${pae.getStartLineNumber()}`; + const nested = Node.isPropertyAccessExpression(parent) && parent.getExpression() === pae; + const written = [pae, ...(nested ? [parent] : [])].find(isWriteTarget); + if (written) { + result.skipped = `assigns to ${written.getText()} at line ${written.getStartLineNumber()}`; return { result, edits: [] }; } } @@ -500,6 +522,9 @@ function reverseFile( 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) => @@ -513,6 +538,16 @@ function reverseFile( const target = table[spec.getName()]; if (!target) continue; const local = spec.getAliasNode()?.getText() ?? spec.getName(); + // `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 (reexport) { + result.left.push({ name: local, line: reexport.getStartLineNumber(), module: rule.module }); + continue; + } const refs = identifiers.filter((id) => id.getText() === local && isBindingOrReference(id) && id !== spec.getNameNode() && id !== spec.getAliasNode() && diff --git a/tools/codemod/codemod-deno-ral.test.ts b/tools/codemod/codemod-deno-ral.test.ts index 8d34556774..64aa3f87c1 100644 --- a/tools/codemod/codemod-deno-ral.test.ts +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -162,6 +162,20 @@ Deno.test("codemod - a file assigning to Deno.* is skipped whole", () => { assertEquals(output, read("assignment-skip.input.ts")); }); +Deno.test("codemod - compound, nested and delete writes to Deno.* skip 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"], + ]; + 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 --reverse - round-trips the basic fixtures", () => { const cases: [string, RuleName[]][] = [ ["fs-basic", ["fs"]], @@ -194,6 +208,16 @@ Deno.test("codemod --reverse - keeps an import whose Deno.* would be shadowed", 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, 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/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-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-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"; From 13238c562afdde81f5f6885c3f2b130ec5f35da7 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 18:55:12 +0100 Subject: [PATCH 21/45] tools: codemod-deno-ral treats destructuring and for-of targets as writes The write guard only looked at the immediate parent of `Deno.x`, so a destructuring assignment such as `({ readTextFileSync: Deno.readTextFileSync } = instrumented)`, an array pattern `[Deno.build.os] = [...]`, or a `for (Deno.build.os of ...)` head passed it and became a write to the imported binding. isWriteTarget now climbs through the array and object literals, property values and spreads a pattern is written with, and counts the node as written when the outermost literal is the left of `=` or the head of for-of/for-in. A literal that is not a pattern (an argument, an initializer) is still rewritten, which a negative fixture pins. The forward dry run over src/ (all rules) is unchanged. --- tools/codemod-deno-ral.ts | 24 ++++++++++++++++--- tools/codemod/codemod-deno-ral.test.ts | 11 ++++++++- .../fixtures/literal-not-write.expected.ts | 3 +++ .../fixtures/literal-not-write.input.ts | 2 ++ .../fixtures/write-destructure-array.input.ts | 2 ++ .../write-destructure-object.input.ts | 3 +++ tools/codemod/fixtures/write-for-of.input.ts | 2 ++ 7 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 tools/codemod/fixtures/literal-not-write.expected.ts create mode 100644 tools/codemod/fixtures/literal-not-write.input.ts create mode 100644 tools/codemod/fixtures/write-destructure-array.input.ts create mode 100644 tools/codemod/fixtures/write-destructure-object.input.ts create mode 100644 tools/codemod/fixtures/write-for-of.input.ts diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts index 717cbe112f..8d05916402 100644 --- a/tools/codemod-deno-ral.ts +++ b/tools/codemod-deno-ral.ts @@ -188,16 +188,34 @@ function isBindingOrReference(id: Node): boolean { return true; } -// The target of an assignment (`=`, `+=`, `&&=`, ...), `++`/`--`, or -// `delete`: rewriting it to an imported binding would not be valid. +// 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(); - while (parent && Node.isParenthesizedExpression(parent)) { + let inPattern = false; + // Climb parentheses 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 ( + !Node.isParenthesizedExpression(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 && diff --git a/tools/codemod/codemod-deno-ral.test.ts b/tools/codemod/codemod-deno-ral.test.ts index 64aa3f87c1..074bce9956 100644 --- a/tools/codemod/codemod-deno-ral.test.ts +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -162,11 +162,14 @@ Deno.test("codemod - a file assigning to Deno.* is skipped whole", () => { assertEquals(output, read("assignment-skip.input.ts")); }); -Deno.test("codemod - compound, nested and delete writes to Deno.* skip the file", () => { +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"], ]; for (const [name, rules, skipped] of cases) { const { result, output } = run(name, rules); @@ -176,6 +179,12 @@ Deno.test("codemod - compound, nested and delete writes to Deno.* skip the file" } }); +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 --reverse - round-trips the basic fixtures", () => { const cases: [string, RuleName[]][] = [ ["fs-basic", ["fs"]], 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/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-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; From ce7c5daa651139bc9a905649289c0852226d56c8 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 19:10:09 +0100 Subject: [PATCH 22/45] tools: codemod-deno-ral sees writes through type assertions and wrapped receivers The write guard climbed only parentheses, so `(Deno.readTextFileSync as any) = r` or `Deno.build.os! = "linux"` passed it and became a write to the imported binding. It also only looked at `Deno.x` with a bare `Deno` receiver, so a patch through `(Deno as any).readTextFileSync = ...` or `Deno["readTextFileSync"] = ...` went unseen. That case is worse than a type error: a `const original = Deno.readTextFileSync` captured earlier in the file is rewritten to the layer wrapper, which forwards to the patched function, so the patch calls itself. Parentheses, `as`, `x`, `x!` and `satisfies` are now one set of transparent wrappers, climbed both above a target and above the `Deno` receiver, and property and element access are both checked. Fixtures for each form were rewritten by the previous guard and are now skipped unchanged. The forward dry run over src/ (all rules) is unchanged. --- tools/codemod-deno-ral.ts | 48 +++++++++++++++---- tools/codemod/codemod-deno-ral.test.ts | 11 +++++ .../fixtures/write-asserted-nested.input.ts | 2 + .../fixtures/write-asserted-receiver.input.ts | 3 ++ .../fixtures/write-asserted-target.input.ts | 3 ++ .../fixtures/write-element-access.input.ts | 3 ++ .../codemod/fixtures/write-non-null.input.ts | 2 + 7 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 tools/codemod/fixtures/write-asserted-nested.input.ts create mode 100644 tools/codemod/fixtures/write-asserted-receiver.input.ts create mode 100644 tools/codemod/fixtures/write-asserted-target.input.ts create mode 100644 tools/codemod/fixtures/write-element-access.input.ts create mode 100644 tools/codemod/fixtures/write-non-null.input.ts diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts index 8d05916402..a0593795a7 100644 --- a/tools/codemod-deno-ral.ts +++ b/tools/codemod-deno-ral.ts @@ -188,6 +188,32 @@ function isBindingOrReference(id: Node): boolean { 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 `.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; +} + // 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 @@ -196,13 +222,13 @@ function isWriteTarget(node: Node): boolean { let target = node; let parent = node.getParent(); let inPattern = false; - // Climb parentheses and the literals that a destructuring pattern is - // written with; whether the outermost literal is a pattern is decided below. + // 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 ( - !Node.isParenthesizedExpression(parent) && !Node.isSpreadElement(parent) && + !isTransparentWrapper(parent) && !Node.isSpreadElement(parent) && !Node.isSpreadAssignment(parent) && !(Node.isPropertyAssignment(parent) && parent.getInitializer() === target) ) break; @@ -330,12 +356,16 @@ function forwardFile( 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`. - for (const pae of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) { - if (!isGlobalDeno(pae.getExpression(), kValueMeaning, checker)) continue; - const parent = pae.getParent(); - const nested = Node.isPropertyAccessExpression(parent) && parent.getExpression() === pae; - const written = [pae, ...(nested ? [parent] : [])].find(isWriteTarget); + // 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. + for (const id of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) { + if (!isGlobalDeno(id, kValueMeaning, checker)) continue; + const access = accessOn(id); + if (!access) continue; + const nested = accessOn(access); + const written = [access, ...(nested ? [nested] : [])].find(isWriteTarget); if (written) { result.skipped = `assigns to ${written.getText()} at line ${written.getStartLineNumber()}`; return { result, edits: [] }; diff --git a/tools/codemod/codemod-deno-ral.test.ts b/tools/codemod/codemod-deno-ral.test.ts index 074bce9956..a7eab513ed 100644 --- a/tools/codemod/codemod-deno-ral.test.ts +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -170,6 +170,17 @@ Deno.test("codemod - every form of write to Deno.* skips the file", () => { ["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'], ]; for (const [name, rules, skipped] of cases) { const { result, output } = run(name, rules); 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-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-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"; From 73d017f4f6beb1ae3cd4268f0f8b4649e32d53fd Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 19:36:24 +0100 Subject: [PATCH 23/45] tools: codemod-deno-ral skips files that use Deno other than as Deno.x A file that captures `const original = Deno.readTextFileSync` and then patches the method with `Object.defineProperty(Deno, ...)` passed the write guard. The codemod rewrote `original` to the layer wrapper, which forwards to the patched method, so the patch called itself. An alias (`const D = Deno; D.readTextFileSync = ...`) or `globalThis.Deno` hid the same patch. Rather than list every call that can mutate Deno, the guard now holds the codemod to the rule the PR review checklist already applies to src/: Deno is only reached as `Deno.x`. A file where the global Deno is an alias, an argument, a spread or a destructuring source, or is reached through globalThis/self/window, is skipped whole; `typeof Deno`, type positions and declaration names are not uses. For nested rules, `Object.assign(Deno.build, ...)` and the other Object/Reflect mutators also count as writes. What happens past an escape, or in another file, is still left to that review rule. The forward dry run over src/ (all rules) is unchanged: the only bare Deno uses are in the already-skipped monkey-patch.ts and in the excluded src/core/lib. --- tools/codemod-deno-ral.ts | 69 +++++++++++++++++-- tools/codemod/codemod-deno-ral.test.ts | 12 ++++ .../fixtures/mutator-not-write.expected.ts | 4 ++ .../fixtures/mutator-not-write.input.ts | 3 + tools/codemod/fixtures/write-alias.input.ts | 4 ++ .../fixtures/write-define-property.input.ts | 3 + .../write-global-this-element.input.ts | 3 + .../fixtures/write-global-this.input.ts | 3 + .../fixtures/write-object-assign.input.ts | 2 + 9 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 tools/codemod/fixtures/mutator-not-write.expected.ts create mode 100644 tools/codemod/fixtures/mutator-not-write.input.ts create mode 100644 tools/codemod/fixtures/write-alias.input.ts create mode 100644 tools/codemod/fixtures/write-define-property.input.ts create mode 100644 tools/codemod/fixtures/write-global-this-element.input.ts create mode 100644 tools/codemod/fixtures/write-global-this.input.ts create mode 100644 tools/codemod/fixtures/write-object-assign.input.ts diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts index a0593795a7..280adf9c2a 100644 --- a/tools/codemod-deno-ral.ts +++ b/tools/codemod-deno-ral.ts @@ -214,6 +214,54 @@ function accessOn(node: Node): Node | undefined { 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; + // 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); +} + +// `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); + if (Node.isPropertyAccessExpression(access) && access.getName() === "Deno") return access; + const arg = Node.isElementAccessExpression(access) ? access.getArgumentExpression() : undefined; + if (Node.isStringLiteral(arg) && arg.getLiteralText() === "Deno") return access; + return undefined; +} + +// Calls that replace or remove members of their first argument. +const kMutators: Record = { + 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, ...)`). +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 = call.getExpression(); + if (!Node.isPropertyAccessExpression(callee)) return undefined; + const owner = callee.getExpression(); + if (!Node.isIdentifier(owner) || !kMutators[owner.getText()]?.includes(callee.getName())) { + return undefined; + } + return callee.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 @@ -359,17 +407,26 @@ function forwardFile( // 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. + // 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. + const skip = (reason: string, node: Node) => { + result.skipped = `${reason} at line ${node.getStartLineNumber()}`; + return { result, edits: [] }; + }; for (const id of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) { - if (!isGlobalDeno(id, kValueMeaning, checker)) continue; + 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) { - result.skipped = `assigns to ${written.getText()} at line ${written.getStartLineNumber()}`; - return { result, edits: [] }; - } + if (written) return skip(`assigns to ${written.getText()}`, written); + const mutator = mutatorOf(access); + if (mutator) return skip(`assigns to ${access.getText()} via ${mutator}`, access); } const importDecls = sourceFile.getImportDeclarations(); diff --git a/tools/codemod/codemod-deno-ral.test.ts b/tools/codemod/codemod-deno-ral.test.ts index a7eab513ed..c81afef12f 100644 --- a/tools/codemod/codemod-deno-ral.test.ts +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -181,6 +181,12 @@ Deno.test("codemod - every form of write to Deno.* skips the file", () => { ], ["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-alias", ["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-object-assign", ["platform"], "assigns to Deno.build via Object.assign at line 2"], ]; for (const [name, rules, skipped] of cases) { const { result, output } = run(name, rules); @@ -196,6 +202,12 @@ Deno.test("codemod - Deno.* inside object and array literals that are not writte 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 --reverse - round-trips the basic fixtures", () => { const cases: [string, RuleName[]][] = [ ["fs-basic", ["fs"]], 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/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-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-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.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-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" }); From ace18fd2686913bc21953fa2283b8fbcc9a284b5 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 19:39:39 +0100 Subject: [PATCH 24/45] rt-09 plan: codemod forward item 1 lists the current skip reasons The plan said only a plain assignment to `Deno.` skips a file. After the write-guard review rounds the codemod reports four skip reasons (writes, nested mutator calls, Deno used other than as `Deno.x`, Deno reached through globalThis/self/window), and the check is per file, with anything further left to the README rule against reaching Deno indirectly. Item 1 now states that, with the recursion it prevents. --- dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 cd0e49059a..250c9dd714 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 @@ -217,7 +217,13 @@ 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"`. +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"]`. + + 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`). This keeps `src/core/deno/monkey-patch.ts` and (until PR 20b) `src/core/performance/metrics.ts` untouched automatically. 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. From 9ac1f1beb18c854c21c464ee3ed9cd4b1b385760 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 19:40:28 +0100 Subject: [PATCH 25/45] runtime-migration docs: PR 09 task 4 logged, write-guard review rounds recorded Steps 1-7 of Task 4 are ticked and logged, including the CRLF fixture normalization and the one-file delta in the dry-run count. A "Review of Task 4" section records the four write-guard rounds commit by commit, with the self-calling patch that motivates the guard and the dry-run figures that held unchanged across them, so the next session does not rediscover why the guard is broader than the plan's code block. --- .../09-ral-fs-and-temp-layer.md | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) 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 250c9dd714..fb0a40a307 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 @@ -1255,7 +1255,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). @@ -1566,7 +1566,7 @@ export function stop() { } ``` -- [ ] **Step 2: Write the failing test** +- [x] **Step 2: Write the failing test** `tools/codemod/codemod-deno-ral.test.ts`: @@ -1774,11 +1774,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 /* @@ -2390,7 +2390,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). @@ -2398,7 +2398,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): ``` @@ -2412,7 +2412,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 @@ -2640,3 +2640,22 @@ Executed 2026-10-28 on branch `rt/09-ral-fs-and-temp-layer` (Windows 11, pwsh + - `$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, in `temp.ts` and `fs.test.ts`, are tracked in beads). `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`; this worktree's `src/core` gained a rewritable call in Tasks 1–3 (one extra file), not an error. `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 + +Four 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. +- After each round, `package/dist/bin/tools/x86_64/deno test --allow-all --import-map=src/import_map.json tools/codemod/` passed (26 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`). From 5648cf0675abc9b4e75503e67af1b4a9268e755c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 19:49:00 +0100 Subject: [PATCH 26/45] tools: codemod-deno-ral treats { Deno } as a use and unwraps mutator callees A shorthand property `{ Deno }` names both the property and the value it reads, so the declaration-name exemption also exempted the read. With `const holder = { Deno }`, a patch through `holder.Deno.readTextFileSync` went unseen and an earlier `const original = Deno.readTextFileSync` became the layer wrapper, which then called the patched method, itself. A shorthand `Deno` now counts as a use, like any other escape. The Object/Reflect mutator check for nested targets required a bare `Object.assign` callee, so `(Object.assign)(Deno.build, ...)` passed. The callee and its owner are now unwrapped from parentheses and type assertions first. The mutator table was a plain object, so an owner named after an Object.prototype key (`constructor.inspect(Deno.readTextFileSync)`) found the inherited `Object` constructor and the codemod threw on `.includes`. It is now a Map. `(Object.defineProperty)(Deno, ...)` was already skipped, since Deno as an argument is an escape; a fixture pins it. The forward dry run over src/ with all rules is unchanged. --- tools/codemod-deno-ral.ts | 33 +++++++++++++------ tools/codemod/codemod-deno-ral.test.ts | 13 ++++++++ .../mutator-inherited-key.expected.ts | 4 +++ .../fixtures/mutator-inherited-key.input.ts | 3 ++ .../write-define-property-paren.input.ts | 3 ++ .../write-object-assign-paren.input.ts | 2 ++ .../codemod/fixtures/write-shorthand.input.ts | 4 +++ 7 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 tools/codemod/fixtures/mutator-inherited-key.expected.ts create mode 100644 tools/codemod/fixtures/mutator-inherited-key.input.ts create mode 100644 tools/codemod/fixtures/write-define-property-paren.input.ts create mode 100644 tools/codemod/fixtures/write-object-assign-paren.input.ts create mode 100644 tools/codemod/fixtures/write-shorthand.input.ts diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts index 280adf9c2a..f6275be95f 100644 --- a/tools/codemod-deno-ral.ts +++ b/tools/codemod-deno-ral.ts @@ -202,6 +202,15 @@ function outermostWrapper(node: Node): Node { 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 { @@ -221,6 +230,8 @@ 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) && @@ -241,25 +252,27 @@ function globalObjectDeno(id: Node): Node | undefined { return undefined; } -// Calls that replace or remove members of their first argument. -const kMutators: Record = { - Object: ["assign", "defineProperty", "defineProperties"], - Reflect: ["set", "defineProperty", "deleteProperty"], -}; +// 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, ...)`). +// such a call (`Object.assign(Deno.build, ...)`), the callee and its owner +// possibly wrapped too (`(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 = call.getExpression(); + const callee = unwrap(call.getExpression()); if (!Node.isPropertyAccessExpression(callee)) return undefined; - const owner = callee.getExpression(); - if (!Node.isIdentifier(owner) || !kMutators[owner.getText()]?.includes(callee.getName())) { + const owner = unwrap(callee.getExpression()); + if (!Node.isIdentifier(owner) || !kMutators.get(owner.getText())?.includes(callee.getName())) { return undefined; } - return callee.getText(); + return call.getExpression().getText(); } // The target of an assignment (`=`, `+=`, `&&=`, ...), `++`/`--`, `delete`, diff --git a/tools/codemod/codemod-deno-ral.test.ts b/tools/codemod/codemod-deno-ral.test.ts index c81afef12f..002d8d0124 100644 --- a/tools/codemod/codemod-deno-ral.test.ts +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -183,10 +183,17 @@ Deno.test("codemod - every form of write to Deno.* skips the file", () => { ["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-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", + ], ]; for (const [name, rules, skipped] of cases) { const { result, output } = run(name, rules); @@ -208,6 +215,12 @@ Deno.test("codemod - Deno.x as a later argument and typeof Deno are not writes", 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"]], 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/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-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-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"); From 0839065100146de04ae5a132ffccc57147cb5a7b Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 19:49:06 +0100 Subject: [PATCH 27/45] runtime-migration docs: PR 09 fifth write-guard round, dry-run delta attributed The Task 4 log credited the extra rewritten file in the Step 6 dry run to Tasks 1-3, which touch only src/deno_ral and tests/unit/ral. The file is src/core/bibliography.ts, which gained Deno.readTextFileSync in upstream d0bc2d947 between b86f98d and the branch base. --- dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 fb0a40a307..a57a229415 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 @@ -2646,16 +2646,17 @@ Executed 2026-10-28 on branch `rt/09-ral-fs-and-temp-layer` (Windows 11, pwsh + - 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`; this worktree's `src/core` gained a rewritable call in Tasks 1–3 (one extra file), not an error. `git status --short src` empty (dry-run wrote nothing). +- 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 -Four 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. +Five 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. -- After each round, `package/dist/bin/tools/x86_64/deno test --allow-all --import-map=src/import_map.json tools/codemod/` passed (26 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`). +- 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. +- 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`). From bbd1831a528ee15bc7d13d274e0780ab9f21060a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 19:53:42 +0100 Subject: [PATCH 28/45] tools: codemod-deno-ral sees Object["assign"] as a mutator The nested-target mutator check matched only a dotted callee, so `Object["assign"](Deno.build, ...)` was not recognised as a write and the file's `Deno.build.os` reads were rewritten. The callee may now be an element access with a string literal key. globalObjectDeno already read `o.x` and `o["x"]` the same way; both guards now share one memberOf helper. The forward dry run over src/ with all rules is unchanged. --- tools/codemod-deno-ral.ts | 28 +++++++++++++------ tools/codemod/codemod-deno-ral.test.ts | 5 ++++ .../write-object-assign-element.input.ts | 2 ++ 3 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 tools/codemod/fixtures/write-object-assign-element.input.ts diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts index f6275be95f..0a1d0cefc4 100644 --- a/tools/codemod-deno-ral.ts +++ b/tools/codemod-deno-ral.ts @@ -241,15 +241,24 @@ function isDenoEscape(id: Node): boolean { !Node.isTypeQuery(parent) && !Node.isTypeReference(parent); } +// The object and member name of `o.x` or `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 arg = node.getArgumentExpression(); + return Node.isStringLiteral(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); - if (Node.isPropertyAccessExpression(access) && access.getName() === "Deno") return access; - const arg = Node.isElementAccessExpression(access) ? access.getArgumentExpression() : undefined; - if (Node.isStringLiteral(arg) && arg.getLiteralText() === "Deno") return access; - return undefined; + return memberOf(access)?.name === "Deno" ? access : undefined; } // Calls that replace or remove members of their first argument. A Map, so @@ -261,15 +270,16 @@ const kMutators = new Map([ // `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)(...)`). +// 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 = unwrap(call.getExpression()); - if (!Node.isPropertyAccessExpression(callee)) return undefined; - const owner = unwrap(callee.getExpression()); - if (!Node.isIdentifier(owner) || !kMutators.get(owner.getText())?.includes(callee.getName())) { + 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(); diff --git a/tools/codemod/codemod-deno-ral.test.ts b/tools/codemod/codemod-deno-ral.test.ts index 002d8d0124..4e94c45e9d 100644 --- a/tools/codemod/codemod-deno-ral.test.ts +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -194,6 +194,11 @@ Deno.test("codemod - every form of write to Deno.* skips the file", () => { ["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', + ], ]; for (const [name, rules, skipped] of cases) { const { result, output } = run(name, rules); 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" }); From 7908159b045e6868fb9e2eae06fde4b795905e56 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 19:53:59 +0100 Subject: [PATCH 29/45] runtime-migration docs: PR 09 write-guard log cites bf7d2da18 and 9472526fd The fifth-round bullet lacked its commit, unlike the earlier rounds, and the bracket-callee follow-up was not logged. --- dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 a57a229415..6a38bcce21 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 @@ -2658,5 +2658,6 @@ Five review rounds on the forward guard that skips files patching `Deno.*` (forw - `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. -- 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. +- `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. - 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`). From 4d169168c88776cc2d87cbca9e83fc587ee7d1f9 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 20:25:29 +0100 Subject: [PATCH 30/45] tools: codemod-deno-ral reads `x` template keys as static member names memberOf accepted only quoted keys, so globalThis[`Deno`] and Object[`assign`] slipped past the escape and mutator guards. A file that captured Deno.readTextFileSync and then patched it through globalThis[`Deno`] was rewritten rather than skipped, which made the captured original the layer wrapper and the patched function call itself. A template literal without substitutions names its member as statically as a string literal does. Fixtures cover both callers of memberOf: the global-object path and the mutator callee. --- tools/codemod-deno-ral.ts | 4 ++-- tools/codemod/codemod-deno-ral.test.ts | 10 ++++++++++ .../fixtures/write-global-this-template.input.ts | 3 +++ .../fixtures/write-object-assign-template.input.ts | 2 ++ 4 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 tools/codemod/fixtures/write-global-this-template.input.ts create mode 100644 tools/codemod/fixtures/write-object-assign-template.input.ts diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts index 0a1d0cefc4..4a1487fc5f 100644 --- a/tools/codemod-deno-ral.ts +++ b/tools/codemod-deno-ral.ts @@ -241,14 +241,14 @@ function isDenoEscape(id: Node): boolean { !Node.isTypeQuery(parent) && !Node.isTypeReference(parent); } -// The object and member name of `o.x` or `o["x"]`. +// The object and member name of `o.x`, `o["x"]` or 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 arg = node.getArgumentExpression(); - return Node.isStringLiteral(arg) + return Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg) ? { object: node.getExpression(), name: arg.getLiteralText() } : undefined; } diff --git a/tools/codemod/codemod-deno-ral.test.ts b/tools/codemod/codemod-deno-ral.test.ts index 4e94c45e9d..98c0d4f127 100644 --- a/tools/codemod/codemod-deno-ral.test.ts +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -188,6 +188,11 @@ Deno.test("codemod - every form of write to Deno.* skips the file", () => { ["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-object-assign", ["platform"], "assigns to Deno.build via Object.assign at line 2"], [ "write-object-assign-paren", @@ -199,6 +204,11 @@ Deno.test("codemod - every form of write to Deno.* skips the file", () => { ["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", + ], ]; for (const [name, rules, skipped] of cases) { const { result, output } = run(name, rules); 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-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" }); From 502032971ec8120314dc5010affb61d65a7d3254 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 20:34:20 +0100 Subject: [PATCH 31/45] tools: codemod-deno-ral reads parenthesized keys as static member names memberOf checked the literal kind of the raw element-access argument, so globalThis[("Deno")] and Object[("assign")] slipped past the escape and mutator guards, as template keys did before the previous fix. A file that captured Deno.readTextFileSync and then patched it through globalThis[("Deno")] was rewritten rather than skipped, making the captured original the layer wrapper and the patched function call itself. The key is now unwrapped through the same transparent wrappers the receiver and callee already are. Fixtures cover both callers of memberOf: the global-object path and the mutator callee. The forward dry run over src/ is unchanged. --- tools/codemod-deno-ral.ts | 6 ++++-- tools/codemod/codemod-deno-ral.test.ts | 10 ++++++++++ .../fixtures/write-global-this-paren-key.input.ts | 3 +++ .../fixtures/write-object-assign-paren-key.input.ts | 2 ++ 4 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 tools/codemod/fixtures/write-global-this-paren-key.input.ts create mode 100644 tools/codemod/fixtures/write-object-assign-paren-key.input.ts diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts index 4a1487fc5f..96c2c86bc6 100644 --- a/tools/codemod-deno-ral.ts +++ b/tools/codemod-deno-ral.ts @@ -241,13 +241,15 @@ function isDenoEscape(id: Node): boolean { !Node.isTypeQuery(parent) && !Node.isTypeReference(parent); } -// The object and member name of `o.x`, `o["x"]` or o[`x`]. +// 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 arg = node.getArgumentExpression(); + const key = node.getArgumentExpression(); + const arg = key && unwrap(key); return Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg) ? { object: node.getExpression(), name: arg.getLiteralText() } : undefined; diff --git a/tools/codemod/codemod-deno-ral.test.ts b/tools/codemod/codemod-deno-ral.test.ts index 98c0d4f127..4adde02275 100644 --- a/tools/codemod/codemod-deno-ral.test.ts +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -193,6 +193,11 @@ Deno.test("codemod - every form of write to Deno.* skips the file", () => { ["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", @@ -209,6 +214,11 @@ Deno.test("codemod - every form of write to Deno.* skips the file", () => { ["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); 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-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" }); From 99e13d785217872fa336a8b793848def5c594979 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 20:36:14 +0100 Subject: [PATCH 32/45] runtime-migration docs: PR 09 write-guard log adds the sixth and seventh rounds The template-key round (3c973e12b) was not logged, so the parenthesized-key round (428714395) is the seventh rather than the sixth. Both now have a bullet and the intro counts seven rounds. --- dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 6a38bcce21..9ee84b6988 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 @@ -2651,7 +2651,7 @@ Executed 2026-10-28 on branch `rt/09-ral-fs-and-temp-layer` (Windows 11, pwsh + ### Review of Task 4: the write guard -Five 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. +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. @@ -2660,4 +2660,6 @@ Five review rounds on the forward guard that skips files patching `Deno.*` (forw - `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`). From e05db9bf7a59f465b4af3979fe3af987aef4074d Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Oct 2026 10:43:19 +0100 Subject: [PATCH 33/45] tools: codemod-deno-ral --reverse reports canonicalPathSync instead of mapping it to Deno.realPathSync On stable branches Deno.realPathSync is the monkey-patched normalizePath, which neither resolves symlinks nor throws, so reversing to it would silently change semantics. --reverse now keeps the import and reports the binding in `left` with a hint naming originalRealPathSync from deno_ral/original-real-path.ts; the hint check runs before the re-export check so a re-exported canonicalPathSync keeps the hint too. --- .../09-ral-fs-and-temp-layer.md | 11 +++-- tools/codemod-deno-ral.ts | 41 +++++++++++++++---- tools/codemod/codemod-deno-ral.test.ts | 20 ++++++++- .../reverse-realpath-reexport.input.ts | 2 + .../fixtures/reverse-realpath.expected.ts | 2 + .../fixtures/reverse-realpath.input.ts | 2 + 6 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 tools/codemod/fixtures/reverse-realpath-reexport.input.ts create mode 100644 tools/codemod/fixtures/reverse-realpath.expected.ts create mode 100644 tools/codemod/fixtures/reverse-realpath.input.ts 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 9ee84b6988..40ffb42d14 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 @@ -237,7 +237,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`. @@ -2473,7 +2474,7 @@ 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`. @@ -2552,7 +2553,7 @@ 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. +- [ ] `--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 is unchanged. ## Risks and rollback @@ -2663,3 +2664,7 @@ Seven review rounds on the forward guard that skips files patching `Deno.*` (for - `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. diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts index 96c2c86bc6..6a931a9130 100644 --- a/tools/codemod-deno-ral.ts +++ b/tools/codemod-deno-ral.ts @@ -32,6 +32,7 @@ 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 { @@ -54,10 +55,16 @@ const kFsNames = [ 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" } }, + members: { + ...same(kFsNames), + realPathSync: { to: "canonicalPathSync", reverseHint: kCanonicalReverseHint }, + }, types: { FileInfo: "FileInfo", DirEntry: "DirEntry", FsFile: "FsFile" }, nested: {}, }, @@ -109,6 +116,7 @@ export interface Collision { name: string; line: number; module: string; + hint?: string; } export interface CodemodResult { file: string; @@ -626,12 +634,17 @@ 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.callAfter ? { call: `Deno.${deno}` } : { value: `Deno.${deno}` }; + 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}` }; @@ -668,21 +681,33 @@ function reverseFile( 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 refs = identifiers.filter((id) => - id.getText() === local && isBindingOrReference(id) && - id !== spec.getNameNode() && id !== spec.getAliasNode() && - referencedDeclarations(id, checker).includes(spec.compilerNode) - ); const specEdits: Edit[] = []; const specReplacements: Replacement[] = []; let complete = true; @@ -799,7 +824,7 @@ if (import.meta.main) { } for (const c of r.left) { console.log( - `${rel}:${c.line}: left: '${c.name}' (deno_ral/${c.module}) has a use --reverse cannot express; edit by hand`, + `${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++; diff --git a/tools/codemod/codemod-deno-ral.test.ts b/tools/codemod/codemod-deno-ral.test.ts index 4adde02275..c385ba7110 100644 --- a/tools/codemod/codemod-deno-ral.test.ts +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -15,6 +15,7 @@ import { codemodFile, type CodemodResult, collectFiles, + kCanonicalReverseHint, type RuleName, } from "../codemod-deno-ral.ts"; @@ -250,7 +251,6 @@ Deno.test("codemod --reverse - round-trips the basic fixtures", () => { const cases: [string, RuleName[]][] = [ ["fs-basic", ["fs"]], ["no-imports", ["fs"]], - ["realpath", ["fs"]], ["temp-basic", ["temp"]], ["env-basic", ["env"]], ["process-basic", ["process"]], @@ -300,6 +300,24 @@ Deno.test("codemod --reverse - follows aliases and reports what it cannot expres 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 { 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)); From d8418525e8c7a54edc710efc35e1a4a5da7c4c96 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Oct 2026 10:51:38 +0100 Subject: [PATCH 34/45] tools: codemod-deno-ral freezes the write guard and skips the known patch files by path Seven review rounds each closed one bypass spelling and more remain (Reflect.get(globalThis, "Deno"), a computed globalThis[k], a comma-wrapped Object.assign((0, Deno.build), ...)); no file in the repo uses them. The guard is documented as best-effort and frozen, and kCodemodPatchFiles protects the two files that actually patch Deno (src/core/deno/monkey-patch.ts, src/core/performance/metrics.ts) as a backstop checked after the guard, so a caught file keeps the guard's reason. --- .../09-ral-fs-and-temp-layer.md | 5 ++++- dev-docs/runtime-migration/README.md | 2 ++ tools/codemod-deno-ral.ts | 22 +++++++++++++++++++ tools/codemod/codemod-deno-ral.test.ts | 13 +++++++++-- .../fixtures/patch-file-backstop.input.ts | 3 +++ 5 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 tools/codemod/fixtures/patch-file-backstop.input.ts 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 40ffb42d14..c98c7c1c66 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 @@ -222,9 +222,11 @@ Behaviour (forward): - `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`). - This keeps `src/core/deno/monkey-patch.ts` and (until PR 20b) `src/core/performance/metrics.ts` untouched automatically. + 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. @@ -2668,3 +2670,4 @@ Seven review rounds on the forward guard that skips files patching `Deno.*` (for ### 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. diff --git a/dev-docs/runtime-migration/README.md b/dev-docs/runtime-migration/README.md index 78feec0237..ca478d557f 100644 --- a/dev-docs/runtime-migration/README.md +++ b/dev-docs/runtime-migration/README.md @@ -132,6 +132,8 @@ 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 | diff --git a/tools/codemod-deno-ral.ts b/tools/codemod-deno-ral.ts index 6a931a9130..8b20e8a444 100644 --- a/tools/codemod-deno-ral.ts +++ b/tools/codemod-deno-ral.ts @@ -107,6 +107,15 @@ export const kCodemodExclusions: readonly string[] = [ "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; @@ -444,6 +453,11 @@ function forwardFile( // 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: [] }; @@ -462,6 +476,14 @@ function forwardFile( 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) => diff --git a/tools/codemod/codemod-deno-ral.test.ts b/tools/codemod/codemod-deno-ral.test.ts index c385ba7110..284c0cbda1 100644 --- a/tools/codemod/codemod-deno-ral.test.ts +++ b/tools/codemod/codemod-deno-ral.test.ts @@ -27,11 +27,11 @@ const read = (name: string) => Deno.readTextFileSync(join(fixtures, name)); function run( name: string, rules: RuleName[], - opts: { reverse?: boolean; input?: string } = {}, + opts: { reverse?: boolean; input?: string; path?: string } = {}, ): { result: CodemodResult; output: string } { const tmp = Deno.makeTempDirSync({ prefix: "codemod-" }); try { - const target = join(tmp, "src", "core", `${name}.ts`); + 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, { @@ -229,6 +229,15 @@ Deno.test("codemod - every form of write to Deno.* skips the file", () => { } }); +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); 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); From bb0a773aa02171a6876f958866c903381dcb9d10 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Oct 2026 10:58:33 +0100 Subject: [PATCH 35/45] runtime-migration docs: PR 09 plan matches its execution; tracking section is tool-neutral The committed docs carried tracker IDs, local worktree paths and tracker instructions that only make sense on the maintainer's machine; progress tracking in README is now tool-neutral. Acceptance criteria, Verification and the review checklist are updated to the executed numbers (codemod suite 29 passed, ratchet baseline tests 874 -> 875 via the three-dot diff against the merge base). The ReadFileOptions review item is closed without a code change and the Context records why the layer's reads stay path-only. --- .../09-ral-fs-and-temp-layer.md | 25 ++++++++++--------- dev-docs/runtime-migration/README.md | 6 ++--- 2 files changed, 15 insertions(+), 16 deletions(-) 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 c98c7c1c66..83e298422b 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). @@ -255,10 +255,10 @@ 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 @@ -2537,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`). @@ -2556,7 +2556,7 @@ git commit -m "codemod CI step, backport step, rules docs and changelog for deno - [ ] 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; `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 is unchanged. +- [ ] 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 @@ -2589,14 +2589,14 @@ Executed 2026-10-28 on branch `rt/09-ral-fs-and-temp-layer` (Windows 11, pwsh + - 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. -- Beads: `quarto-cli-81ld.13` open with both `blocks` dependencies closed (PR 02 #15135, PR 02c #15153). `quarto-cli-81ld.50` (plan defect: contract tests imported `testing/asserts`) is closed: 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). +- 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:///C:/Users/chris/Documents/DEV_R/quarto-cli.worktrees/rt-09-ral-fs-and-temp-layer/src/deno_ral/errors.ts'. + TS2307 [ERROR]: Cannot find module 'file:////src/deno_ral/errors.ts'. error: Type checking failed. ``` - Step 4 (GREEN): same command → @@ -2611,7 +2611,7 @@ Executed 2026-10-28 on branch `rt/09-ral-fs-and-temp-layer` (Windows 11, pwsh + - 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:///C:/Users/chris/Documents/DEV_R/quarto-cli.worktrees/rt-09-ral-fs-and-temp-layer/src/deno_ral/temp.ts'. + TS2307 [ERROR]: Cannot find module 'file:////src/deno_ral/temp.ts'. error: Type checking failed. ``` - Step 4 (GREEN): same command → @@ -2642,7 +2642,7 @@ Executed 2026-10-28 on branch `rt/09-ral-fs-and-temp-layer` (Windows 11, pwsh + - 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, in `temp.ts` and `fs.test.ts`, are tracked in beads). `String(path)` is unchanged, so idempotence with the active wrapper is unaffected. +- 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 @@ -2671,3 +2671,4 @@ Seven review rounds on the forward guard that skips files patching `Deno.*` (for - 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, `C:/Users/...` 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`. diff --git a/dev-docs/runtime-migration/README.md b/dev-docs/runtime-migration/README.md index ca478d557f..f98ad0593a 100644 --- a/dev-docs/runtime-migration/README.md +++ b/dev-docs/runtime-migration/README.md @@ -214,11 +214,9 @@ 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. -- The beads issue for the PR is set to `in_progress` when execution starts; a `br comments add` one-liner records each finished task. - Beads state lives in the main checkout's `.beads/` (the worktree has a `redirect` file); it is never committed from a feature branch. - The issue closes when the PR merges, not before. +- 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, the beads comments 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." + 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 From bb54314ebe705329551dd06d8aea0b0a3578d825 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Oct 2026 10:59:42 +0100 Subject: [PATCH 36/45] runtime-migration docs: drop a literal local-path prefix from the PR 09 log The branch-review bullet named the removed path prefix verbatim, which the branch's own no-local-paths check (added lines matching C:/Users) flags. Reworded to "maintainer-local worktree paths". --- dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 83e298422b..f9d19ea9d2 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 @@ -2671,4 +2671,4 @@ Seven review rounds on the forward guard that skips files patching `Deno.*` (for - 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, `C:/Users/...` 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`. +- 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`. From 22490c9ecb87a82502358399ea6914cdf62495da Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Oct 2026 11:57:23 +0100 Subject: [PATCH 37/45] tools: pin the codemod test files to LF checkouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codemod fixture tests compare file bytes verbatim (assertEquals on the transformed output against the .expected.ts fixtures), and ts-morph inserts new lines as \n. With core.autocrlf=true on Windows, git checks tools/codemod/** out as CRLF: the blobs are LF, so nothing is committed back, but the comparison fails. A rebase surfaced this — replaying the branch re-checked the files out and 14 of 29 tests failed with pure line-ending diffs. src/** is already pinned text eol=lf for the same reason; this extends the rule to the one tools/ directory whose tests read file bytes exactly. --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) 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 From 6d875372c0898e0b72ac4bbe4220944de6630b7d Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Oct 2026 11:58:59 +0100 Subject: [PATCH 38/45] runtime-migration docs: 02d is merged and 09 is rebased onto it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two items HANDOFF held open for this branch are both done — the rebase onto main (02d, #15218) ran in this session, and the Task 4 codemod defects were fixed in the review rounds — so the "Pending on rt/09" section goes, the README sequence table marks 02d merged, and the plan's execution log records the rebase and its verification outputs, including the CRLF fixture failure the rebase surfaced and the .gitattributes pin that fixes it. --- .../09-ral-fs-and-temp-layer.md | 20 +++++++++++++++++++ dev-docs/runtime-migration/HANDOFF.md | 11 ---------- dev-docs/runtime-migration/README.md | 2 +- 3 files changed, 21 insertions(+), 12 deletions(-) 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 f9d19ea9d2..7e00b1db42 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 @@ -2672,3 +2672,23 @@ Seven review rounds on the forward guard that skips files patching `Deno.*` (for - 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`). diff --git a/dev-docs/runtime-migration/HANDOFF.md b/dev-docs/runtime-migration/HANDOFF.md index 15c624a6f0..458f2fd9bd 100644 --- a/dev-docs/runtime-migration/HANDOFF.md +++ b/dev-docs/runtime-migration/HANDOFF.md @@ -72,17 +72,6 @@ The C-row correction of `aliasTarget()` in PR 36 is subsumed by PR 36 Decision 4 Two notes for whoever continues: the B1 commit carries the maintainer's author identity by mistake (an amend and force-push was refused; the attribution line is in the message); the triage's C rows for PRs 33, 35 and 36 overlapped files that B2, B4, B7 and B8 edited, and were run after the pass landed (all three are done). -## Pending on `rt/09` before its PR opens - -Written 2026-10-28; delete this section once both items are done. - -1. Rebase onto `main`: PR 02d merged as #15218 (`623cd99e8`). - `HANDOFF.md` conflicts on the two status lines both branches edited; resolve them to "02d (#15218) is merged, 09 is in progress". - After the rebase, set the README `02d` row to `merged`. - `tools/runtime-boundary-baseline.json` merges without a conflict; `quarto run --dev tools/check-runtime-boundary.ts` should then report `src` 992, `resources` 12, `tests` 875 and no "rose" message. -2. Task 4 review: the committed codemod (`c5af406bc`) had four defects, each to be fixed with a fixture before Task 5. - Reverse mode turned a shorthand property (`{ os }`) into invalid syntax (`{ Deno.build.os }`); a type-only namespace import (`import type * as fs`) was overwritten with named imports; an existing `import { type statSync }` plus a `Deno.statSync(...)` call produced a duplicate binding; and forward mode rewrote calls on a local binding named `Deno`. - ## How to resume 1. Work from an up-to-date `main`: the plans are there. diff --git a/dev-docs/runtime-migration/README.md b/dev-docs/runtime-migration/README.md index f98ad0593a..3578998c40 100644 --- a/dev-docs/runtime-migration/README.md +++ b/dev-docs/runtime-migration/README.md @@ -232,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 | From e80c88a398898cf6f4c032ef2884abfc3d82317b Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Oct 2026 12:11:51 +0100 Subject: [PATCH 39/45] runtime-migration docs: PR 09 branch greps pass on the rebased branch The rebase rewrote the branch's base, so the whole-branch review greps README "How to execute a plan" requires before the PR opens were re-run against upstream/main...HEAD and the results recorded in the execution log: no indirect Deno access outside the layer beyond an exempt typeof, no PR-number comments, and the one new test helper is the shared one the reuse rule asks for. --- dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md | 4 ++++ 1 file changed, 4 insertions(+) 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 7e00b1db42..9b21cfbb79 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 @@ -2692,3 +2692,7 @@ Seven review rounds on the forward guard that skips files patching `Deno.*` (for - `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`. From 2aade3d025e38756da315272ae6f87e2c5efc873 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Oct 2026 12:31:42 +0100 Subject: [PATCH 40/45] runtime-migration: codemod CI step, backport step and rules docs for deno_ral fs/temp/errors PR 09 task 5 (steps 2-4): run the codemod contract test in the runtime boundary workflow, document the --reverse backport procedure in the backport checklist, and point the TypeScript rules docs at the new fs/temp/errors layer surface. The backport procedure was verified on this branch's own fs.ts commit against upstream/v1.10 before documenting it: the patch applies with git apply --check, and the one unreverseable name (canonicalPathSync) is reported as `left` with the originalRealPathSync hint, as the doc describes. The check found one defect in the procedure as planned: reversing over --diff-filter=AM at the base revision crashes on files the commit adds, so the doc's rev() takes the filter (M at the base, AM after the checkout). --- .claude/rules/typescript/deno-essentials.md | 19 ++++++------ .claude/rules/typescript/deno-ral.md | 32 +++++++++++++++------ .github/workflows/runtime-boundary.yml | 3 ++ dev-docs/checklist-backport-a-pr.md | 22 ++++++++++++++ 4 files changed, 57 insertions(+), 19 deletions(-) diff --git a/.claude/rules/typescript/deno-essentials.md b/.claude/rules/typescript/deno-essentials.md index 23dd9d1896..728cebc476 100644 --- a/.claude/rules/typescript/deno-essentials.md +++ b/.claude/rules/typescript/deno-essentials.md @@ -49,19 +49,17 @@ 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"; +import { cwd } from "../deno_ral/process.ts"; -// Wrong - Node.js patterns -process.env.PATH; -fs.readFileSync(path); +// Wrong - direct runtime calls (the CI ratchet counts these) +Deno.readTextFileSync(path); +Deno.cwd(); ``` ## Sync vs Async @@ -86,6 +84,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..b0ae7cd854 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.*` + +Never add new `Deno.*` calls; import the equivalent from `deno_ral/`. 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/.github/workflows/runtime-boundary.yml b/.github/workflows/runtime-boundary.yml index 3a71947f68..f6428b21e7 100644 --- a/.github/workflows/runtime-boundary.yml +++ b/.github/workflows/runtime-boundary.yml @@ -52,6 +52,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..cd2fdb9dd5 100644 --- a/dev-docs/checklist-backport-a-pr.md +++ b/dev-docs/checklist-backport-a-pr.md @@ -8,6 +8,28 @@ 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^" + rev() { + for f in $(git diff --name-only --diff-filter=$1 "$c^" "$c" -- '*.ts'); 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 M && git -C "$w" commit -qam "reversed base" --allow-empty + git -C "$w" checkout "$c" -- $(git diff --name-only --diff-filter=AM "$c^" "$c") + for f in $(git diff --name-only --diff-filter=D "$c^" "$c"); 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. From f1693e66468df7b7c17463a80099625a019c1698 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Oct 2026 12:32:10 +0100 Subject: [PATCH 41/45] runtime-migration docs: PR 09 task 5 steps 1-4 logged Lockfile step skipped (deno.lock arrives with PR 00b); changelog step waits for the PR number. --- .../09-ral-fs-and-temp-layer.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) 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 9b21cfbb79..58e29c9f7f 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 @@ -2427,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: @@ -2439,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: @@ -2451,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: @@ -2481,7 +2481,7 @@ In `dev-docs/checklist-backport-a-pr.md`, after the bullet "Cherry-pick the comm 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`: @@ -2696,3 +2696,14 @@ Seven review rounds on the forward guard that skips files patching `Deno.*` (for - `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). +- Steps 5 (changelog, needs the PR number) and 6 run when the PR is opened. From 04927ee2057fc8d3ae48ac25824b1d6774bd6c91 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Oct 2026 12:39:02 +0100 Subject: [PATCH 42/45] runtime-migration: changelog line for the sorted directory order (PR 09 task 5) Directories read through deno_ral are sorted by name in code-unit order, so discovery order stops depending on the file system once PRs 11-13 move the callers. References the draft PR number. --- news/changelog-1.11.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index db98133f40..39955190d4 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, uppercase before lowercase) 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. From 3e1a29a81eba9aa2d81de9501bba0349b1200dfb Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Oct 2026 12:39:13 +0100 Subject: [PATCH 43/45] runtime-migration docs: PR 09 task 5 completed, verification re-run --- dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 58e29c9f7f..705834a1b0 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 @@ -2517,7 +2517,7 @@ 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`: @@ -2528,7 +2528,7 @@ Open the PR as a draft to get its number `N`, then add under `## Other fixes and (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 @@ -2706,4 +2706,5 @@ Seven review rounds on the forward guard that skips files patching `Deno.*` (for - 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). -- Steps 5 (changelog, needs the PR number) and 6 run when the PR is opened. +- 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. From 1a3d0689ddd3e2ad3bb542c97fb7361965689192 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Oct 2026 13:46:04 +0100 Subject: [PATCH 44/45] runtime-migration: fix codemod CI triggers, backport recipe and rules docs The runtime-boundary workflow now also runs when only the codemod or its contract tests change. The backport recipe lists changes with --no-renames so renamed files are no longer dropped, reverses deleted TypeScript files in the base so their deletion applies against the stable branch's Deno.* form, and passes paths NUL-delimited so names with spaces survive. The rules no longer show a cwd import that deno_ral/process.ts does not export, and scope the no-new-Deno.* rule to callers outside deno_ral. The changelog names the directory order as character-code order: uppercase before lowercase only holds for ASCII names. --- .claude/rules/typescript/deno-essentials.md | 2 -- .claude/rules/typescript/deno-ral.md | 2 +- .github/workflows/runtime-boundary.yml | 4 ++++ dev-docs/checklist-backport-a-pr.md | 9 +++++---- news/changelog-1.11.md | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.claude/rules/typescript/deno-essentials.md b/.claude/rules/typescript/deno-essentials.md index 728cebc476..bd14dd3866 100644 --- a/.claude/rules/typescript/deno-essentials.md +++ b/.claude/rules/typescript/deno-essentials.md @@ -55,11 +55,9 @@ Import from the runtime layer (`src/deno_ral/`) instead of calling `Deno.*` dire // Correct - runtime layer import { readTextFileSync, writeTextFileSync } from "../deno_ral/fs.ts"; import { makeTempDirSync } from "../deno_ral/temp.ts"; -import { cwd } from "../deno_ral/process.ts"; // Wrong - direct runtime calls (the CI ratchet counts these) Deno.readTextFileSync(path); -Deno.cwd(); ``` ## Sync vs Async diff --git a/.claude/rules/typescript/deno-ral.md b/.claude/rules/typescript/deno-ral.md index b0ae7cd854..11eb8cee51 100644 --- a/.claude/rules/typescript/deno-ral.md +++ b/.claude/rules/typescript/deno-ral.md @@ -73,7 +73,7 @@ This ensures compatibility shims load before any code runs. ## Moving code off `Deno.*` -Never add new `Deno.*` calls; import the equivalent from `deno_ral/`. To migrate existing files mechanically: +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/ diff --git a/.github/workflows/runtime-boundary.yml b/.github/workflows/runtime-boundary.yml index f6428b21e7..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: diff --git a/dev-docs/checklist-backport-a-pr.md b/dev-docs/checklist-backport-a-pr.md index cd2fdb9dd5..a24fb8f695 100644 --- a/dev-docs/checklist-backport-a-pr.md +++ b/dev-docs/checklist-backport-a-pr.md @@ -14,15 +14,16 @@ We backport development PRs to the stable branch using the following steps: 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() { - for f in $(git diff --name-only --diff-filter=$1 "$c^" "$c" -- '*.ts'); do + 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 M && git -C "$w" commit -qam "reversed base" --allow-empty - git -C "$w" checkout "$c" -- $(git diff --name-only --diff-filter=AM "$c^" "$c") - for f in $(git diff --name-only --diff-filter=D "$c^" "$c"); do git -C "$w" rm -q "$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" diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 39955190d4..febeeeb32b 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -206,5 +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, uppercase before lowercase) on every platform. +- ([#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. From 2358d28831be94b5f634ee1d855c3b15ab72c4b0 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Oct 2026 13:47:28 +0100 Subject: [PATCH 45/45] runtime-migration docs: PR 09 changelog line names character-code order --- dev-docs/runtime-migration/09-ral-fs-and-temp-layer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 705834a1b0..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 @@ -2522,7 +2522,7 @@ Deno.cwd(); 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. ```