test: Add canonicalizePath() utility using fs.realpathSync() with symli…

SF-Task: S01/T02
This commit is contained in:
Mikael Hugo 2026-04-30 22:42:08 +02:00
parent 8418e88730
commit 51202225ec
4 changed files with 76 additions and 13 deletions

View file

@ -3,6 +3,7 @@ import { homedir } from "os";
import { basename, isAbsolute, join, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js";
import { canonicalizePath } from "./tools/path-utils.js";
/**
* Represents a prompt template loaded from a markdown file
@ -253,6 +254,7 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P
};
// 3. Load explicit prompt paths
const seenPaths = new Set<string>();
for (const rawPath of promptPaths) {
const resolvedPath = resolvePromptPath(rawPath, resolvedCwd);
if (!existsSync(resolvedPath)) {
@ -263,11 +265,22 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P
const stats = statSync(resolvedPath);
const { source, label } = getSourceInfo(resolvedPath);
if (stats.isDirectory()) {
templates.push(...loadTemplatesFromDir(resolvedPath, source, label));
const dirTemplates = loadTemplatesFromDir(resolvedPath, source, label);
for (const template of dirTemplates) {
const realPath = canonicalizePath(template.filePath);
if (!seenPaths.has(realPath)) {
seenPaths.add(realPath);
templates.push(template);
}
}
} else if (stats.isFile() && resolvedPath.endsWith(".md")) {
const template = loadTemplateFromFile(resolvedPath, source, label);
if (template) {
templates.push(template);
const realPath = canonicalizePath(resolvedPath);
if (!seenPaths.has(realPath)) {
seenPaths.add(realPath);
const template = loadTemplateFromFile(resolvedPath, source, label);
if (template) {
templates.push(template);
}
}
}
} catch {

View file

@ -1,9 +1,10 @@
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import ignore from "ignore";
import { homedir } from "os";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
import { parseFrontmatter } from "../utils/frontmatter.js";
import { toPosixPath } from "../utils/path-display.js";
import { canonicalizePath } from "./tools/path-utils.js";
import type { ResourceDiagnostic } from "./diagnostics.js";
import { CONFIG_DIR_NAME } from "../config.js";
@ -386,12 +387,7 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
allDiagnostics.push(...result.diagnostics);
for (const skill of result.skills) {
// Resolve symlinks to detect duplicate files
let realPath: string;
try {
realPath = realpathSync(skill.filePath);
} catch {
realPath = skill.filePath;
}
const realPath = canonicalizePath(skill.filePath);
// Skip silently if we've already loaded this exact file (via symlink)
if (realPathSet.has(realPath)) {

View file

@ -1,6 +1,42 @@
import { describe, it, mock, afterEach } from "node:test";
import assert from "node:assert/strict";
import { resolveToCwd, expandPath } from "./path-utils.js";
import { mkdtempSync, writeFileSync, symlinkSync, unlinkSync, rmdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveToCwd, expandPath, canonicalizePath } from "./path-utils.js";
describe("canonicalizePath", () => {
it("returns realpath for existing files", () => {
const result = canonicalizePath("./package.json");
// Should resolve to absolute path
assert.ok(result.startsWith("/"), "should be absolute path");
assert.ok(result.endsWith("package.json"), "should end with package.json");
});
it("falls back to input path when file does not exist", () => {
const nonExistent = "/tmp/non-existent-file-12345.txt";
const result = canonicalizePath(nonExistent);
assert.equal(result, nonExistent);
});
it("resolves symlinks to their target", () => {
const tmpDir = mkdtempSync(join(tmpdir(), "canonicalize-test-"));
const realFile = join(tmpDir, "real.txt");
const symlink = join(tmpDir, "link.txt");
writeFileSync(realFile, "hello");
symlinkSync(realFile, symlink);
try {
const result = canonicalizePath(symlink);
assert.equal(result, realFile, "symlink should resolve to real file path");
} finally {
unlinkSync(symlink);
unlinkSync(realFile);
rmdirSync(tmpDir);
}
});
});
describe("resolveToCwd", () => {
it("resolves relative paths against cwd", () => {

View file

@ -1,4 +1,4 @@
import { accessSync, constants } from "node:fs";
import { accessSync, constants, realpathSync } from "node:fs";
import * as os from "node:os";
import { isAbsolute, resolve as resolvePath } from "node:path";
@ -71,6 +71,24 @@ export function resolveToCwd(filePath: string, cwd: string): string {
return resolvePath(cwd, expanded);
}
/**
* Resolve symlinks to their canonical absolute path.
* Returns the original path if realpathSync fails (e.g., path does not exist).
*
* Purpose: deduplicate resources that are reachable via multiple symlinked
* paths two different paths may resolve to the same underlying file.
*
* Consumer: resource loaders (skills, prompts, themes) that merge paths
* from multiple sources and must avoid loading the same file twice.
*/
export function canonicalizePath(filePath: string): string {
try {
return realpathSync(filePath);
} catch {
return filePath;
}
}
export function resolveReadPath(filePath: string, cwd: string): string {
const resolved = resolveToCwd(filePath, cwd);