From 51202225ec293faa210ffec3a6eb51b6057c1ef3 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 30 Apr 2026 22:42:08 +0200 Subject: [PATCH] =?UTF-8?q?test:=20Add=20canonicalizePath()=20utility=20us?= =?UTF-8?q?ing=20fs.realpathSync()=20with=20symli=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SF-Task: S01/T02 --- .../src/core/prompt-templates.ts | 21 ++++++++-- packages/pi-coding-agent/src/core/skills.ts | 10 ++--- .../src/core/tools/path-utils.test.ts | 38 ++++++++++++++++++- .../src/core/tools/path-utils.ts | 20 +++++++++- 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/packages/pi-coding-agent/src/core/prompt-templates.ts b/packages/pi-coding-agent/src/core/prompt-templates.ts index 6f7ab64c6..490114463 100644 --- a/packages/pi-coding-agent/src/core/prompt-templates.ts +++ b/packages/pi-coding-agent/src/core/prompt-templates.ts @@ -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(); 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 { diff --git a/packages/pi-coding-agent/src/core/skills.ts b/packages/pi-coding-agent/src/core/skills.ts index ae023d34c..e65102d00 100644 --- a/packages/pi-coding-agent/src/core/skills.ts +++ b/packages/pi-coding-agent/src/core/skills.ts @@ -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)) { diff --git a/packages/pi-coding-agent/src/core/tools/path-utils.test.ts b/packages/pi-coding-agent/src/core/tools/path-utils.test.ts index 93e1dba60..78c3a30ab 100644 --- a/packages/pi-coding-agent/src/core/tools/path-utils.test.ts +++ b/packages/pi-coding-agent/src/core/tools/path-utils.test.ts @@ -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", () => { diff --git a/packages/pi-coding-agent/src/core/tools/path-utils.ts b/packages/pi-coding-agent/src/core/tools/path-utils.ts index 8c01d3ad2..f87261782 100644 --- a/packages/pi-coding-agent/src/core/tools/path-utils.ts +++ b/packages/pi-coding-agent/src/core/tools/path-utils.ts @@ -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);