From d86a5cdc1dad95c6f00395ef0cd1a927a5ea25a8 Mon Sep 17 00:00:00 2001 From: Helweg Date: Mon, 26 Oct 2026 16:52:28 +0100 Subject: [PATCH] fix!: adopt Chokidar 6 with directory permission recovery --- .github/dependabot.yml | 4 +- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 7 +- CONTRIBUTING.md | 11 ++- README.md | 6 +- SECURITY.md | 6 +- docs/installation.md | 4 +- docs/tools.md | 2 +- package-lock.json | 36 +++++-- package.json | 4 +- src/watcher/file-watcher.ts | 44 +++++---- tests/identity-phase0.test.ts | 9 +- tests/include-excluded-paths-indexing.test.ts | 93 ++++++++++++++----- 13 files changed, 164 insertions(+), 64 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8b9b5412..e07f21f4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -33,7 +33,7 @@ updates: - dependency-name: typescript update-types: - version-update:semver-major - # Types must track the minimum supported runtime major (engines >=22.13.0, + # Types must track the minimum supported runtime major (engines >=22.22.0, # CI matrix 22 + 24), never the newest release. Revisit with the Node 22 # end-of-life (2027-04-30), adopting @types/node@24 at that point. - dependency-name: "@types/node" @@ -41,7 +41,7 @@ updates: - version-update:semver-major # 1.18.x pulls effect@4.0.0-beta.83 → ini@7.0.0, whose engines are # "^22.22.2 || ^24.15.0 || >=26.0.0". That silently raises our declared - # runtime floor (engines >=22.13.0, enforced by the CI packed-install + # runtime floor (engines >=22.22.0, enforced by the CI packed-install # smoke test) and ships a beta dependency, so adoption is a deliberate # floor-raising release. See #392. - dependency-name: "@opencode-ai/plugin" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 491f7d58..9d25481f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,7 +186,7 @@ jobs: if: matrix.node == '22' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '22.13.0' + node-version: '22.22.0' - name: Smoke-test minimum supported runtime if: matrix.node == '22' diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e64514..4f845891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] - ### Changed +- **Node.js runtime floor and Chokidar 6**: Raise both package identities' minimum from Node.js 22.13.0 to 22.22.0 to satisfy Chokidar 6's declared engine requirement. Existing platform, binary, and host contracts remain unchanged. + - **Complete history sync scheduling**: Reconcile up to four entities concurrently through one sync-local request budget of four active calls and eight starts per rolling second, without skipping historical edits, deletions, or conditional page validation. Drain real requests before publication, cancellation settlement, or cleanup; retain resumable checkpoints on transient failures and let late access denials take precedence. +### Fixed + +- **Directory watcher permission recovery**: Recover readable, policy-selected directory and project-root registrations rejected with `EPERM` or `EACCES`, including empty directories that later gain source files. Reuse the one-shot polling cutover and catch-up reconciliation without broadening ignored, excluded, restricted, protected-index, unreadable, or external-configuration scope. Exercise actual Chokidar 6 directory resources in the watcher/Indexer acceptance checks. + ## [0.38.2] - 2026-10-26 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5040307b..daeeb36a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ Use this when you just want the shortest path to a good PR: ### Prerequisites -- Development Node.js range: `^22.19.0 || ^24.11.0 || >=26.0.0` (Pi and tsup development dependencies). Node.js 24.11+ LTS is recommended. The published package still supports Node.js 22.13 or newer. +- Development Node.js range: `^22.22.0 || ^24.11.0 || >=26.0.0` (Chokidar 6, Pi, and tsup dependencies). Node.js 24.11+ LTS is recommended. Published consumers require Node.js 22.22 or newer. - Rust toolchain (for native module) - npm @@ -72,6 +72,15 @@ npm run test cd native && cargo test ``` +Watcher permission-recovery tests use real Chokidar 6 directory registrations, +not per-file native handles. Controlled fault cases explicitly select Chokidar's +per-directory native backend on every platform, matching Linux's default strategy. +Readable project roots and traversable directories (including empty directories) +can trigger the existing one-shot polling fallback; ignored, excluded, restricted, +protected-index, unreadable, and auxiliary external-config targets cannot broaden +source coverage. Keep readiness/fault synchronization bounded and retain real +event-delivery, index-definition replacement, and filtering assertions. + The default-root-build watcher rejection scenarios in `tests/include-excluded-paths-indexing.test.ts` retain diagnostics **only on failure**, after stopping watchers, settling handler tails, and closing indexers, diff --git a/README.md b/README.md index 506815d2..70cc730c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Downloads](https://img.shields.io/npm/dm/open-codebase-index.svg)](https://www.npmjs.com/package/open-codebase-index) [![Build Status](https://img.shields.io/github/actions/workflow/status/Helweg/open-codebase-index/ci.yml?branch=main)](https://github.com/Helweg/open-codebase-index/actions) -[![Node.js](https://img.shields.io/badge/node-%3E%3D22.13-brightgreen.svg)](https://nodejs.org/) +[![Node.js](https://img.shields.io/badge/node-%3E%3D22.22-brightgreen.svg)](https://nodejs.org/) > Search a codebase by meaning, then follow the result into definitions, callers, and dependency paths. @@ -34,7 +34,7 @@ ocbi search "retry recovery" --project /path/to/repo ## Quick start with OpenCode -Requires Node.js 22.13 or newer. Node.js 24 LTS is recommended. +Requires Node.js 22.22 or newer. Node.js 24 LTS is recommended. 1. Install the package: @@ -280,6 +280,8 @@ npm run lint npm run test:run ``` +- Source installs and published consumers require Node.js 22.22 or newer because Chokidar 6 enforces that engine floor. The development toolchain supports `^22.22.0 || ^24.11.0 || >=26.0.0`; building the native module also requires the Rust toolchain. + Pi integration tests use the development host `@earendil-works/pi-coding-agent` 1.3 or newer and its `emitBeforeAgentStart(trigger, systemPromptOptions)` API. This test-host floor does not narrow the optional Pi peer contract or change the diff --git a/SECURITY.md b/SECURITY.md index 1ecc3078..79924a64 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -45,7 +45,7 @@ Track override decisions in [#379](https://github.com/Helweg/open-codebase-index - Preserve existing `os`, `cpu`, and `libc` selectors on unchanged optional packages when refreshing lockfiles. Package-path/version equality alone does not prove platform-selection equivalence. - Before calling an override inert, resolve two disposable lock-only trees from the same manifest with no existing lock, with and without that override. Compare every package path and version; report explicit added, removed, and version-changed lists. All three lists must be empty. - For active changes, record those resolution deltas and run `npm audit --json` including development dependencies, a fresh `npm ci`, affected real CLI/build paths, and the repository validation gate. Preserve security coverage rather than accepting a newly introduced finding. -- Root overrides govern repository installs, not the transitive dependencies of a published package installed by a consumer. Check both packed identities with `npm_config_engine_strict=true` on the supported Node.js floor, 22.13.0. +- Root overrides govern repository installs, not the transitive dependencies of a published package installed by a consumer. Check both packed identities with `npm_config_engine_strict=true` on the supported Node.js floor, 22.22.0. ### Retained Security Floors @@ -94,7 +94,9 @@ The existing AJV range admits fast-uri `3.1.9`, addressing [GHSA-333x-w4r6-6crp](https://github.com/advisories/GHSA-333x-w4r6-6crp). These runtime resolutions do not need new root overrides. The scoped development overrides above do not govern downstream installs. Undici's existing development -Node.js `22.19.0` minimum and the published `22.13.0` minimum remain distinct. +Node.js `22.19.0` was the development minimum and `22.13.0` the published +minimum at that review. Chokidar 6 now requires Node.js `22.22.0` for both +source installs and published package use. ### Removed Overrides and Resolved Pi Exception diff --git a/docs/installation.md b/docs/installation.md index 2ae191a8..d306de7c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,6 +1,6 @@ # Installation and host setup -`open-codebase-index` requires Node.js 22.13 or newer and is the preferred package identity. Node.js 24 LTS is recommended. `opencode-codebase-index` remains a supported legacy package alias, and `open-codebase-index-mcp` is the preferred binary alias. +`open-codebase-index` requires Node.js 22.22 or newer and is the preferred package identity. Node.js 24 LTS is recommended. `opencode-codebase-index` remains a supported legacy package alias, and `open-codebase-index-mcp` is the preferred binary alias. The published package includes the MCP server dependencies and native binaries for supported platforms. @@ -308,7 +308,7 @@ Progress and diagnostics for `index` are written to `stderr`; the final formatte ## Local source checkout -Development requires Node.js 22.19 or newer within 22.x, 24.11 or newer within 24.x, or 26 or newer. This build-tool requirement does not raise the published Node.js 22.13 runtime floor. +Development requires Node.js 22.22 or newer within 22.x, 24.11 or newer within 24.x, or 26 or newer. Chokidar 6's engine requirement applies to source installs as well as published packages. ```bash npm ci diff --git a/docs/tools.md b/docs/tools.md index cbcf9439..df4bd2a5 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -200,7 +200,7 @@ References must be executable native-parsed calls or constructions inside exact Bounded CommonJS support accepts an immutable top-level `const Name = require('literal')` only when the importer has proved CommonJS origin and `require` is an unshadowed Node builtin. The target must have one statically available root `module.exports = Name`, either unconditional or inside the supported `typeof module !== 'undefined'` availability guard without an alternate branch, and have proved CommonJS format. `.cjs`/`.cts` are explicit CommonJS; ambiguous extensions require bounded nearest in-root package-format evidence. An ownership boundary, missing proof, runtime ESM syntax, shadowed/reassigned builtins, dynamic/conditional/competing exports or export-object escape causes abstention. ESM default import of a proved CommonJS root remains supported; requiring an ESM default is not treated as importing that function. Arbitrary compiler rewrites and CommonJS property-export patterns are not inferred. -Package selection models the default Node 22.13+ conditions: `node`, `node-addons`, `module-sync` and `default`, plus mode-specific `import` for ESM or `require` for CommonJS. Declaration order, exact/wildcard precedence and explicit blocks are retained; custom runtime condition sets and opt-out flags are not inferred from source. Legacy `require` entry points use only `main`, not bundler `module` or declaration `types`. Assignment-form `for-in`/`for-of` headers (including destructuring and `for await`) and parenthesized writes, including leading comments and nested parentheses, invalidate affected binding proofs. Ordinary functions' implicit `arguments` objects block outer bindings in parameters and bodies; arrows inherit lexical ownership. Computed method names execute in the enclosing scope and retain its constructor owner. Potential sloppy-script Annex B hoists also invalidate existing body-level function declarations without opening ambiguous parameter/var bindings. Explicit strict directives, runtime ES module syntax, class strictness and lexical declaration conflicts retain block-function isolation. +Package selection models the default Node 22.22+ conditions: `node`, `node-addons`, `module-sync` and `default`, plus mode-specific `import` for ESM or `require` for CommonJS. Declaration order, exact/wildcard precedence and explicit blocks are retained; custom runtime condition sets and opt-out flags are not inferred from source. Legacy `require` entry points use only `main`, not bundler `module` or declaration `types`. Assignment-form `for-in`/`for-of` headers (including destructuring and `for await`) and parenthesized writes, including leading comments and nested parentheses, invalidate affected binding proofs. Ordinary functions' implicit `arguments` objects block outer bindings in parameters and bodies; arrows inherit lexical ownership. Computed method names execute in the enclosing scope and retain its constructor owner. Potential sloppy-script Annex B hoists also invalidate existing body-level function declarations without opening ambiguous parameter/var bindings. Explicit strict directives, runtime ES module syntax, class strictness and lexical declaration conflicts retain block-function isolation. Ordinary function declarations/expressions can support construction when native syntax proves constructability. Arrow, async and generator functions do not; classes support construction, not bare calls. Same-name foreign imports, comments, strings, substring matches, import-only mentions, registration arguments and arbitrary object/`this` methods are not linked evidence. Syntax errors, unsupported runtime binding patterns or escaped runtime binding spellings, and unverified or unresolved nearer configuration fail linkage closed. Aliases outside the bounded candidate set, namespace calls, dynamic registrations and unsupported container shapes can be missed; this is discovery, not exhaustive coverage analysis. Missing or ambiguous targets return no linked evidence; missing or stale graphs cannot create caller linkage. When linked discovery misses, ordinary scoped conceptual retrieval remains available without direct/related labels. diff --git a/package-lock.json b/package-lock.json index ebff2e74..a288434e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@opencode-ai/sdk": "1.3.13", - "chokidar": "^5.0.0", + "chokidar": "^6.0.0", "ignore": "^7.0.5", "p-queue": "^10.0.0", "p-retry": "^8.0.1", @@ -42,7 +42,7 @@ "vitest": "^5.0.0" }, "engines": { - "node": ">=22.13.0" + "node": ">=22.22.0" }, "peerDependencies": { "@earendil-works/pi-coding-agent": "*", @@ -5111,15 +5111,15 @@ "license": "MIT" }, "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-6.0.0.tgz", + "integrity": "sha512-Ec2Z7wpcKBiMpR7DxteoqS/sq5G5d2A4fpxzPv6+fD7xNNdrTghn7L11YwV5EN1B3i72Erc3XVT5FkWIDx90+A==", "license": "MIT", "dependencies": { - "readdirp": "^5.0.0" + "readdirp": "^5.1.1" }, "engines": { - "node": ">= 20.19.0" + "node": ">= 22.22" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -7578,9 +7578,9 @@ } }, "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -8657,6 +8657,22 @@ "node": ">=18" } }, + "node_modules/tsup/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/tsup/node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", diff --git a/package.json b/package.json index 04e99faf..17ee2005 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "pi" ], "engines": { - "node": ">=22.13.0" + "node": ">=22.22.0" }, "scripts": { "build": "npm run build:native && npm run build:ts", @@ -105,7 +105,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@opencode-ai/sdk": "1.3.13", - "chokidar": "^5.0.0", + "chokidar": "^6.0.0", "ignore": "^7.0.5", "p-queue": "^10.0.0", "p-retry": "^8.0.1", diff --git a/src/watcher/file-watcher.ts b/src/watcher/file-watcher.ts index ef5d061d..5cb22009 100644 --- a/src/watcher/file-watcher.ts +++ b/src/watcher/file-watcher.ts @@ -306,32 +306,40 @@ export class FileWatcher { private canRecoverSourceWatchPermissionError(error: NodeJS.ErrnoException): boolean { if (error.syscall !== "watch" || typeof error.path !== "string" || !path.isAbsolute(error.path)) return false; + // The auxiliary Chokidar watcher must not take ownership of native source coverage. + if (this.nativeWatcher || this.nativeStarting) return false; const projectRoot = path.resolve(this.projectRoot); - const filePath = path.resolve(error.path); - const relativePath = path.relative(projectRoot, filePath); - if (!relativePath || this.isOutsideProjectPath(relativePath)) return false; + const watchPath = path.resolve(error.path); + if (this.isOutsideProjectPath(path.relative(projectRoot, watchPath))) return false; const ignoreFilter = createIgnoreFilter(projectRoot); const gitIgnoreFilter = createGitIgnoreFilter(projectRoot); const includeIgnored = this.config.indexing?.includeIgnored ?? []; const filterOptions = this.getPathFilterOptions(); - if ( - this.isProjectConfigPath(filePath) - || this.localModuleConfigTracker.has(filePath) - || shouldTrackLocalModuleConfigPath(filePath, projectRoot, ignoreFilter, this.getSnapshotConfig()) - || (path.basename(filePath).toLowerCase() === "package.json" - && this.localModuleConfigTracker.shouldTrackPackagePath(filePath)) - || !shouldIncludeFile( - filePath, projectRoot, [...this.config.include, ...(this.config.additionalInclude ?? [])], - this.config.exclude, ignoreFilter, includeIgnored, gitIgnoreFilter, filterOptions, - ) - ) return false; - try { - if (!lstatSync(filePath).isFile()) return false; - accessSync(filePath, constants.R_OK); - let directory = path.dirname(filePath); + const state = lstatSync(watchPath); + let directory = watchPath; + if (state.isFile()) { + if ( + this.isProjectConfigPath(watchPath) + || this.localModuleConfigTracker.has(watchPath) + || shouldTrackLocalModuleConfigPath(watchPath, projectRoot, ignoreFilter, this.getSnapshotConfig()) + || (path.basename(watchPath).toLowerCase() === "package.json" + && this.localModuleConfigTracker.shouldTrackPackagePath(watchPath)) + || !shouldIncludeFile( + watchPath, projectRoot, [...this.config.include, ...(this.config.additionalInclude ?? [])], + this.config.exclude, ignoreFilter, includeIgnored, gitIgnoreFilter, filterOptions, + ) + ) return false; + accessSync(watchPath, constants.R_OK); + directory = path.dirname(watchPath); + } else if (!state.isDirectory()) { + return false; + } + + // Chokidar 6 registers directories, including empty ones that can gain selected files. + // Reuse watch traversal policy rather than requiring an already-present source file. for (;;) { if (!shouldTraverseDirectory( directory, projectRoot, this.config.exclude, ignoreFilter, includeIgnored, gitIgnoreFilter, filterOptions, diff --git a/tests/identity-phase0.test.ts b/tests/identity-phase0.test.ts index ec3bb857..9265b5c7 100644 --- a/tests/identity-phase0.test.ts +++ b/tests/identity-phase0.test.ts @@ -13,6 +13,7 @@ interface PackageMetadata { bin: Record; repository: { url: string }; napi: { binaryName: string }; + engines: { node: string }; } interface CodexManifestMetadata { @@ -40,7 +41,7 @@ interface ClaudeMarketplaceMetadata { interface PackageLockMetadata { name: string; - packages: Record }>; + packages: Record; engines?: { node: string } }>; } function readJson(filePath: string): T { @@ -154,6 +155,8 @@ describe("Phase 1 product identity compatibility", () => { expect(packageJson.bin).toEqual(expectedBin); expect(packageJson.repository.url).toBe(current.repository); expect(packageLock.name).toBe(current.packageName); + expect(packageJson.engines.node).toBe(">=22.22.0"); + expect(packageLock.packages[""]?.engines?.node).toBe(">=22.22.0"); expect(packageLock.packages[""]?.name).toBe(current.packageName); expect(packageLock.packages[""]?.bin).toEqual(expectedBin); expect(packageJson.napi.binaryName).toBe(IDENTITY_CATALOG.native.binaryName); @@ -188,6 +191,8 @@ describe("Phase 1 product identity compatibility", () => { const expectedBin = { [IDENTITY_CATALOG.product.current.mcpBinary]: "dist/cli.js", ocbi: "dist/cbi.js", cbi: "dist/cbi.js" }; expect(packageJson.name).toBe(IDENTITY_CATALOG.product.current.packageName); + expect(packageJson.engines.node).toBe(">=22.22.0"); + expect(packageLock.packages[""]?.engines?.node).toBe(">=22.22.0"); expect(packageJson.bin).toEqual(expectedBin); expect(packageLock.name).toBe(IDENTITY_CATALOG.product.current.packageName); expect(packageLock.packages[""]?.bin).toEqual(expectedBin); @@ -229,6 +234,8 @@ describe("Phase 1 product identity compatibility", () => { ocbi: "dist/cbi.js", cbi: "dist/cbi.js", }; + expect(packageJson.engines.node).toBe(">=22.22.0"); + expect(packageLock.packages[""]?.engines?.node).toBe(">=22.22.0"); const expectedMcpArgs = [ "-y", "--package", diff --git a/tests/include-excluded-paths-indexing.test.ts b/tests/include-excluded-paths-indexing.test.ts index 342f23df..ca3b892f 100644 --- a/tests/include-excluded-paths-indexing.test.ts +++ b/tests/include-excluded-paths-indexing.test.ts @@ -1,3 +1,4 @@ +import type * as Chokidar from "chokidar"; import type * as FsPromises from "node:fs/promises"; import fs from "node:fs"; import { syncBuiltinESMExports } from "node:module"; @@ -17,6 +18,7 @@ import { DefaultRootBuildFailureCapture } from "./watcher/default-root-build-fai const snapshotFaults = vi.hoisted(() => ({ beforeRead: undefined as ((directory: string) => Promise) | undefined, afterStat: undefined as ((filePath: string) => Promise) | undefined, + perDirectoryBackend: false, })); vi.mock("node:fs/promises", async () => { const actual = await vi.importActual("node:fs/promises"); @@ -34,6 +36,19 @@ vi.mock("node:fs/promises", async () => { }; }); +// Exercise Linux's real per-directory resource strategy on every host, only for controlled faults. +vi.mock("chokidar", async () => { + const actual = await vi.importActual("chokidar"); + return { + ...actual, + FSWatcher: class extends actual.FSWatcher { + constructor(options: ConstructorParameters[0] = {}) { + super({ ...options, ...(snapshotFaults.perDirectoryBackend ? { backend: "native" as const } : {}) }); + } + }, + }; +}); + const WATCH_TIMEOUT_MS = 20_000; const NODE_MAJOR_VERSION = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10); const SUPPORTS_NATIVE_RECURSIVE_WATCH = process.platform === "darwin" @@ -138,6 +153,7 @@ describe("include-excluded path indexing acceptance", () => { try { snapshotFaults.beforeRead = undefined; snapshotFaults.afterStat = undefined; + snapshotFaults.perDirectoryBackend = false; const stopResults = await Promise.allSettled(watchers.map((watcher) => watcher.stop())); watchersStopped = stopResults.every((result) => result.status === "fulfilled"); const handlerResults = await Promise.allSettled(handlerTails); @@ -693,40 +709,71 @@ describe("include-excluded path indexing acceptance", () => { return { watcher, observed }; } - it("recovers a readable selected source rejected with EPERM during initial Node watch registration", async () => { + it.each(["directory", "root"] as const)("recovers a readable selected %s rejected with EPERM during initial Node watch registration", async (resource) => { const ordinaryPath = path.join(projectDir, "src", "ordinary.ts"); const buildPath = path.join(projectDir, "build-helper.ts"); const restrictedPath = path.join(projectDir, "Library", "restricted.ts"); + const negativePaths = [ + path.join(projectDir, "excluded", "blocked.ts"), + path.join(projectDir, "vendor", "ignored.ts"), + path.join(projectDir, ".git", "protected.ts"), + path.join(projectDir, "private-index", "protected.ts"), + ]; + fs.writeFileSync(path.join(projectDir, ".gitignore"), "vendor/\n"); + for (const filePath of negativePaths) writeSource(filePath, "permissionOriginalNegativeDefinition"); writeSource(ordinaryPath, "permissionOriginalOrdinaryDefinition"); writeSource(buildPath, "permissionOriginalBuildDefinition"); writeSource(restrictedPath, "permissionOriginalRestrictedDefinition"); - const config = structuralConfig(); - const indexer = createIndexer(config); + const config = structuralConfig({ exclude: ["excluded/**"] }); + const indexPath = path.join(projectDir, "private-index"); + const indexer = createIndexer(config, { indexPath }); await indexer.index(); + snapshotFaults.perDirectoryBackend = true; + const watchPath = resource === "root" ? projectDir : path.dirname(ordinaryPath); const actualWatch = fs.watch; let faulted = false; vi.spyOn(fs, "watch").mockImplementation(function (this: unknown, ...args) { - if (String(args[0]) === ordinaryPath && !faulted) { + if (path.resolve(String(args[0])) === watchPath && !faulted) { faulted = true; - throw Object.assign(new Error("CONTROLLED source watch EPERM"), { - code: "EPERM", errno: -1, syscall: "watch", path: ordinaryPath, filename: ordinaryPath, + throw Object.assign(new Error("CONTROLLED directory watch EPERM"), { + code: "EPERM", errno: -1, syscall: "watch", path: watchPath, filename: watchPath, }); } return Reflect.apply(actualWatch, this, args); }); syncBuiltinESMExports(); - const { watcher, observed } = startRecoveryConsumer(config, indexer); + const { watcher, observed } = startRecoveryConsumer(config, indexer, { indexPath }); + // Access the real emitters only to verify that stale/secondary errors cannot restart recovery. + const internals = watcher as unknown as { watcher: FSWatcher }; + const first = internals.watcher; + // Retain the callback to model an already-queued error; close removes emitter listeners. + const firstErrorHandler = first.listeners("error")[0]; + if (!firstErrorHandler) throw new Error("Initial watcher error handler was not registered"); await watcher.waitUntilReady(); if (!faulted) throw new Error("CONTROLLED Node fs.watch registration boundary was not reached"); + const replacement = internals.watcher; + expect(replacement).not.toBe(first); + expect(replacement.options.usePolling).toBe(true); + const repeatedError = Object.assign(new Error("CONTROLLED repeated directory EPERM"), { + code: "EPERM", syscall: "watch", path: watchPath, + }); + Reflect.apply(firstErrorHandler, first, [repeatedError]); + replacement.emit("error", repeatedError); + expect(internals.watcher).toBe(replacement); + const addedPath = path.join(path.dirname(ordinaryPath), "new-after-ready.ts"); + writeSource(addedPath, "permissionNewAfterReadyDefinition"); writeSource(buildPath, "permissionUpdatedBuildDefinition"); writeSource(restrictedPath, "permissionUpdatedRestrictedDefinition"); + for (const filePath of negativePaths) writeSource(filePath, "permissionUpdatedNegativeDefinition"); writeSource(ordinaryPath, "permissionUpdatedOrdinaryDefinition"); await vi.waitFor(async () => { expect(observed.some((change) => change.path === ordinaryPath)).toBe(true); expect(await hasDefinition(indexer, "permissionUpdatedOrdinaryDefinition", ordinaryPath)).toBe(true); expect(await hasDefinition(indexer, "permissionUpdatedBuildDefinition", buildPath)).toBe(true); + expect(await hasDefinition(indexer, "permissionNewAfterReadyDefinition", addedPath)).toBe(true); + expect(observed.some((change) => change.path === addedPath && change.type === "add")).toBe(true); }, { timeout: WATCH_TIMEOUT_MS, interval: 50 }); await Promise.all(handlerTails); const secondObservedStart = observed.length; @@ -737,26 +784,27 @@ describe("include-excluded path indexing acceptance", () => { expect(await hasDefinition(indexer, "permissionUpdatedOrdinaryDefinition", ordinaryPath)).toBe(false); }, { timeout: WATCH_TIMEOUT_MS, interval: 50 }); expect(observed.some((change) => change.path === buildPath || change.path === restrictedPath)).toBe(false); + for (const filePath of negativePaths) expect(observed.some((change) => change.path === filePath)).toBe(false); }); it("keeps newly added source updates live after post-ready EACCES with a trailing-separator root", async () => { const ordinaryPath = path.join(projectDir, "src", "ordinary.ts"); - const addedPath = path.join(projectDir, "src", "added.ts"); + const addedPath = path.join(projectDir, "src", "new-directory", "added.ts"); writeSource(ordinaryPath, "permissionExistingDefinition"); const config = structuralConfig(); const indexer = createIndexer(config); await indexer.index(); + snapshotFaults.perDirectoryBackend = true; + const watchPath = path.dirname(addedPath); const actualWatch = fs.watch; let faulted = false; - let reportFault!: () => void; - const fault = new Promise((resolve) => { reportFault = resolve; }); + // Bounded observation below fails explicitly if the real directory resource is never reached. vi.spyOn(fs, "watch").mockImplementation(function (this: unknown, ...args) { - if (String(args[0]) === addedPath && !faulted) { + if (path.resolve(String(args[0])) === watchPath && !faulted) { faulted = true; - reportFault(); - throw Object.assign(new Error("CONTROLLED added source watch EACCES"), { - code: "EACCES", errno: -13, syscall: "watch", path: addedPath, filename: addedPath, + throw Object.assign(new Error("CONTROLLED added directory watch EACCES"), { + code: "EACCES", errno: -13, syscall: "watch", path: watchPath, filename: watchPath, }); } return Reflect.apply(actualWatch, this, args); @@ -764,9 +812,10 @@ describe("include-excluded path indexing acceptance", () => { syncBuiltinESMExports(); const { watcher, observed } = startRecoveryConsumer(config, indexer, {}, `${projectDir}${path.sep}`); await watcher.waitUntilReady(); - writeSource(addedPath, "permissionAddedOriginalDefinition"); - await fault; + fs.mkdirSync(watchPath, { recursive: true }); + await vi.waitFor(() => expect(faulted).toBe(true), { timeout: WATCH_TIMEOUT_MS, interval: 50 }); await watcher.waitUntilReady(); + writeSource(addedPath, "permissionAddedOriginalDefinition"); const observedStart = observed.length; writeSource(addedPath, "permissionAddedUpdatedDefinition"); @@ -788,7 +837,7 @@ describe("include-excluded path indexing acceptance", () => { it.each([ { label: "default root build file", file: "build-helper.ts", exclude: undefined, denyAccess: false, indexed: true }, { label: "explicitly excluded file", file: "ignored.ts", exclude: ["ignored.ts"], denyAccess: false, indexed: false }, - { label: "source denied by the access boundary", file: "blocked.ts", exclude: undefined, denyAccess: true, indexed: true }, + { label: "source denied by the access boundary", file: "blocked/blocked.ts", exclude: undefined, denyAccess: true, indexed: true }, ])("preserves consumer filtering after a permission error for a $label", async ({ file, exclude, denyAccess, indexed }) => { const ordinaryPath = path.join(projectDir, "src", "ordinary.ts"); const rejectedPath = path.join(projectDir, file); @@ -797,13 +846,15 @@ describe("include-excluded path indexing acceptance", () => { const config = structuralConfig({ exclude }); const indexer = createIndexer(config); await indexer.index(); + snapshotFaults.perDirectoryBackend = true; + const watchPath = denyAccess ? path.dirname(rejectedPath) : projectDir; const actualAccess = fs.accessSync; let denyRecoveryAccess = denyAccess; if (denyAccess) { // Portable classification fault only: the fixture's real reads and file modes are unchanged. vi.spyOn(fs, "accessSync").mockImplementation(function (this: unknown, ...args) { - if (String(args[0]) === rejectedPath && denyRecoveryAccess) { + if (String(args[0]) === watchPath && denyRecoveryAccess) { denyRecoveryAccess = false; throw Object.assign(new Error("CONTROLLED source access EACCES"), { code: "EACCES" }); } @@ -813,10 +864,10 @@ describe("include-excluded path indexing acceptance", () => { const actualWatch = fs.watch; let faulted = false; vi.spyOn(fs, "watch").mockImplementation(function (this: unknown, ...args) { - if (String(args[0]) === rejectedPath && !faulted) { + if (path.resolve(String(args[0])) === watchPath && !faulted) { faulted = true; - throw Object.assign(new Error("CONTROLLED filtered source watch EPERM"), { - code: "EPERM", errno: -1, syscall: "watch", path: rejectedPath, filename: rejectedPath, + throw Object.assign(new Error("CONTROLLED filtered directory watch EPERM"), { + code: "EPERM", errno: -1, syscall: "watch", path: watchPath, filename: watchPath, }); } return Reflect.apply(actualWatch, this, args);