From 20ad690099cd102d72e4db7d0f3a117488903d58 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 29 Sep 2026 14:59:42 +0200 Subject: [PATCH 1/3] fix(paths): report a deleted working directory as a user error process.cwd() throws a raw ENOENT (uv_cwd) when the directory the CLI was launched from has been deleted. It reached the ARCH-012 boundary as an internal bug: exit 2 plus a Sentry report (CLI-C) for what is a caller environment problem. Add currentDir(), which turns that failure into a UserError (exit 1, no Sentry), and tryCurrentDir(), which returns null for telemetry and diagnostics that must never fail a command. Route every process.cwd() call in src/ through them and enforce it with a cwd/no-process-cwd oxlint rule. Signed-off-by: Rhuan Barreto --- ...-011-consistent-project-root-resolution.md | 4 +- .oxlintrc.json | 9 +- lint/no-process-cwd.ts | 67 +++++++++++++++ src/commands/init.ts | 7 +- src/commands/plugin/install.ts | 4 +- src/helpers/doctor.ts | 12 ++- src/helpers/install-info.ts | 12 ++- src/helpers/paths.ts | 50 ++++++++++- src/helpers/repo.ts | 7 +- src/helpers/session-context-antigravity.ts | 3 +- src/helpers/session-context-codex.ts | 4 +- src/helpers/session-context-copilot.ts | 4 +- src/helpers/session-context-opencode.ts | 6 +- src/helpers/session-context-pi.ts | 4 +- src/helpers/session-context.ts | 5 +- tests/helpers/paths.test.ts | 82 ++++++++++++++++++- tests/lint/no-process-cwd.test.ts | 70 ++++++++++++++++ 17 files changed, 324 insertions(+), 26 deletions(-) create mode 100644 lint/no-process-cwd.ts create mode 100644 tests/lint/no-process-cwd.test.ts diff --git a/.archgate/adrs/ARCH-011-consistent-project-root-resolution.md b/.archgate/adrs/ARCH-011-consistent-project-root-resolution.md index 936a2799..c2d1284b 100644 --- a/.archgate/adrs/ARCH-011-consistent-project-root-resolution.md +++ b/.archgate/adrs/ARCH-011-consistent-project-root-resolution.md @@ -41,10 +41,11 @@ All commands that operate on `.archgate/` project resources MUST use `findProjec - **Commands that REQUIRE a project MUST use `requireProjectRoot()`** from `src/helpers/paths.ts` — it throws a `UserError` with the message "No .archgate/ directory found. Run `archgate init` first.", which the command's ARCH-012 error boundary turns into exit 1 with no Sentry capture. Commands that can operate without a project (e.g. `session-context` with its cwd fallback) keep `findProjectRoot()` and handle `null` themselves - Check the return value for `null` and exit with a helpful error message (only when using `findProjectRoot()` directly) - Pass the resolved `projectRoot` to `projectPaths()` for derived paths +- When the working directory itself is needed (`init`, a `findProjectRoot() ?? cwd` fallback), use `currentDir()` from `src/helpers/paths.ts` — it turns the `ENOENT` that `process.cwd()` throws in a deleted directory into a `UserError` (exit 1, no Sentry). Telemetry and diagnostics use `tryCurrentDir()`, which returns `null` instead of throwing ### Don't -- Don't use `process.cwd()` to locate `.archgate/` in command files (except `init`) +- Don't call `process.cwd()` anywhere in `src/` outside `src/helpers/paths.ts` - Don't define local `findProjectRoot()` variants — use the shared implementation - Don't hand-roll the `if (!projectRoot) { logError(...); await exitWith(1); return; }` guard in commands that require a project — `requireProjectRoot()` is the single implementation of it - Don't assume the user is running from the project root @@ -65,6 +66,7 @@ All commands that operate on `.archgate/` project resources MUST use `findProjec ### Automated Enforcement - **Archgate rule** `ARCH-011/no-process-cwd-for-project-root`: Scans command files for `process.cwd()` usage and flags violations. The `init` command is exempt. Severity: `error`. +- **Oxlint rule** `cwd/no-process-cwd` (`lint/no-process-cwd.ts`): Flags any `process.cwd` reference in `src/` outside `src/helpers/paths.ts`. Severity: `error`. ### Manual Enforcement diff --git a/.oxlintrc.json b/.oxlintrc.json index 83c3654f..1f777106 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -3,6 +3,7 @@ "./lint/expect-expect.ts", "./lint/no-bare-env-restore.ts", "./lint/no-bun-stdin.ts", + "./lint/no-process-cwd.ts", "./lint/no-bare-fetch.ts", "./lint/no-shared-module-mock.ts", "./lint/no-lowered-test-timeout.ts", @@ -125,9 +126,15 @@ "files": ["src/**/*.ts"], "rules": { "runtime/no-bun-stdin": "error", - "http/no-bare-fetch": "error" + "http/no-bare-fetch": "error", + "cwd/no-process-cwd": "error" } }, + { + // The one sanctioned process.cwd() call site: the helpers that wrap it. + "files": ["src/helpers/paths.ts"], + "rules": { "cwd/no-process-cwd": "off" } + }, { "files": ["tests/**/*.test.ts"], "rules": { diff --git a/lint/no-process-cwd.ts b/lint/no-process-cwd.ts new file mode 100644 index 00000000..8e4e5e4a --- /dev/null +++ b/lint/no-process-cwd.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate + +// Custom oxlint JS plugin: production code resolves the working directory +// through `currentDir()` / `tryCurrentDir()` (src/helpers/paths.ts), never +// `process.cwd()`, which throws a raw `ENOENT` in a deleted directory and so +// reaches the ARCH-012 boundary as an internal bug (exit 2, Sentry report). +// The helpers turn it into a `UserError` or a `null`. + +/** Minimal ESTree-ish node shape. The oxlint AST is ESLint-compatible. */ +type AstNode = { type: string } & Record; + +function isAstNode(value: unknown): value is AstNode { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +/** The identifier name of a non-computed member property. */ +function staticPropertyName(node: AstNode): string | undefined { + if (node.computed === true) return undefined; + const property = node.property; + return isAstNode(property) && + property.type === "Identifier" && + typeof property.name === "string" + ? property.name + : undefined; +} + +/** True for the `process.cwd` member expression itself. */ +function isProcessCwd(node: AstNode): boolean { + if (node.type !== "MemberExpression") return false; + if (staticPropertyName(node) !== "cwd") return false; + const object = node.object; + return ( + isAstNode(object) && + object.type === "Identifier" && + object.name === "process" + ); +} + +interface RuleContext { + report(descriptor: { node: AstNode; message: string }): void; +} + +const MESSAGE = + "Resolve the working directory with `currentDir()` (or `tryCurrentDir()` in telemetry and diagnostics) from src/helpers/paths.ts instead of `process.cwd()`: it throws a raw ENOENT when the launch directory was deleted, which surfaces as an internal error (exit 2) and a Sentry report."; + +const noProcessCwd = { + create(context: RuleContext) { + return { + MemberExpression(node: AstNode) { + if (isProcessCwd(node)) context.report({ node, message: MESSAGE }); + }, + }; + }, +}; + +const plugin = { + meta: { name: "cwd" }, + rules: { "no-process-cwd": noProcessCwd }, +}; + +export default plugin; diff --git a/src/commands/init.ts b/src/commands/init.ts index c2500699..e12017c7 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -19,6 +19,7 @@ import type { EditorTarget } from "../helpers/init-project"; import { selfInvokeArgv } from "../helpers/install-info"; import { logError, logInfo, logWarn } from "../helpers/log"; import { runLoginFlow } from "../helpers/login-flow"; +import { currentDir } from "../helpers/paths"; import { withPromptFix } from "../helpers/prompt"; import { getRepoContext, @@ -74,7 +75,7 @@ export function registerInitCommand(program: Command) { } const hadExistingProject = existsSync( - join(process.cwd(), ".archgate", "adrs") + join(currentDir(), ".archgate", "adrs") ); let hasCredentials = (await loadCredentials()) !== null; @@ -111,7 +112,7 @@ export function registerInitCommand(program: Command) { // Run init for each selected editor (sequential for ordered output) for (const editor of editors) { // oxlint-disable-next-line no-await-in-loop -- sequential init with per-editor output - const result = await initProject(process.cwd(), { + const result = await initProject(currentDir(), { editor, installPlugin, }); @@ -188,7 +189,7 @@ export function registerInitCommand(program: Command) { }); if (process.stdin.isTTY && !hadExistingProject) { - await runGreenfieldWizard(process.cwd()); + await runGreenfieldWizard(currentDir()); } } catch (err) { if (err instanceof Error && err.name === "ExitPromptError") throw err; diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index 2b6c7810..c7096a91 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -14,7 +14,7 @@ import { exitWith, handleCommandError } from "../../helpers/exit"; import { EDITOR_LABELS, EDITOR_TARGETS } from "../../helpers/init-project"; import type { EditorTarget } from "../../helpers/init-project"; import { logError, logInfo, logWarn } from "../../helpers/log"; -import { findProjectRoot } from "../../helpers/paths"; +import { currentDir, findProjectRoot } from "../../helpers/paths"; import { buildMarketplaceUrl, buildVscodeMarketplaceUrl, @@ -124,7 +124,7 @@ export async function installForEditor( } case "vscode": { const url = buildVscodeMarketplaceUrl(); - await configureVscodeSettings(findProjectRoot() ?? process.cwd(), url); + await configureVscodeSettings(findProjectRoot() ?? currentDir(), url); if (await isVscodeCliAvailable()) { await installVscodeExtension(token); logInfo(`Archgate extension installed for ${label}.`); diff --git a/src/helpers/doctor.ts b/src/helpers/doctor.ts index d41c2c0e..e7a859ce 100644 --- a/src/helpers/doctor.ts +++ b/src/helpers/doctor.ts @@ -16,7 +16,7 @@ import { detectEditors } from "./editor-detect"; import type { CredentialHelperStatus } from "./git-credential-config"; import { inspectGitCredentialHelper } from "./git-credential-config"; import { detectInstallMethod, getProjectContext } from "./install-info"; -import { internalPath } from "./paths"; +import { internalPath, tryCurrentDir } from "./paths"; import { getPlatformInfo, resolveCommand } from "./platform"; import { isTelemetryEnabled } from "./telemetry-config"; @@ -79,7 +79,15 @@ interface IntegrationInfo { } function detectIntegrations(): IntegrationInfo { - const cwd = process.cwd(); + const cwd = tryCurrentDir(); + if (cwd === null) { + return { + claudePlugin: false, + cursorPlugin: false, + vscodeSettings: false, + copilotSettings: false, + }; + } return { claudePlugin: existsSync(join(cwd, ".claude", "settings.local.json")), // The Cursor plugin is embedded inside the archgate VS Code extension diff --git a/src/helpers/install-info.ts b/src/helpers/install-info.ts index 0a111f8c..69b0662c 100644 --- a/src/helpers/install-info.ts +++ b/src/helpers/install-info.ts @@ -10,7 +10,7 @@ import { existsSync, readdirSync } from "node:fs"; import { join } from "node:path"; -import { internalPath } from "./paths"; +import { internalPath, tryCurrentDir } from "./paths"; import { resolvedProjectPaths } from "./project-config"; // --------------------------------------------------------------------------- @@ -116,7 +116,15 @@ export interface ProjectContext { * enough to re-run on every event. */ export function getProjectContext(): ProjectContext { - const cwd = process.cwd(); + const cwd = tryCurrentDir(); + if (cwd === null) { + return { + hasProject: false, + adrCount: 0, + adrWithRulesCount: 0, + domains: [], + }; + } const archgateDir = join(cwd, ".archgate"); const hasProject = existsSync(archgateDir); diff --git a/src/helpers/paths.ts b/src/helpers/paths.ts index e64667f7..bffdcc24 100644 --- a/src/helpers/paths.ts +++ b/src/helpers/paths.ts @@ -24,6 +24,54 @@ function userHomeDir(): string { return homedir(); } +/** + * True when `process.cwd()` failed because the working directory was deleted + * out from under the process (libuv reports `ENOENT` from `uv_cwd`). + */ +function isDeletedCwdError(err: unknown): boolean { + return ( + err instanceof Error && + "code" in err && + err.code === "ENOENT" && + (!("syscall" in err) || err.syscall === "uv_cwd") + ); +} + +/** + * Resolve the current working directory. Prefer it over `process.cwd()`, + * which throws a raw `ENOENT` when the launch directory has been deleted — a + * caller environment problem, not a CLI bug. + * + * @throws {UserError} When the working directory has been deleted, so the + * ARCH-012 boundary exits 1 without reporting it to Sentry. + */ +export function currentDir(): string { + try { + return process.cwd(); + } catch (err) { + if (isDeletedCwdError(err)) { + throw new UserError( + "The current working directory no longer exists.", + "Change to an existing directory and run the command again." + ); + } + throw err; + } +} + +/** + * Like {@link currentDir}, but returns `null` instead of throwing. For + * telemetry and diagnostics, which must never fail the command that invoked + * them. + */ +export function tryCurrentDir(): string | null { + try { + return process.cwd(); + } catch { + return null; + } +} + export function internalPath(...path: string[]) { const internalFolder = join(userHomeDir(), ".archgate"); return join(internalFolder, ...path); @@ -219,7 +267,7 @@ export function findProjectRoot(startDir?: string): string | null { const ceilingEnv = Bun.env.ARCHGATE_PROJECT_CEILING; const ceiling = ceilingEnv !== undefined && ceilingEnv !== "" ? resolve(ceilingEnv) : null; - let dir = startDir ?? process.cwd(); + let dir = startDir ?? currentDir(); for (let i = 0; i < MAX_ANCESTOR_DEPTH; i++) { const adrsDir = join(dir, ".archgate", "adrs"); diff --git a/src/helpers/repo.ts b/src/helpers/repo.ts index d9b84172..a42a0128 100644 --- a/src/helpers/repo.ts +++ b/src/helpers/repo.ts @@ -11,6 +11,7 @@ import { createHash } from "node:crypto"; import { logDebug } from "./log"; +import { tryCurrentDir } from "./paths"; import { _resetPublicProbeCache, isPublicRepo } from "./repo-probe"; // Re-export the public-visibility probe so commands / telemetry can import @@ -84,7 +85,11 @@ let cached: RepoContext | null = null; export async function getRepoContext(): Promise { if (cached) return cached; - const cwd = process.cwd(); + const cwd = tryCurrentDir(); + if (cwd === null) { + cached = emptyContext(false); + return cached; + } // Fire all four git probes concurrently. On Windows each subprocess costs // ~25ms, so running them in parallel instead of gating on diff --git a/src/helpers/session-context-antigravity.ts b/src/helpers/session-context-antigravity.ts index 6aa28787..6f47bc27 100644 --- a/src/helpers/session-context-antigravity.ts +++ b/src/helpers/session-context-antigravity.ts @@ -22,6 +22,7 @@ import { antigravityCliDir, antigravityConversationsDir, antigravityDataDirs, + currentDir, usableEnv, } from "./paths"; import { @@ -262,7 +263,7 @@ function findConversations( ); if (!hasStore) return null; - const target = normalizePath(projectRoot ?? process.cwd()); + const target = normalizePath(projectRoot ?? currentDir()); const current = currentConversationId(); const summaries = summaryWorkspaces(); diff --git a/src/helpers/session-context-codex.ts b/src/helpers/session-context-codex.ts index 8106318b..819fd717 100644 --- a/src/helpers/session-context-codex.ts +++ b/src/helpers/session-context-codex.ts @@ -17,7 +17,7 @@ import { z } from "zod"; import { readIfExists } from "./fs-read"; import { logDebug } from "./log"; -import { codexSessionsDir } from "./paths"; +import { codexSessionsDir, currentDir } from "./paths"; import { type ReadSessionOptions, type SessionListEntry, @@ -294,7 +294,7 @@ async function findCodexRollouts( const sessionsDir = codexSessionsDir(); if (!existsSync(sessionsDir)) return null; - const target = normalizePath(projectRoot ?? process.cwd()); + const target = normalizePath(projectRoot ?? currentDir()); const files = enumerateRolloutFiles(sessionsDir); const inspected = await mapBounded( diff --git a/src/helpers/session-context-copilot.ts b/src/helpers/session-context-copilot.ts index 8e1daaf0..14af1624 100644 --- a/src/helpers/session-context-copilot.ts +++ b/src/helpers/session-context-copilot.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { readTextIfExists } from "./fs-read"; import { logDebug } from "./log"; -import { copilotSessionStateDir } from "./paths"; +import { copilotSessionStateDir, currentDir } from "./paths"; import { MessageContentSchema, type ReadSessionOptions, @@ -65,7 +65,7 @@ async function findMatchingCopilotSessions( projectRoot: string | null ): Promise { const stateDir = copilotSessionStateDir(); - const normalizedProjectRoot = normalizePath(projectRoot ?? process.cwd()); + const normalizedProjectRoot = normalizePath(projectRoot ?? currentDir()); // 1. List all session UUID directories let allDirs: Array<{ name: string; mtime: number }>; diff --git a/src/helpers/session-context-opencode.ts b/src/helpers/session-context-opencode.ts index f7fa1ae5..18d3c052 100644 --- a/src/helpers/session-context-opencode.ts +++ b/src/helpers/session-context-opencode.ts @@ -4,7 +4,7 @@ import { Database } from "bun:sqlite"; import { existsSync } from "node:fs"; import { logDebug } from "./log"; -import { opencodeDbPath } from "./paths"; +import { currentDir, opencodeDbPath } from "./paths"; import { RELEVANT_ROLES, type ReadSessionOptions, @@ -69,7 +69,7 @@ export function listOpencodeSessions( projectRoot: string | null ): SessionListResult { const dbPath = opencodeDbPath(); - const normalizedProjectRoot = normalizePath(projectRoot ?? process.cwd()); + const normalizedProjectRoot = normalizePath(projectRoot ?? currentDir()); if (!existsSync(dbPath)) { return { ok: false, error: "No opencode database found", path: dbPath }; @@ -128,7 +128,7 @@ export function readOpencodeSession( ): OpencodeSessionResult { const limit = options?.maxEntries ?? 200; const dbPath = opencodeDbPath(); - const normalizedProjectRoot = normalizePath(projectRoot ?? process.cwd()); + const normalizedProjectRoot = normalizePath(projectRoot ?? currentDir()); if (!existsSync(dbPath)) { return { ok: false, error: "No opencode database found", path: dbPath }; diff --git a/src/helpers/session-context-pi.ts b/src/helpers/session-context-pi.ts index 2520a8a5..de2c5177 100644 --- a/src/helpers/session-context-pi.ts +++ b/src/helpers/session-context-pi.ts @@ -15,7 +15,7 @@ import { z } from "zod"; import { readIfExists, readTextIfExists } from "./fs-read"; import { logDebug } from "./log"; -import { piSessionsDir } from "./paths"; +import { currentDir, piSessionsDir } from "./paths"; import { MessageContentSchema, type ReadSessionOptions, @@ -135,7 +135,7 @@ async function findPiSessions( const sessionsDir = piSessionsDir(); if (!existsSync(sessionsDir)) return null; - const root = projectRoot ?? process.cwd(); + const root = projectRoot ?? currentDir(); const target = normalizePath(root); const shard = join(sessionsDir, encodePiProjectDir(root)); const searchDirs = existsSync(shard) diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index 13cea3ed..dd6bea0e 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { readTextIfExists } from "./fs-read"; import type { EditorTarget } from "./init-project"; +import { currentDir } from "./paths"; import { isWindows, isWSL, toWindowsPath } from "./platform"; /** @@ -199,7 +200,7 @@ interface ReadCursorSessionOptions extends ReadSessionOptions { } async function claudeProjectsDir(projectRoot: string | null): Promise { - const encodedPath = await encodeProjectPath(projectRoot ?? process.cwd()); + const encodedPath = await encodeProjectPath(projectRoot ?? currentDir()); return join(homedir(), ".claude", "projects", encodedPath); } @@ -332,7 +333,7 @@ async function cursorTranscriptsDir( projectRoot: string | null ): Promise { const encodedPath = await encodeProjectPath( - projectRoot ?? process.cwd(), + projectRoot ?? currentDir(), "cursor" ); return join( diff --git a/tests/helpers/paths.test.ts b/tests/helpers/paths.test.ts index 9a75890e..96e5bf2a 100644 --- a/tests/helpers/paths.test.ts +++ b/tests/helpers/paths.test.ts @@ -6,7 +6,14 @@ import * as os from "node:os"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { findProjectRoot, internalPath } from "../../src/helpers/paths"; +import { + currentDir, + findProjectRoot, + internalPath, + requireProjectRoot, + tryCurrentDir, +} from "../../src/helpers/paths"; +import { UserError } from "../../src/helpers/user-error"; import { restoreEnv } from "../test-utils"; describe("findProjectRoot", () => { @@ -131,3 +138,76 @@ describe("internalPath", () => { } ); }); + +/** The error libuv raises from `process.cwd()` once the directory is deleted. */ +function deletedCwdError(): Error { + return Object.assign( + new Error( + "ENOENT: process.cwd failed with error no such file or directory, uv_cwd" + ), + { code: "ENOENT", syscall: "uv_cwd" } + ); +} + +describe("currentDir", () => { + test("returns process.cwd() when the directory exists", () => { + expect(currentDir()).toBe(process.cwd()); + }); + + test("throws a UserError when the working directory was deleted", () => { + const cwdSpy = spyOn(process, "cwd").mockImplementation(() => { + throw deletedCwdError(); + }); + try { + expect(() => currentDir()).toThrow(UserError); + expect(() => currentDir()).toThrow( + "The current working directory no longer exists." + ); + } finally { + cwdSpy.mockRestore(); + } + }); + + test("rethrows unrelated errors unchanged", () => { + const failure = Object.assign(new Error("permission denied"), { + code: "EACCES", + }); + const cwdSpy = spyOn(process, "cwd").mockImplementation(() => { + throw failure; + }); + try { + expect(() => currentDir()).toThrow(failure); + } finally { + cwdSpy.mockRestore(); + } + }); + + test("findProjectRoot and requireProjectRoot surface the UserError", () => { + const cwdSpy = spyOn(process, "cwd").mockImplementation(() => { + throw deletedCwdError(); + }); + try { + expect(() => findProjectRoot()).toThrow(UserError); + expect(() => requireProjectRoot()).toThrow(UserError); + } finally { + cwdSpy.mockRestore(); + } + }); +}); + +describe("tryCurrentDir", () => { + test("returns process.cwd() when the directory exists", () => { + expect(tryCurrentDir()).toBe(process.cwd()); + }); + + test("returns null when the working directory was deleted", () => { + const cwdSpy = spyOn(process, "cwd").mockImplementation(() => { + throw deletedCwdError(); + }); + try { + expect(tryCurrentDir()).toBeNull(); + } finally { + cwdSpy.mockRestore(); + } + }); +}); diff --git a/tests/lint/no-process-cwd.test.ts b/tests/lint/no-process-cwd.test.ts new file mode 100644 index 00000000..875f0517 --- /dev/null +++ b/tests/lint/no-process-cwd.test.ts @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test } from "bun:test"; + +import plugin from "../../lint/no-process-cwd"; +import { parseJsModule } from "../../src/engine/js-parser"; + +/** Minimal ESTree-ish node shape, matching the plugin's own definition. */ +type AstNode = { type: string } & Record; + +function isAstNode(value: unknown): value is AstNode { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +/** Depth-first walk handing every `MemberExpression` to `visit`. */ +function walkMembers(node: AstNode, visit: (member: AstNode) => void): void { + if (node.type === "MemberExpression") visit(node); + for (const key of Object.keys(node)) { + if (key === "parent" || key === "loc" || key === "range") continue; + const value = node[key]; + for (const item of Array.isArray(value) ? value : [value]) { + if (isAstNode(item)) walkMembers(item, visit); + } + } +} + +/** Run the `cwd/no-process-cwd` rule against a snippet; returns every reported message. */ +function lint(source: string): string[] { + // meriyah's Program type structurally satisfies the plugin's own loose + // AstNode shape; the plugin doesn't export a type to convert through. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + const program = parseJsModule(source) as unknown as AstNode; + const messages: string[] = []; + const visitor = plugin.rules["no-process-cwd"].create({ + report({ message }) { + messages.push(message); + }, + }); + walkMembers(program, (member) => { + visitor.MemberExpression(member); + }); + return messages; +} + +describe("no-process-cwd", () => { + test.each([ + ["a direct call", "const dir = process.cwd();"], + ["a fallback", "const dir = root ?? process.cwd();"], + ["a bare reference", "const getDir = process.cwd;"], + ])("reports %s", (_label, source) => { + const messages = lint(source); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain("currentDir()"); + }); + + test.each([ + ["the shared helper", "const dir = currentDir();"], + ["the non-throwing helper", "const dir = tryCurrentDir();"], + ["another process member", "const argv = process.argv;"], + ["cwd off another object", "const dir = proc.cwd();"], + ["a computed property", 'const dir = process["cwd"]();'], + ])("permits %s", (_label, source) => { + expect(lint(source)).toEqual([]); + }); +}); From 4156925d174569b731756d5b9e5299cf5a3f466d Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 29 Sep 2026 15:07:51 +0200 Subject: [PATCH 2/3] test: cover the deleted working directory fallbacks Signed-off-by: Rhuan Barreto --- tests/helpers/doctor.test.ts | 24 +++++++++++++++++++++++- tests/helpers/install-info.test.ts | 21 ++++++++++++++++++++- tests/helpers/paths.test.ts | 11 +++++++++++ tests/helpers/repo.test.ts | 17 +++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/tests/helpers/doctor.test.ts b/tests/helpers/doctor.test.ts index 6731b773..9c20432d 100644 --- a/tests/helpers/doctor.test.ts +++ b/tests/helpers/doctor.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { runDoctor } from "../../src/helpers/doctor"; import type { DoctorReport } from "../../src/helpers/doctor"; @@ -55,6 +55,28 @@ describe("doctor", () => { expect(report.project.domains.length).toBeGreaterThan(0); }); + test("reports no project or integrations when the working directory was deleted", async () => { + const cwdSpy = spyOn(process, "cwd").mockImplementation(() => { + throw Object.assign(new Error("ENOENT: process.cwd failed"), { + code: "ENOENT", + syscall: "uv_cwd", + }); + }); + try { + const report = await runDoctor(); + + expect(report.project.has_project).toBe(false); + expect(report.integrations).toEqual({ + claude_plugin: false, + cursor_plugin: false, + vscode_settings: false, + copilot_settings: false, + }); + } finally { + cwdSpy.mockRestore(); + } + }); + test("report is JSON-serializable", async () => { const report = await runDoctor(); const json = JSON.stringify(report); diff --git a/tests/helpers/install-info.test.ts b/tests/helpers/install-info.test.ts index 64448703..4d659b76 100644 --- a/tests/helpers/install-info.test.ts +++ b/tests/helpers/install-info.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -249,6 +249,25 @@ describe("install-info", () => { expect(first).toEqual(second); }); + test("reports no project when the working directory was deleted", () => { + const cwdSpy = spyOn(process, "cwd").mockImplementation(() => { + throw Object.assign(new Error("ENOENT: process.cwd failed"), { + code: "ENOENT", + syscall: "uv_cwd", + }); + }); + try { + expect(getProjectContext()).toEqual({ + hasProject: false, + adrCount: 0, + adrWithRulesCount: 0, + domains: [], + }); + } finally { + cwdSpy.mockRestore(); + } + }); + test("domains are sorted alphabetically", () => { const ctx = getProjectContext(); const sorted = [...ctx.domains].sort(); diff --git a/tests/helpers/paths.test.ts b/tests/helpers/paths.test.ts index 96e5bf2a..db660692 100644 --- a/tests/helpers/paths.test.ts +++ b/tests/helpers/paths.test.ts @@ -168,6 +168,17 @@ describe("currentDir", () => { } }); + test("treats an ENOENT without a syscall as a deleted directory", () => { + const cwdSpy = spyOn(process, "cwd").mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + try { + expect(() => currentDir()).toThrow(UserError); + } finally { + cwdSpy.mockRestore(); + } + }); + test("rethrows unrelated errors unchanged", () => { const failure = Object.assign(new Error("permission denied"), { code: "EACCES", diff --git a/tests/helpers/repo.test.ts b/tests/helpers/repo.test.ts index 713a7319..a7497ed8 100644 --- a/tests/helpers/repo.test.ts +++ b/tests/helpers/repo.test.ts @@ -294,6 +294,23 @@ describe("repo helper", () => { } }); + test("returns an empty context when the working directory was deleted", async () => { + const cwdSpy = spyOn(process, "cwd").mockImplementation(() => { + throw Object.assign(new Error("ENOENT: process.cwd failed"), { + code: "ENOENT", + syscall: "uv_cwd", + }); + }); + try { + _resetRepoContextCache(); + + expect(await getRepoContext()).toEqual(EMPTY_CONTEXT); + } finally { + cwdSpy.mockRestore(); + _resetRepoContextCache(); + } + }); + test("populates host/repoId when the CWD is a git repo with a remote", async () => { // Using the CLI's own repo — it has a github.com/archgate/cli remote. _resetRepoContextCache(); From 8a128f0af848e6684a3483ac699c81001181c416 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 29 Sep 2026 15:21:56 +0200 Subject: [PATCH 3/3] fix(paths): address review feedback on deleted-cwd handling - Resolve a relative ARCHGATE_PROJECT_CEILING through currentDir(). - Do not cache the empty repo context from a failed cwd lookup. - Keep doctor's cursor_plugin false when the cwd is unavailable. - Flag process["cwd"] in the cwd/no-process-cwd rule. - Correct the ARCH-011 init exception. Signed-off-by: Rhuan Barreto --- ...-011-consistent-project-root-resolution.md | 2 +- lint/no-process-cwd.ts | 16 ++++++---- src/helpers/doctor.ts | 9 +++--- src/helpers/paths.ts | 14 ++++++--- src/helpers/repo.ts | 6 ++-- tests/helpers/paths.test.ts | 30 +++++++++++++++++++ tests/helpers/repo.test.ts | 3 ++ tests/lint/no-process-cwd.test.ts | 7 ++++- 8 files changed, 68 insertions(+), 19 deletions(-) diff --git a/.archgate/adrs/ARCH-011-consistent-project-root-resolution.md b/.archgate/adrs/ARCH-011-consistent-project-root-resolution.md index c2d1284b..9578c207 100644 --- a/.archgate/adrs/ARCH-011-consistent-project-root-resolution.md +++ b/.archgate/adrs/ARCH-011-consistent-project-root-resolution.md @@ -29,7 +29,7 @@ All commands that operate on `.archgate/` project resources MUST use `findProjec **Exceptions:** -- `archgate init` — Creates the `.archgate/` directory; uses `process.cwd()` because no project root exists yet +- `archgate init` — Creates the `.archgate/` directory; uses `currentDir()` because no project root exists yet - `archgate upgrade` — Operates on the binary, not on a project; its `findPackageRoot()` walks up from the binary path to find `package.json` for local install detection (a different concern than project root) - Commands that don't require a project (e.g., `clean`, `login`) are not affected diff --git a/lint/no-process-cwd.ts b/lint/no-process-cwd.ts index 8e4e5e4a..8eaf4852 100644 --- a/lint/no-process-cwd.ts +++ b/lint/no-process-cwd.ts @@ -19,13 +19,19 @@ function isAstNode(value: unknown): value is AstNode { ); } -/** The identifier name of a non-computed member property. */ +/** + * The statically known name of a member property: an identifier + * (`process.cwd`) or a string literal in brackets (`process["cwd"]`). + */ function staticPropertyName(node: AstNode): string | undefined { - if (node.computed === true) return undefined; const property = node.property; - return isAstNode(property) && - property.type === "Identifier" && - typeof property.name === "string" + if (!isAstNode(property)) return undefined; + if (node.computed === true) { + return property.type === "Literal" && typeof property.value === "string" + ? property.value + : undefined; + } + return property.type === "Identifier" && typeof property.name === "string" ? property.name : undefined; } diff --git a/src/helpers/doctor.ts b/src/helpers/doctor.ts index e7a859ce..7e50f51d 100644 --- a/src/helpers/doctor.ts +++ b/src/helpers/doctor.ts @@ -78,8 +78,7 @@ interface IntegrationInfo { copilotSettings: boolean; } -function detectIntegrations(): IntegrationInfo { - const cwd = tryCurrentDir(); +function detectIntegrations(cwd: string | null): IntegrationInfo { if (cwd === null) { return { claudePlugin: false, @@ -124,7 +123,8 @@ function sessionKind( export async function runDoctor(): Promise { const platform = getPlatformInfo(); const projectCtx = getProjectContext(); - const integrations = detectIntegrations(); + const cwd = tryCurrentDir(); + const integrations = detectIntegrations(cwd); const configDir = internalPath(); const [editors, gitCmd, credentials, session, credentialHelper] = @@ -140,7 +140,8 @@ export async function runDoctor(): Promise { // Cursor plugin is embedded in the VSIX — no project file to detect. // Use cursor CLI availability as a proxy (prerequisite for install). - integrations.cursorPlugin = editorMap.cursor; + // Without a working directory no integration is checked, Cursor included. + integrations.cursorPlugin = cwd !== null && editorMap.cursor; return { system: { diff --git a/src/helpers/paths.ts b/src/helpers/paths.ts index bffdcc24..75bf06e5 100644 --- a/src/helpers/paths.ts +++ b/src/helpers/paths.ts @@ -2,7 +2,7 @@ // Copyright 2026 Archgate import { existsSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; -import { join, dirname, resolve } from "node:path"; +import { join, dirname, isAbsolute, resolve } from "node:path"; import { logDebug } from "./log"; import { UserError } from "./user-error"; @@ -264,10 +264,16 @@ export function createPathIfNotExists(path: string) { const MAX_ANCESTOR_DEPTH = 1000; export function findProjectRoot(startDir?: string): string | null { - const ceilingEnv = Bun.env.ARCHGATE_PROJECT_CEILING; - const ceiling = - ceilingEnv !== undefined && ceilingEnv !== "" ? resolve(ceilingEnv) : null; let dir = startDir ?? currentDir(); + const ceilingEnv = Bun.env.ARCHGATE_PROJECT_CEILING; + let ceiling: string | null = null; + if (ceilingEnv !== undefined && ceilingEnv !== "") { + // A relative ceiling resolves against cwd; go through currentDir() so a + // deleted cwd still surfaces as a UserError, not a raw ENOENT. + ceiling = isAbsolute(ceilingEnv) + ? resolve(ceilingEnv) + : resolve(currentDir(), ceilingEnv); + } for (let i = 0; i < MAX_ANCESTOR_DEPTH; i++) { const adrsDir = join(dir, ".archgate", "adrs"); diff --git a/src/helpers/repo.ts b/src/helpers/repo.ts index a42a0128..c70ddd87 100644 --- a/src/helpers/repo.ts +++ b/src/helpers/repo.ts @@ -86,10 +86,8 @@ export async function getRepoContext(): Promise { if (cached) return cached; const cwd = tryCurrentDir(); - if (cwd === null) { - cached = emptyContext(false); - return cached; - } + // Not cached: a later call from an existing directory can still resolve. + if (cwd === null) return emptyContext(false); // Fire all four git probes concurrently. On Windows each subprocess costs // ~25ms, so running them in parallel instead of gating on diff --git a/tests/helpers/paths.test.ts b/tests/helpers/paths.test.ts index db660692..35f54a98 100644 --- a/tests/helpers/paths.test.ts +++ b/tests/helpers/paths.test.ts @@ -193,6 +193,36 @@ describe("currentDir", () => { } }); + test("a relative project ceiling surfaces the UserError, not ENOENT", () => { + const saved = Bun.env.ARCHGATE_PROJECT_CEILING; + Bun.env.ARCHGATE_PROJECT_CEILING = "relative-ceiling"; + const cwdSpy = spyOn(process, "cwd").mockImplementation(() => { + throw deletedCwdError(); + }); + try { + expect(() => findProjectRoot(tmpdir())).toThrow(UserError); + } finally { + cwdSpy.mockRestore(); + restoreEnv("ARCHGATE_PROJECT_CEILING", saved); + } + }); + + test("an explicit start dir and absolute ceiling need no working directory", () => { + const saved = Bun.env.ARCHGATE_PROJECT_CEILING; + const root = mkdtempSync(join(tmpdir(), "archgate-paths-test-")); + Bun.env.ARCHGATE_PROJECT_CEILING = root; + const cwdSpy = spyOn(process, "cwd").mockImplementation(() => { + throw deletedCwdError(); + }); + try { + expect(findProjectRoot(root)).toBeNull(); + } finally { + cwdSpy.mockRestore(); + restoreEnv("ARCHGATE_PROJECT_CEILING", saved); + rmSync(root, { recursive: true, force: true }); + } + }); + test("findProjectRoot and requireProjectRoot surface the UserError", () => { const cwdSpy = spyOn(process, "cwd").mockImplementation(() => { throw deletedCwdError(); diff --git a/tests/helpers/repo.test.ts b/tests/helpers/repo.test.ts index a7497ed8..acb4cea1 100644 --- a/tests/helpers/repo.test.ts +++ b/tests/helpers/repo.test.ts @@ -305,6 +305,9 @@ describe("repo helper", () => { _resetRepoContextCache(); expect(await getRepoContext()).toEqual(EMPTY_CONTEXT); + cwdSpy.mockRestore(); + // The failed lookup is not cached, so an existing cwd resolves again. + expect((await getRepoContext()).isGit).toBe(true); } finally { cwdSpy.mockRestore(); _resetRepoContextCache(); diff --git a/tests/lint/no-process-cwd.test.ts b/tests/lint/no-process-cwd.test.ts index 875f0517..6e8dee4e 100644 --- a/tests/lint/no-process-cwd.test.ts +++ b/tests/lint/no-process-cwd.test.ts @@ -52,6 +52,7 @@ describe("no-process-cwd", () => { ["a direct call", "const dir = process.cwd();"], ["a fallback", "const dir = root ?? process.cwd();"], ["a bare reference", "const getDir = process.cwd;"], + ["a string-literal computed property", 'const dir = process["cwd"]();'], ])("reports %s", (_label, source) => { const messages = lint(source); expect(messages).toHaveLength(1); @@ -63,7 +64,11 @@ describe("no-process-cwd", () => { ["the non-throwing helper", "const dir = tryCurrentDir();"], ["another process member", "const argv = process.argv;"], ["cwd off another object", "const dir = proc.cwd();"], - ["a computed property", 'const dir = process["cwd"]();'], + ["a dynamic computed property", "const dir = process[key]();"], + [ + "a private member named cwd", + "class C { #cwd = 1; get(o) { return o.#cwd; } }", + ], ])("permits %s", (_label, source) => { expect(lint(source)).toEqual([]); });