From e47e6cbf11c5a4de38e82ec6cd4a21c17992b7b4 Mon Sep 17 00:00:00 2001 From: kevin-lozada-santos Date: Thu, 17 Sep 2026 16:04:13 -0400 Subject: [PATCH] fix(cli): canonicalize architecture comparison paths on Windows --- .../catches-that-swallow.arch.test.ts | 4 +- cli/tests/architecture/comments.arch.test.ts | 4 +- cli/tests/architecture/helpers.ts | 19 +++++--- .../no-shared-binary.arch.test.ts | 3 +- .../paths-select-source.arch.test.ts | 47 +++++++++++++++++++ .../ports-are-called.arch.test.ts | 8 ++-- 6 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 cli/tests/architecture/paths-select-source.arch.test.ts diff --git a/cli/tests/architecture/catches-that-swallow.arch.test.ts b/cli/tests/architecture/catches-that-swallow.arch.test.ts index 6ea935361..c47484b03 100644 --- a/cli/tests/architecture/catches-that-swallow.arch.test.ts +++ b/cli/tests/architecture/catches-that-swallow.arch.test.ts @@ -5,7 +5,7 @@ import { readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; -import { CLI_ROOT, read, sourceFiles } from "./helpers.js"; +import { CLI_ROOT, canonicalPath, read, sourceFiles } from "./helpers.js"; /** File → how many empty catches it keeps, and why each is a deliberate best effort. */ const BASELINE: Readonly> = { @@ -78,7 +78,7 @@ function testFiles(): string[] { const full = join(dir, entry); if (statSync(full).isDirectory()) { if (entry !== "fixtures" && entry !== "snapshots") walk(full); - } else if (entry.endsWith(".ts")) out.push(relative(CLI_ROOT, full)); + } else if (entry.endsWith(".ts")) out.push(canonicalPath(relative(CLI_ROOT, full))); } }; walk(join(CLI_ROOT, "tests")); diff --git a/cli/tests/architecture/comments.arch.test.ts b/cli/tests/architecture/comments.arch.test.ts index 88fab05fc..71d05f747 100644 --- a/cli/tests/architecture/comments.arch.test.ts +++ b/cli/tests/architecture/comments.arch.test.ts @@ -6,7 +6,7 @@ import { readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; -import { CLI_ROOT, read, sourceFiles } from "./helpers.js"; +import { CLI_ROOT, canonicalPath, read, sourceFiles } from "./helpers.js"; const COMMENT_LINE = /^\s*(\/\/|\/\*|\*)/; const DIRECTIVE = /biome-ignore|@ts-expect-error|eslint-disable/; @@ -31,7 +31,7 @@ function testFiles(): string[] { if (statSync(full).isDirectory()) { if (entry === "fixtures" || entry === "snapshots") continue; walk(full); - } else if (entry.endsWith(".ts")) out.push(relative(CLI_ROOT, full)); + } else if (entry.endsWith(".ts")) out.push(canonicalPath(relative(CLI_ROOT, full))); } }; walk(join(CLI_ROOT, "tests")); diff --git a/cli/tests/architecture/helpers.ts b/cli/tests/architecture/helpers.ts index de74b7c03..12e3fbde0 100644 --- a/cli/tests/architecture/helpers.ts +++ b/cli/tests/architecture/helpers.ts @@ -3,20 +3,24 @@ * enough for a pre-commit hook and cannot be broken by runtime wiring. */ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { dirname, join, normalize, relative, resolve } from "node:path"; +import { join, posix, relative, resolve } from "node:path"; export const CLI_ROOT = resolve(import.meta.dirname, "..", ".."); export const SRC = join(CLI_ROOT, "src"); export const REPO_ROOT = resolve(CLI_ROOT, ".."); +export function canonicalPath(path: string): string { + return path.replace(/\\/g, "/"); +} + export function sourceFiles(): string[] { const out: string[] = []; const walk = (dir: string): void => { for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) walk(full); - else if (entry.endsWith(".ts")) out.push(relative(CLI_ROOT, full)); + else if (entry.endsWith(".ts")) out.push(canonicalPath(relative(CLI_ROOT, full))); } }; walk(SRC); @@ -37,7 +41,7 @@ export function pluginReadmes(): string[] { for (const entry of readdirSync(pluginsDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const readme = join(pluginsDir, entry.name, "README.md"); - if (existsSync(readme)) found.push(join("plugins", entry.name, "README.md")); + if (existsSync(readme)) found.push(canonicalPath(join("plugins", entry.name, "README.md"))); } if (found.length === 0) { throw new Error("no plugin README found — the scope of this rule is stale"); @@ -53,11 +57,12 @@ export function pluginReadmes(): string[] { */ export const INTERNAL_IMPORT = /(?:from|import)\s*\(?\s*["'](\.[^"']+|@\/[^"']+)["']/g; -function resolveImportTarget(file: string, specifier: string): string { +export function resolveImportTarget(file: string, specifier: string): string { + const target = canonicalPath(specifier); return ( - specifier.startsWith("@/") - ? `src/${specifier.slice(2)}` - : normalize(join(dirname(file), specifier)) + target.startsWith("@/") + ? `src/${target.slice(2)}` + : posix.join(posix.dirname(canonicalPath(file)), target) ).replace(/\.js$/, ".ts"); } diff --git a/cli/tests/architecture/no-shared-binary.arch.test.ts b/cli/tests/architecture/no-shared-binary.arch.test.ts index caae270cc..f2e650c1c 100644 --- a/cli/tests/architecture/no-shared-binary.arch.test.ts +++ b/cli/tests/architecture/no-shared-binary.arch.test.ts @@ -7,6 +7,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { canonicalPath } from "./helpers.js"; const CLI_ROOT = resolve(import.meta.dirname, "..", ".."); const TESTS_ROOT = join(CLI_ROOT, "tests"); @@ -31,7 +32,7 @@ describe("no test resolves a path into the shared dist/ build output", () => { it("every file under tests/ reads the e2e run's own binary, not dist/cli.js", () => { const violations = testFiles() .filter((file) => CWD_INTO_DIST.test(readFileSync(file, "utf8"))) - .map((file) => relative(CLI_ROOT, file)); + .map((file) => canonicalPath(relative(CLI_ROOT, file))); expect( violations, diff --git a/cli/tests/architecture/paths-select-source.arch.test.ts b/cli/tests/architecture/paths-select-source.arch.test.ts new file mode 100644 index 000000000..612c862cc --- /dev/null +++ b/cli/tests/architecture/paths-select-source.arch.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalPath, + contextOf, + importersByFile, + matchesGlob, + pluginReadmes, + resolveImportTarget, + sourceFiles, +} from "./helpers.js"; + +describe("architecture paths select the same files on every platform", () => { + it.each([ + ["src\\contexts\\tools\\domain\\tool.ts", "tools", "src/contexts/**/*.ts"], + ["src\\kernel\\errors.ts", "kernel", "src/kernel/**/*.ts"], + ["src\\presentation\\commands\\setup.ts", "presentation", "src/presentation/commands/**"], + ])("selects the explicit backslash path %s", (nativePath, context, glob) => { + const file = canonicalPath(nativePath); + expect(contextOf(file)).toBe(context); + expect(matchesGlob(glob, file)).toBe(true); + expect(matchesGlob("src/deleted-context/**", file)).toBe(false); + }); + + it.each([ + ["src\\kernel\\ports\\file-reader.ts", "../file.js"], + ["src/kernel/ports/file-reader.ts", "..\\file.js"], + ["src\\runtime\\caller.ts", "@/kernel/file.js"], + ])("resolves %s importing %s to the canonical source key", (file, specifier) => { + const known = new Set(["src/kernel/file.ts"]); + expect(known.has(resolveImportTarget(file, specifier))).toBe(true); + }); + + it("returns canonical, nonempty source and plugin scopes", () => { + const files = sourceFiles(); + const readmes = pluginReadmes(); + expect(files.length).toBeGreaterThan(0); + expect(files.every((file) => file.startsWith("src/") && !file.includes("\\"))).toBe(true); + expect(readmes.length).toBeGreaterThan(0); + expect(readmes.every((file) => /^plugins\/[^/]+\/README\.md$/.test(file))).toBe(true); + }); + + it("keeps relative-import lookups connected to the source-file keys", () => { + expect(importersByFile().get("src/kernel/file.ts")).toContain( + "src/kernel/ports/file-reader.ts" + ); + }); +}); diff --git a/cli/tests/architecture/ports-are-called.arch.test.ts b/cli/tests/architecture/ports-are-called.arch.test.ts index c05f42afe..8b060e23a 100644 --- a/cli/tests/architecture/ports-are-called.arch.test.ts +++ b/cli/tests/architecture/ports-are-called.arch.test.ts @@ -5,9 +5,9 @@ * nothing spells the name as a call, never that a real path reaches it. */ import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative, sep } from "node:path"; +import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; -import { CLI_ROOT, expectRatchet, SRC, sourceFiles } from "./helpers.js"; +import { CLI_ROOT, canonicalPath, expectRatchet, SRC, sourceFiles } from "./helpers.js"; /** "ports" must be its own path segment: a substring check also matches `supports/` and * `reports/`. */ @@ -17,8 +17,8 @@ function portFiles(): string[] { for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) walk(full); - else if (entry.endsWith(".ts") && /(^|\/)ports\//.test(full.split(sep).join("/"))) { - out.push(relative(CLI_ROOT, full)); + else if (entry.endsWith(".ts") && /(^|\/)ports\//.test(canonicalPath(full))) { + out.push(canonicalPath(relative(CLI_ROOT, full))); } } };