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
3 changes: 2 additions & 1 deletion src/drift/checkers/broken-link.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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");
Expand Down
3 changes: 2 additions & 1 deletion src/drift/checkers/todo-fixme.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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");
Expand Down
5 changes: 3 additions & 2 deletions src/drift/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions src/events.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 11 additions & 2 deletions src/global-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
3 changes: 2 additions & 1 deletion src/heartbeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Expand Down
15 changes: 15 additions & 0 deletions src/paths.ts
Original file line number Diff line number Diff line change
@@ -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("/");
}
4 changes: 3 additions & 1 deletion src/scanner/entry-points.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { globSync } from "glob";
import { toPosix } from "../paths.js";
import type { EntryPoint } from "../types.js";

const MAIN_PATTERNS = [
Expand Down Expand Up @@ -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 });
Expand Down
14 changes: 8 additions & 6 deletions test/feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand Down
29 changes: 20 additions & 9 deletions test/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 });
}
});
Expand All @@ -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 });
}
});
Expand Down
Loading