Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

### 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
Expand All @@ -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

Expand Down
9 changes: 8 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
73 changes: 73 additions & 0 deletions lint/no-process-cwd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// 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<string, unknown>;

function isAstNode(value: unknown): value is AstNode {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof value.type === "string"
);
}

/**
* 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 {
const property = node.property;
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;
}

/** 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;
7 changes: 4 additions & 3 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/commands/plugin/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}.`);
Expand Down
19 changes: 14 additions & 5 deletions src/helpers/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -78,8 +78,15 @@ interface IntegrationInfo {
copilotSettings: boolean;
}

function detectIntegrations(): IntegrationInfo {
const cwd = process.cwd();
function detectIntegrations(cwd: string | null): IntegrationInfo {
if (cwd === null) {
return {
claudePlugin: false,
cursorPlugin: false,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
vscodeSettings: false,
copilotSettings: false,
};
}
return {
claudePlugin: existsSync(join(cwd, ".claude", "settings.local.json")),
// The Cursor plugin is embedded inside the archgate VS Code extension
Expand Down Expand Up @@ -116,7 +123,8 @@ function sessionKind(
export async function runDoctor(): Promise<DoctorReport> {
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] =
Expand All @@ -132,7 +140,8 @@ export async function runDoctor(): Promise<DoctorReport> {

// 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: {
Expand Down
12 changes: 10 additions & 2 deletions src/helpers/install-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);

Expand Down
62 changes: 58 additions & 4 deletions src/helpers/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -216,10 +264,16 @@ export function createPathIfNotExists(path: string) {
const MAX_ANCESTOR_DEPTH = 1000;

export function findProjectRoot(startDir?: string): string | null {
let dir = startDir ?? currentDir();
const ceilingEnv = Bun.env.ARCHGATE_PROJECT_CEILING;
const ceiling =
ceilingEnv !== undefined && ceilingEnv !== "" ? resolve(ceilingEnv) : null;
let dir = startDir ?? process.cwd();
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");
Expand Down
5 changes: 4 additions & 1 deletion src/helpers/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -84,7 +85,9 @@ let cached: RepoContext | null = null;
export async function getRepoContext(): Promise<RepoContext> {
if (cached) return cached;

const cwd = process.cwd();
const cwd = tryCurrentDir();
// 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
Expand Down
3 changes: 2 additions & 1 deletion src/helpers/session-context-antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
antigravityCliDir,
antigravityConversationsDir,
antigravityDataDirs,
currentDir,
usableEnv,
} from "./paths";
import {
Expand Down Expand Up @@ -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();

Expand Down
4 changes: 2 additions & 2 deletions src/helpers/session-context-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading