From 734cab39dbf996b42cd6a91e8285d68b24f73ac6 Mon Sep 17 00:00:00 2001 From: Yashasvi Date: Mon, 15 Jun 2026 14:09:14 +0530 Subject: [PATCH] fix: make drift output and global config cross-platform (Windows) The new telemetry/feedback/drift code was only exercised on Linux CI, so several Windows-only bugs slipped through: - Path output used native separators. relative()/glob return `\` on Windows, but drift issue `file` fields, heartbeat staleFiles, scanner entry paths, and event-log entries are forward-slash contracts (printed, JSON-serialized, consumed by mex-agent). The broken-link/path `patterns/` severity check (`source.includes(patterns/)`) silently misfired on Windows. Added src/paths.ts `toPosix()` and applied it at every boundary. - mexHomeDir() relied on homedir(), which ignores $HOME on Windows (reads USERPROFILE). Added a cross-platform MEX_HOME override so the global config / telemetry-id can be isolated and relocated. Tests: - telemetry/feedback suites now isolate via MEX_HOME instead of $HOME. - dev-repo guard tests chdir out before rmSync (Windows can't delete cwd). - gated the POSIX 0o600 mode assertion to non-Windows (NTFS has no mode bits). All 231 tests pass on Windows and remain green on POSIX. Co-Authored-By: Claude Opus 4.8 --- src/drift/checkers/broken-link.ts | 3 ++- src/drift/checkers/todo-fixme.ts | 3 ++- src/drift/index.ts | 5 +++-- src/events.ts | 5 +++-- src/global-config.ts | 13 +++++++++++-- src/heartbeat.ts | 3 ++- src/paths.ts | 15 +++++++++++++++ src/scanner/entry-points.ts | 4 +++- test/feedback.test.ts | 14 ++++++++------ test/telemetry.test.ts | 29 ++++++++++++++++++++--------- 10 files changed, 69 insertions(+), 25 deletions(-) create mode 100644 src/paths.ts diff --git a/src/drift/checkers/broken-link.ts b/src/drift/checkers/broken-link.ts index ed85394..86aef6f 100644 --- a/src/drift/checkers/broken-link.ts +++ b/src/drift/checkers/broken-link.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, resolve, relative } from "node:path"; +import { toPosix } from "../../paths.js"; import type { DriftIssue } from "../../types.js"; const LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g; @@ -13,7 +14,7 @@ export function checkBrokenLinks( const issues: DriftIssue[] = []; for (const filePath of scaffoldFiles) { - const source = relative(projectRoot, filePath); + const source = toPosix(relative(projectRoot, filePath)); let content: string; try { content = readFileSync(filePath, "utf-8"); diff --git a/src/drift/checkers/todo-fixme.ts b/src/drift/checkers/todo-fixme.ts index bba15bf..06e4ca3 100644 --- a/src/drift/checkers/todo-fixme.ts +++ b/src/drift/checkers/todo-fixme.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { relative } from "node:path"; +import { toPosix } from "../../paths.js"; import type { DriftIssue } from "../../types.js"; const MARKER_RE = /\b(TODO|FIXME)\b/g; @@ -12,7 +13,7 @@ export function checkTodoFixme( const issues: DriftIssue[] = []; for (const filePath of scaffoldFiles) { - const source = relative(projectRoot, filePath); + const source = toPosix(relative(projectRoot, filePath)); let content: string; try { content = readFileSync(filePath, "utf-8"); diff --git a/src/drift/index.ts b/src/drift/index.ts index e47bf4e..2bcbaf0 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -16,6 +16,7 @@ import { checkScriptCoverage } from "./checkers/script-coverage.js"; import { checkToolConfigSync } from "./checkers/tool-config-sync.js"; import { checkTodoFixme } from "./checkers/todo-fixme.js"; import { checkBrokenLinks } from "./checkers/broken-link.js"; +import { toPosix } from "../paths.js"; /** * Default glob patterns used to locate scaffold markdown files, relative to @@ -62,14 +63,14 @@ export async function runDriftCheck( // Extract claims from all files for (const filePath of scaffoldFiles) { - const source = relative(projectRoot, filePath); + const source = toPosix(relative(projectRoot, filePath)); const claims = extractClaims(filePath, source); allClaims.push(...claims); } // Run checkers that work on individual files for (const filePath of scaffoldFiles) { - const source = relative(projectRoot, filePath); + const source = toPosix(relative(projectRoot, filePath)); // Frontmatter edge check const frontmatter = parseFrontmatter(filePath); diff --git a/src/events.ts b/src/events.ts index 97d4ba8..af9f18e 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, appendFileSync, readFileSync } from "node:fs"; import { dirname, resolve, relative } from "node:path"; import chalk from "chalk"; +import { toPosix } from "./paths.js"; import type { MexConfig } from "./types.js"; /** Runtime list of valid event kinds. Re-exported as part of the public API so @@ -68,13 +69,13 @@ export async function runLog(config: MexConfig, message: string, opts: LogOpts = export function appendEvent(config: MexConfig, message: string, opts: LogOpts = {}): EventEntry { const kind = normalizeKind(opts.kind); - const files = (opts.files ?? []).map((f) => relative(config.projectRoot, resolve(config.projectRoot, f))); + const files = (opts.files ?? []).map((f) => toPosix(relative(config.projectRoot, resolve(config.projectRoot, f)))); const entry: EventEntry = { timestamp: new Date().toISOString(), kind, message, files, - cwd: relative(config.projectRoot, process.cwd()) || ".", + cwd: toPosix(relative(config.projectRoot, process.cwd())) || ".", }; if (opts.trace !== undefined) entry.trace = opts.trace; if (opts.source !== undefined) entry.source = opts.source; diff --git a/src/global-config.ts b/src/global-config.ts index 8f085a6..bb36565 100644 --- a/src/global-config.ts +++ b/src/global-config.ts @@ -18,9 +18,18 @@ const MEX_HOME_DIR_NAME = ".mex"; const TELEMETRY_ID_FILE = "telemetry-id"; const GLOBAL_CONFIG_FILE = "config.json"; -/** Absolute path to `~/.mex`. Respects `$HOME`. */ +/** + * Absolute path to `~/.mex`. + * + * `MEX_HOME` overrides the base directory when set — used by tests to isolate + * the global config/telemetry-id from the real home, and lets users relocate + * the dir. We can't rely on `$HOME` for this: Node's `homedir()` ignores `$HOME` + * on Windows (it reads `USERPROFILE`), so an explicit override is the only + * cross-platform seam. + */ export function mexHomeDir(): string { - return join(homedir(), MEX_HOME_DIR_NAME); + const base = process.env.MEX_HOME?.trim() || homedir(); + return join(base, MEX_HOME_DIR_NAME); } /** Create `~/.mex/` if it doesn't exist. */ diff --git a/src/heartbeat.ts b/src/heartbeat.ts index e89640c..26b5feb 100644 --- a/src/heartbeat.ts +++ b/src/heartbeat.ts @@ -4,6 +4,7 @@ import { globSync } from "glob"; import chalk from "chalk"; import { parseFrontmatter } from "./drift/frontmatter.js"; import { daysSinceFrontmatterDate } from "./drift/checkers/staleness.js"; +import { toPosix } from "./paths.js"; import type { MexConfig } from "./types.js"; export interface HeartbeatResult { @@ -70,7 +71,7 @@ export function checkHeartbeat( now, ); return days !== null && days > staleDays - ? { file: relative(config.scaffoldRoot, file), days } + ? { file: toPosix(relative(config.scaffoldRoot, file)), days } : null; }) .filter((v): v is { file: string; days: number } => Boolean(v)); diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..68ad266 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,15 @@ +import { sep } from "node:path"; + +/** + * Normalize a filesystem path to forward slashes. + * + * `path.relative()` and `glob` return native separators (`\` on Windows). mex's + * output contracts — drift issue `file` fields, heartbeat `staleFiles`, scanner + * entry-point `path`s — are forward-slash strings: they're printed to users, + * JSON-serialized, consumed by mex-agent, and compared with literals like + * `source.includes("patterns/")`. Run every native path through this before it + * crosses one of those boundaries so behavior is identical on every OS. + */ +export function toPosix(p: string): string { + return sep === "/" ? p : p.split(sep).join("/"); +} diff --git a/src/scanner/entry-points.ts b/src/scanner/entry-points.ts index 6d68a1b..cac555a 100644 --- a/src/scanner/entry-points.ts +++ b/src/scanner/entry-points.ts @@ -1,4 +1,5 @@ import { globSync } from "glob"; +import { toPosix } from "../paths.js"; import type { EntryPoint } from "../types.js"; const MAIN_PATTERNS = [ @@ -44,7 +45,8 @@ export function scanEntryPoints(projectRoot: string): EntryPoint[] { cwd: projectRoot, ignore: ["node_modules/**", "dist/**", "build/**", ".git/**"], }); - for (const path of matches) { + for (const match of matches) { + const path = toPosix(match); if (seen.has(path)) continue; seen.add(path); entries.push({ path, type }); diff --git a/test/feedback.test.ts b/test/feedback.test.ts index 3668d57..8c110d1 100644 --- a/test/feedback.test.ts +++ b/test/feedback.test.ts @@ -3,10 +3,11 @@ import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -// Each test gets a fresh $HOME so the global config (invite state) is isolated, -// and controls process.stdout.isTTY for the gating checks. +// Each test gets a fresh MEX_HOME so the global config (invite state) is +// isolated, and controls process.stdout.isTTY for the gating checks. We use +// MEX_HOME (not $HOME) because Node's homedir() ignores $HOME on Windows. -let originalHome: string | undefined; +let originalMexHome: string | undefined; let tempHome: string; let originalIsTTY: boolean | undefined; @@ -15,14 +16,15 @@ function setTTY(value: boolean): void { } beforeEach(() => { - originalHome = process.env.HOME; + originalMexHome = process.env.MEX_HOME; originalIsTTY = process.stdout.isTTY; tempHome = mkdtempSync(join(tmpdir(), "mex-fb-")); - process.env.HOME = tempHome; + process.env.MEX_HOME = tempHome; }); afterEach(async () => { - process.env.HOME = originalHome; + if (originalMexHome !== undefined) process.env.MEX_HOME = originalMexHome; + else delete process.env.MEX_HOME; Object.defineProperty(process.stdout, "isTTY", { value: originalIsTTY, configurable: true }); const fb = await import("../src/feedback/index.js"); fb.__setOpener(null); diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts index 1085ef9..fe3f2eb 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry.test.ts @@ -3,14 +3,15 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync import { join } from "node:path"; import { tmpdir } from "node:os"; -// We test via the module's public exports. Each test gets a fresh $HOME -// so the global config / telemetry-id files are isolated. +// We test via the module's public exports. Each test gets a fresh MEX_HOME +// so the global config / telemetry-id files are isolated. We use MEX_HOME (not +// $HOME) because Node's homedir() ignores $HOME on Windows — see mexHomeDir(). // // Important: tests run from the mex repo, so isDevRepo() returns true by // default. Tests that need telemetry ENABLED must chdir to a temp dir that // has no mex-agent package.json. -let originalHome: string | undefined; +let originalMexHome: string | undefined; let originalDoNotTrack: string | undefined; let originalMexTelemetry: string | undefined; let originalMexDev: string | undefined; @@ -19,7 +20,7 @@ let tempHome: string; function setTempHome(): string { tempHome = mkdtempSync(join(tmpdir(), "mex-tel-")); - process.env.HOME = tempHome; + process.env.MEX_HOME = tempHome; return tempHome; } @@ -34,7 +35,7 @@ function restoreCwd(): void { } beforeEach(() => { - originalHome = process.env.HOME; + originalMexHome = process.env.MEX_HOME; originalDoNotTrack = process.env.DO_NOT_TRACK; originalMexTelemetry = process.env.MEX_TELEMETRY; originalMexDev = process.env.MEX_DEV; @@ -47,7 +48,8 @@ beforeEach(() => { afterEach(async () => { restoreCwd(); - process.env.HOME = originalHome; + if (originalMexHome !== undefined) process.env.MEX_HOME = originalMexHome; + else delete process.env.MEX_HOME; if (originalDoNotTrack !== undefined) process.env.DO_NOT_TRACK = originalDoNotTrack; else delete process.env.DO_NOT_TRACK; if (originalMexTelemetry !== undefined) process.env.MEX_TELEMETRY = originalMexTelemetry; @@ -193,9 +195,14 @@ describe("machine_id (AC5)", () => { const filePath = join(tempHome, ".mex", "telemetry-id"); expect(existsSync(filePath)).toBe(true); - const stat = statSync(filePath); - // 0o600 = owner read+write only - expect(stat.mode & 0o777).toBe(0o600); + // POSIX mode bits aren't enforced on Windows (NTFS ignores the `mode` + // option and statSync reports 0o666), so only assert owner-only perms + // where the OS actually honors them. + if (process.platform !== "win32") { + const stat = statSync(filePath); + // 0o600 = owner read+write only + expect(stat.mode & 0o777).toBe(0o600); + } }); it("returns the same id on subsequent calls", async () => { @@ -248,6 +255,8 @@ describe("dev-repo guard (AC7)", () => { const { isDevRepo } = await import("../src/global-config.js"); expect(isDevRepo()).toBe(true); } finally { + // Windows can't remove the directory while it's still the cwd. + restoreCwd(); rmSync(fakeRepo, { recursive: true, force: true }); } }); @@ -264,6 +273,8 @@ describe("dev-repo guard (AC7)", () => { const { isDevRepo } = await import("../src/global-config.js"); expect(isDevRepo()).toBe(false); } finally { + // Windows can't remove the directory while it's still the cwd. + restoreCwd(); rmSync(fakeRepo, { recursive: true, force: true }); } });