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
5 changes: 5 additions & 0 deletions .changeset/add-forward-slash-path-utility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"counterfact": patch
---

Add `toForwardSlashPath` utility function and `ForwardSlashPath` branded type. All path normalization that previously used inline `.replaceAll("\\", "/")` now goes through this single, centralized function, making Windows path handling easier to find and reason about.
12 changes: 10 additions & 2 deletions bin/counterfact.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,12 @@
// helper.ts is imported via .js extension — the TypeScript convention used
// throughout this codebase. If the runtime resolves helper.js → helper.ts,
// it is fully capable of running the TypeScript source tree.
fs.writeFileSync(

Check warning on line 116 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found writeFileSync from package "node:fs" with non literal argument at index 0
nodePath.join(dir, "helper.ts"),
'export const value: string = "ok";\n',
"utf8",
);
fs.writeFileSync(

Check warning on line 121 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found writeFileSync from package "node:fs" with non literal argument at index 0
nodePath.join(dir, "main.ts"),
'import { value } from "./helper.js"; export default value;\n',
"utf8",
Expand Down Expand Up @@ -162,6 +162,14 @@
)
);

const { pathResolve } = await import(
resolve(
nativeTs
? "../src/util/forward-slash-path.js"
: "../dist/util/forward-slash-path.js",
)
);

const DEFAULT_PORT = 3100;

const debug = createDebug("counterfact:bin:counterfact");
Expand Down Expand Up @@ -293,7 +301,7 @@
const optionSource = program.getOptionValueSource(key);

if (optionSource !== "cli") {
options[key] = value;

Check warning on line 304 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-object-injection] Generic Object Injection Sink
}
}

Expand All @@ -314,9 +322,9 @@
source = options.spec;
}

const destinationPath = nodePath.resolve(destination).replaceAll("\\", "/");
const destinationPath = pathResolve(destination);

const basePath = nodePath.resolve(destinationPath).replaceAll("\\", "/");
const basePath = pathResolve(destinationPath);

// If no action-related option is provided, default to all options

Expand All @@ -327,7 +335,7 @@
)
) {
for (const action of actions) {
options[action] = true;

Check warning on line 338 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-object-injection] Generic Object Injection Sink
}
}

Expand Down Expand Up @@ -403,15 +411,15 @@
let didMigrate = false;
let didMigrateRouteTypes;

if (fs.existsSync(nodePath.join(config.basePath, "paths"))) {

Check warning on line 414 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found existsSync from package "node:fs" with non literal argument at index 0
await pathsToRoutes(config.basePath);
await fs.promises.rmdir(nodePath.join(config.basePath, "paths"), {

Check warning on line 416 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found rmdir from package "node:fs" with non literal argument at index 0
recursive: true,
});
await fs.promises.rmdir(nodePath.join(config.basePath, "path-types"), {

Check warning on line 419 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found rmdir from package "node:fs" with non literal argument at index 0
recursive: true,
});
await fs.promises.rmdir(nodePath.join(config.basePath, "components"), {

Check warning on line 422 in bin/counterfact.js

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-non-literal-fs-filename] Found rmdir from package "node:fs" with non literal argument at index 0
recursive: true,
});

Expand Down
17 changes: 8 additions & 9 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import fs, { rm } from "node:fs/promises";
import nodePath from "node:path";

import { createHttpTerminator, type HttpTerminator } from "http-terminator";

Expand All @@ -19,6 +18,7 @@ import { Transpiler } from "./server/transpiler.js";
import { CodeGenerator } from "./typescript-generator/code-generator.js";
import { writeScenarioContextType } from "./typescript-generator/generate.js";
import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable-ts.js";
import { pathJoin } from "./util/forward-slash-path.js";

export { loadOpenApiDocument } from "./server/load-openapi-document.js";

Expand Down Expand Up @@ -113,9 +113,7 @@ export async function createMswHandlers(
await fs.readFile(config.openApiPath);
const openApiDocument = await loadOpenApiDocument(config.openApiPath);
const modulesPath = config.basePath;
const compiledPathsDirectory = nodePath
.join(modulesPath, ".cache")
.replaceAll("\\", "/");
const compiledPathsDirectory = pathJoin(modulesPath, ".cache");

const registry = new Registry();
const contextRegistry = new ContextRegistry();
Expand Down Expand Up @@ -175,9 +173,10 @@ export async function counterfact(config: Config) {

const nativeTs = await runtimeCanExecuteErasableTs();

const compiledPathsDirectory = nodePath
.join(modulesPath, nativeTs ? "routes" : ".cache")
.replaceAll("\\", "/");
const compiledPathsDirectory = pathJoin(
modulesPath,
nativeTs ? "routes" : ".cache",
);

if (!nativeTs) {
await rm(compiledPathsDirectory, { force: true, recursive: true });
Expand Down Expand Up @@ -208,7 +207,7 @@ export async function counterfact(config: Config) {
);

const transpiler = new Transpiler(
nodePath.join(modulesPath, "routes").replaceAll("\\", "/"),
pathJoin(modulesPath, "routes"),
compiledPathsDirectory,
"commonjs",
);
Expand All @@ -217,7 +216,7 @@ export async function counterfact(config: Config) {
compiledPathsDirectory,
registry,
contextRegistry,
nodePath.join(modulesPath, "scenarios").replaceAll("\\", "/"),
pathJoin(modulesPath, "scenarios"),
scenarioRegistry,
);

Expand Down
5 changes: 2 additions & 3 deletions src/migrate/update-route-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from "node:path";

import createDebug from "debug";

import { toForwardSlashPath } from "../util/forward-slash-path.js";
import {
OperationTypeCoder,
type SecurityScheme,
Expand Down Expand Up @@ -271,9 +272,7 @@ async function processRouteDirectory(
);
} else if (entry.name.endsWith(".ts") && entry.name !== "_.context.ts") {
// Process TypeScript route files (skip context files)
const routePath = relativePath
.replace(/\.ts$/, "")
.replaceAll("\\", "/");
const routePath = toForwardSlashPath(relativePath.replace(/\.ts$/, ""));
const methodMap = mapping.get(routePath);

if (methodMap) {
Expand Down
16 changes: 5 additions & 11 deletions src/server/file-discovery.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import nodePath from "node:path";

import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
import { escapePathForWindows } from "../util/windows-escape.js";

const JS_EXTENSIONS = new Set(["cjs", "cts", "js", "mjs", "mts", "ts"]);
Expand All @@ -17,7 +17,7 @@ export class FileDiscovery {
private readonly basePath: string;

public constructor(basePath: string) {
this.basePath = basePath.replaceAll("\\", "/");
this.basePath = toForwardSlashPath(basePath);
}

/**
Expand All @@ -29,9 +29,7 @@ export class FileDiscovery {
* @throws When `basePath/directory` does not exist.
*/
public async findFiles(directory = ""): Promise<string[]> {
const fullDir = nodePath
.join(this.basePath, directory)
.replaceAll("\\", "/");
const fullDir = pathJoin(this.basePath, directory);

if (!existsSync(fullDir)) {
throw new Error(`Directory does not exist ${fullDir}`);
Expand All @@ -42,9 +40,7 @@ export class FileDiscovery {
const results = await Promise.all(
entries.map(async (entry) => {
if (entry.isDirectory()) {
return this.findFiles(
nodePath.join(directory, entry.name).replaceAll("\\", "/"),
);
return this.findFiles(pathJoin(directory, entry.name));
}

const extension = entry.name.split(".").at(-1);
Expand All @@ -53,9 +49,7 @@ export class FileDiscovery {
return [];
}

const fullPath = nodePath
.join(this.basePath, directory, entry.name)
.replaceAll("\\", "/");
const fullPath = pathJoin(this.basePath, directory, entry.name);

return [escapePathForWindows(fullPath)];
}),
Expand Down
61 changes: 34 additions & 27 deletions src/server/module-loader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { once } from "node:events";
import fs from "node:fs/promises";
import nodePath, { basename, dirname } from "node:path";
import nodePath, { basename } from "node:path";

import { type FSWatcher, watch } from "chokidar";
import createDebug from "debug";
Expand All @@ -18,6 +18,11 @@ import { ModuleDependencyGraph } from "./module-dependency-graph.js";
import type { Module, Registry } from "./registry.js";
import { ScenarioRegistry } from "./scenario-registry.js";
import { uncachedImport } from "./uncached-import.js";
import {
toForwardSlashPath,
pathDirname,
pathRelative,
} from "../util/forward-slash-path.js";
import { unescapePathForWindows } from "../util/windows-escape.js";

const { uncachedRequire } = await import("./uncached-require.cjs");
Expand Down Expand Up @@ -69,10 +74,13 @@ export class ModuleLoader extends EventTarget {
scenarioRegistry?: ScenarioRegistry,
) {
super();
this.basePath = basePath.replaceAll("\\", "/");
this.basePath = toForwardSlashPath(basePath);
this.registry = registry;
this.contextRegistry = contextRegistry;
this.scenariosPath = scenariosPath?.replaceAll("\\", "/");
this.scenariosPath =
scenariosPath === undefined
? undefined
: toForwardSlashPath(scenariosPath);
this.scenarioRegistry = scenarioRegistry;
this.fileDiscovery = new FileDiscovery(this.basePath);
}
Expand All @@ -98,7 +106,7 @@ export class ModuleLoader extends EventTarget {
)
return;

const pathName = pathNameOriginal.replaceAll("\\", "/");
const pathName = toForwardSlashPath(pathNameOriginal);

if (pathName.includes("$.context") && eventName === "add") {
process.stdout.write(
Expand All @@ -114,17 +122,18 @@ export class ModuleLoader extends EventTarget {

const parts = nodePath.parse(pathName.replace(this.basePath, ""));
const url = unescapePathForWindows(
`/${parts.dir}/${parts.name}`
.replaceAll("\\", "/")
.replaceAll(/\/+/gu, "/"),
toForwardSlashPath(`/${parts.dir}/${parts.name}`).replaceAll(
/\/+/gu,
"/",
),
);

if (eventName === "unlink") {
this.registry.remove(url);
this.dispatchEvent(new Event("remove"));
if (this.isContextFile(pathName)) {
this.contextRegistry.remove(
unescapePathForWindows(parts.dir).replaceAll("\\", "/") || "/",
unescapePathForWindows(toForwardSlashPath(parts.dir)) || "/",
);
}
return;
Expand Down Expand Up @@ -155,7 +164,7 @@ export class ModuleLoader extends EventTarget {

if (!["add", "change", "unlink"].includes(eventName)) return;

const pathName = pathNameOriginal.replaceAll("\\", "/");
const pathName = toForwardSlashPath(pathNameOriginal);

if (eventName === "unlink") {
const fileKey = this.scenarioFileKey(pathName);
Expand Down Expand Up @@ -215,18 +224,18 @@ export class ModuleLoader extends EventTarget {
}

private scenarioFileKey(pathName: string): string {
const normalizedScenariosPath = (this.scenariosPath ?? "").replaceAll(
"\\",
"/",
const normalizedScenariosPath = toForwardSlashPath(
this.scenariosPath ?? "",
);
const directory = dirname(
const directory = pathDirname(
pathName.slice(normalizedScenariosPath.length),
).replaceAll("\\", "/");
);
const name = nodePath.parse(basename(pathName)).name;
const url = unescapePathForWindows(
`/${nodePath.join(directory, name)}`
.replaceAll("\\", "/")
.replaceAll(/\/+/gu, "/"),
toForwardSlashPath(`/${nodePath.join(directory, name)}`).replaceAll(
/\/+/gu,
"/",
),
);

return url.slice(1); // strip leading "/"
Expand Down Expand Up @@ -258,15 +267,12 @@ export class ModuleLoader extends EventTarget {
private async loadEndpoint(pathName: string) {
debug("importing module: %s", pathName);

const directory = dirname(pathName.slice(this.basePath.length)).replaceAll(
"\\",
"/",
);
const directory = pathDirname(pathName.slice(this.basePath.length));

const url = unescapePathForWindows(
`/${nodePath.join(directory, nodePath.parse(basename(pathName)).name)}`
.replaceAll("\\", "/")
.replaceAll(/\/+/gu, "/"),
toForwardSlashPath(
`/${nodePath.join(directory, nodePath.parse(basename(pathName)).name)}`,
).replaceAll(/\/+/gu, "/"),
);

debug(`loading pathName from dependencyGraph: ${pathName}`);
Expand All @@ -290,9 +296,10 @@ export class ModuleLoader extends EventTarget {
importError instanceof SyntaxError ||
String(importError).startsWith("SyntaxError:");

const displayPath = nodePath
.relative(process.cwd(), unescapePathForWindows(pathName))
.replaceAll("\\", "/");
const displayPath = pathRelative(
process.cwd(),
unescapePathForWindows(pathName),
);

const message = isSyntaxError
? `There is a syntax error in the route file: ${displayPath}`
Expand Down
25 changes: 12 additions & 13 deletions src/server/transpiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

import { once } from "node:events";
import fs from "node:fs/promises";
import nodePath from "node:path";

import { type FSWatcher, watch as chokidarWatch } from "chokidar";
import createDebug from "debug";
import ts from "typescript";

import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
import { CHOKIDAR_OPTIONS } from "./constants.js";
import { convertFileExtensionsToCjs } from "./convert-js-extensions-to-cjs.js";

Expand Down Expand Up @@ -79,12 +79,13 @@ export class Transpiler extends EventTarget {
)
return;

const sourcePath = sourcePathOriginal.replaceAll("\\", "/");
const sourcePath = toForwardSlashPath(sourcePathOriginal);

const destinationPath = sourcePath
.replace(this.sourcePath, this.destinationPath)
.replaceAll("\\", "/")
.replace(".ts", this.extension);
const destinationPath = toForwardSlashPath(
sourcePath
.replace(this.sourcePath, this.destinationPath)
.replace(".ts", this.extension),
);

if (["add", "change"].includes(eventName)) {
transpiles.push(
Expand Down Expand Up @@ -152,13 +153,11 @@ export class Transpiler extends EventTarget {

const result: string = transpileOutput.outputText;

const fullDestination = nodePath
.join(
sourcePath
.replace(this.sourcePath, this.destinationPath)
.replace(".ts", this.extension),
)
.replaceAll("\\", "/");
const fullDestination = pathJoin(
sourcePath
.replace(this.sourcePath, this.destinationPath)
.replace(".ts", this.extension),
);

const resultWithTransformedFileExtensions =
convertFileExtensionsToCjs(result);
Expand Down
5 changes: 2 additions & 3 deletions src/typescript-generator/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import nodePath from "node:path";
import createDebug from "debug";

import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
import { pathRelative } from "../util/forward-slash-path.js";
import { OperationCoder } from "./operation-coder.js";
import { type SecurityScheme } from "./operation-type-coder.js";
import { pruneRoutes } from "./prune.js";
Expand Down Expand Up @@ -210,9 +211,7 @@ async function walkForContextFiles(
results,
);
} else if (entry.name === "_.context.ts") {
const relDir = nodePath
.relative(routesDir, currentDir)
.replaceAll("\\", "/");
const relDir = pathRelative(routesDir, currentDir);
const routePath = relDir === "" ? "/" : `/${relDir}`;
const depth = relDir === "" ? 0 : relDir.split("/").length;
const importPath =
Expand Down
7 changes: 2 additions & 5 deletions src/typescript-generator/operation-coder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import nodePath from "node:path";

import { pathJoin } from "../util/forward-slash-path.js";
import { Coder } from "./coder.js";
import {
OperationTypeCoder,
Expand Down Expand Up @@ -88,8 +87,6 @@ export class OperationCoder extends Coder {
.at(-2)!
.replaceAll("~1", "/");

return `${nodePath
.join("routes", pathString)
.replaceAll("\\", "/")}.types.ts`;
return `${pathJoin("routes", pathString)}.types.ts`;
}
}
Loading
Loading