FN-6615: prefer fresh plugin source over stale dist

Plugin entry resolution now avoids stale gitignored dist output in dev worktrees.

- Prefer bundled plugin entries when present, preserving published tarball behavior.
- Choose src/index.ts over dist/index.js when source files are newer than dist output.
- Cover CLI and core plugin loaders with freshness and sync tests.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-6615-prefer-fresh-src.md             |  5 ++
 .../__tests__/bundled-plugin-install.test.ts       | 46 ++++++++---
 .../resolve-plugin-entry-path-sync.test.ts         | 50 ++++++++++--
 packages/cli/src/plugins/bundled-plugin-install.ts | 93 ++++++++++++++++++----
 packages/core/src/__tests__/plugin-loader.test.ts  | 71 ++++++++++++++++-
 packages/core/src/plugin-loader.ts                 | 93 ++++++++++++++++++----
 6 files changed, 313 insertions(+), 45 deletions(-)

Fusion-Task-Id: FN-6615

Fusion-Task-Lineage: 3d9abfa8-a405-47fa-8330-451c26a0ee75
This commit is contained in:
gsxdsm
2026-06-17 19:46:47 -07:00
parent f150a176b4
commit 4c3186d0d1
6 changed files with 313 additions and 45 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Prefer fresher TypeScript plugin source over stale gitignored dist output in dev/worktree plugin resolution when no `bundled.js` exists. Production bundled installs remain unaffected because `bundled.js` still always wins.

View File

@@ -3,17 +3,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// ── Mocks ──────────────────────────────────────────────────────────── // ── Mocks ────────────────────────────────────────────────────────────
// vi.mock factories are hoisted, so we use vi.hoisted() for mock references. // vi.mock factories are hoisted, so we use vi.hoisted() for mock references.
const { mockExistsSync, mockStatSync, mockReadFile, mockFsStat, mockCopyFile, mockValidatePluginManifest } = vi.hoisted(() => ({ const { mockExistsSync, mockReaddirSync, mockStatSync, mockReadFile, mockFsStat, mockCopyFile, mockValidatePluginManifest } =
mockExistsSync: vi.fn<(path: string) => boolean>(), vi.hoisted(() => ({
mockStatSync: vi.fn<(path: string) => { isDirectory: () => boolean }>(), mockExistsSync: vi.fn<(path: string) => boolean>(),
mockReadFile: vi.fn<(path: string, encoding: string) => Promise<string>>(), mockReaddirSync: vi.fn<
mockFsStat: vi.fn<(path: string) => Promise<{ isDirectory: () => boolean }>>(), (path: string, options: { withFileTypes: true; encoding: "utf8" }) => Array<{ name: string; isDirectory: () => boolean }>
mockCopyFile: vi.fn<(src: string, dest: string) => Promise<void>>(), >(),
mockValidatePluginManifest: vi.fn<(manifest: unknown) => { valid: boolean; errors: string[] }>(), mockStatSync: vi.fn<(path: string) => { isDirectory: () => boolean; mtimeMs?: number }>(),
})); mockReadFile: vi.fn<(path: string, encoding: string) => Promise<string>>(),
mockFsStat: vi.fn<(path: string) => Promise<{ isDirectory: () => boolean }>>(),
mockCopyFile: vi.fn<(src: string, dest: string) => Promise<void>>(),
mockValidatePluginManifest: vi.fn<(manifest: unknown) => { valid: boolean; errors: string[] }>(),
}));
vi.mock("node:fs", () => ({ vi.mock("node:fs", () => ({
existsSync: mockExistsSync, existsSync: mockExistsSync,
readdirSync: mockReaddirSync,
statSync: mockStatSync, statSync: mockStatSync,
})); }));
@@ -201,7 +206,8 @@ async function getResolvedBundledPath(): Promise<string> {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockStatSync.mockImplementation(() => ({ isDirectory: () => false })); mockReaddirSync.mockReturnValue([{ name: "index.ts", isDirectory: () => false }]);
mockStatSync.mockImplementation(() => ({ isDirectory: () => false, mtimeMs: 0 }));
mockFsStat.mockImplementation(async () => ({ isDirectory: () => false })); mockFsStat.mockImplementation(async () => ({ isDirectory: () => false }));
mockCopyFile.mockResolvedValue(); mockCopyFile.mockResolvedValue();
}); });
@@ -217,8 +223,27 @@ describe("resolvePluginEntryPath", () => {
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/bundled.js"); expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/bundled.js");
}); });
it("prefers dist/index.js when bundled.js is unavailable", () => { it("prefers src/index.ts when bundled.js is unavailable and src is newer than dist", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js")); mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"));
mockStatSync.mockImplementation((p: string) => ({
isDirectory: () => false,
mtimeMs: p.endsWith("/dist/index.js") ? 1 : 2,
}));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
});
it("prefers dist/index.js when bundled.js is unavailable and dist is newer", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"));
mockStatSync.mockImplementation((p: string) => ({
isDirectory: () => false,
mtimeMs: p.endsWith("/dist/index.js") ? 2 : 1,
}));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/dist/index.js");
});
it("prefers dist/index.js when bundled.js is unavailable and mtimes are equal", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"));
mockStatSync.mockImplementation(() => ({ isDirectory: () => false, mtimeMs: 1 }));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/dist/index.js"); expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/dist/index.js");
}); });
@@ -252,6 +277,7 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
})); }));
vi.doMock("node:fs", () => ({ vi.doMock("node:fs", () => ({
existsSync: mockExistsSync, existsSync: mockExistsSync,
readdirSync: mockReaddirSync,
statSync: mockStatSync, statSync: mockStatSync,
})); }));
vi.doMock("node:fs/promises", () => ({ vi.doMock("node:fs/promises", () => ({

View File

@@ -12,8 +12,8 @@
* seam that exercises both implementations equally. * seam that exercises both implementations equally.
*/ */
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
import { join } from "node:path"; import { dirname, join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { resolvePluginEntryPath as cliResolve } from "../bundled-plugin-install.js"; import { resolvePluginEntryPath as cliResolve } from "../bundled-plugin-install.js";
import { resolvePluginEntryPath as coreResolve } from "@fusion/core"; import { resolvePluginEntryPath as coreResolve } from "@fusion/core";
@@ -31,16 +31,53 @@ describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", ()
function touch(relative: string) { function touch(relative: string) {
const full = join(dir, relative); const full = join(dir, relative);
mkdirSync(join(full, ".."), { recursive: true }); mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, "// entry\n"); writeFileSync(full, "// entry\n");
} }
const layouts: Array<{ name: string; files: string[]; expected: string | null }> = [ const older = new Date("2026-01-01T00:00:00.000Z");
const newer = new Date("2026-01-01T00:01:00.000Z");
const layouts: Array<{
name: string;
files: string[];
expected: string | null;
mtimes?: Record<string, Date>;
}> = [
{ name: "bundled.js only", files: ["bundled.js"], expected: "bundled.js" }, { name: "bundled.js only", files: ["bundled.js"], expected: "bundled.js" },
{ name: "dist/index.js only", files: ["dist/index.js"], expected: "dist/index.js" }, { name: "dist/index.js only", files: ["dist/index.js"], expected: "dist/index.js" },
{ name: "src/index.ts only", files: ["src/index.ts"], expected: "src/index.ts" }, { name: "src/index.ts only", files: ["src/index.ts"], expected: "src/index.ts" },
{ name: "bundled.js preferred over src", files: ["bundled.js", "src/index.ts"], expected: "bundled.js" }, { name: "bundled.js preferred over src", files: ["bundled.js", "src/index.ts"], expected: "bundled.js" },
{ name: "dist preferred over src", files: ["dist/index.js", "src/index.ts"], expected: "dist/index.js" }, {
name: "dist + src, src newer → src/index.ts",
files: ["dist/index.js", "src/index.ts"],
expected: "src/index.ts",
mtimes: { "dist/index.js": older, "src/index.ts": newer },
},
{
name: "dist + src, dist newer → dist/index.js",
files: ["dist/index.js", "src/index.ts"],
expected: "dist/index.js",
mtimes: { "dist/index.js": newer, "src/index.ts": older },
},
{
name: "dist + src, equal mtimes → dist/index.js",
files: ["dist/index.js", "src/index.ts"],
expected: "dist/index.js",
mtimes: { "dist/index.js": older, "src/index.ts": older },
},
{
name: "dist + src, non-index src file newer → src/index.ts",
files: ["dist/index.js", "src/index.ts", "src/settings.ts"],
expected: "src/index.ts",
mtimes: { "dist/index.js": older, "src/index.ts": older, "src/settings.ts": newer },
},
{
name: "bundled.js + dist + src, src newer → bundled.js",
files: ["bundled.js", "dist/index.js", "src/index.ts"],
expected: "bundled.js",
mtimes: { "dist/index.js": older, "src/index.ts": newer },
},
{ name: "all three → bundled.js", files: ["bundled.js", "dist/index.js", "src/index.ts"], expected: "bundled.js" }, { name: "all three → bundled.js", files: ["bundled.js", "dist/index.js", "src/index.ts"], expected: "bundled.js" },
{ name: "no entry files", files: ["README.md"], expected: null }, { name: "no entry files", files: ["README.md"], expected: null },
]; ];
@@ -48,6 +85,9 @@ describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", ()
for (const layout of layouts) { for (const layout of layouts) {
it(`resolves identically for: ${layout.name}`, () => { it(`resolves identically for: ${layout.name}`, () => {
for (const f of layout.files) touch(f); for (const f of layout.files) touch(f);
for (const [file, mtime] of Object.entries(layout.mtimes ?? {})) {
utimesSync(join(dir, file), mtime, mtime);
}
const expected = layout.expected === null ? null : join(dir, layout.expected); const expected = layout.expected === null ? null : join(dir, layout.expected);
expect(cliResolve(dir)).toBe(expected); expect(cliResolve(dir)).toBe(expected);

View File

@@ -1,4 +1,4 @@
import { existsSync, statSync } from "node:fs"; import { existsSync, readdirSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -70,10 +70,17 @@ function resolveBundledPluginDir(pluginId: string): string | null {
/** /**
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM * Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
* does not allow directory imports, so we must register the explicit file the * does not allow directory imports, so we must register the explicit file the
* loader will dynamic-import. Preference order: * loader will dynamic-import. Resolution keeps ./bundled.js unconditional
* 1. ./bundled.js (esbuild-bundled, shipped in npm tarball) * because production npm tarballs ship that esbuild-bundled entry. In
* 2. ./dist/index.js (legacy prebuilt fallback) * dev/worktree contexts where no bundle exists, ./dist/index.js remains the
* 3. ./src/index.ts (workspace/dev fallback when no bundle exists) * prebuilt fallback unless any file under ./src/ is newer than dist/index.js;
* then ./src/index.ts wins so stale gitignored dist output cannot mask a source
* fix (FN-6615/FN-6596).
*
* FNXC:PluginLoader 2026-06-17-19:20:
* Prefer fresher src over stale dist only when bundled.js is absent. This keeps
* production tarballs on their bundled entry while preventing dev/worktree runs
* from silently loading old gitignored build output after a source fix.
* *
* Returns null when the directory exists but none of the loadable entry files * Returns null when the directory exists but none of the loadable entry files
* are present. Callers must treat that as a missing bundle rather than * are present. Callers must treat that as a missing bundle rather than
@@ -82,16 +89,74 @@ function resolveBundledPluginDir(pluginId: string): string | null {
* Keep in sync with resolvePluginEntryPath in @fusion/core (plugin-loader.ts), * Keep in sync with resolvePluginEntryPath in @fusion/core (plugin-loader.ts),
* which the dashboard install/enable routes use for the same contract. * which the dashboard install/enable routes use for the same contract.
*/ */
export function resolvePluginEntryPath(pluginDir: string): string | null { function newestSourceMtimeMs(srcDir: string): number | null {
const candidates = [ let newest = Number.NEGATIVE_INFINITY;
join(pluginDir, "bundled.js"),
join(pluginDir, "dist", "index.js"), function visit(dir: string): boolean {
join(pluginDir, "src", "index.ts"), const entries = (() => {
]; try {
for (const candidate of candidates) { return readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
if (existsSync(candidate)) { } catch {
return candidate; return null;
}
})();
if (!entries) return false;
for (const entry of entries) {
const entryPath = join(dir, entry.name);
let entryStat: ReturnType<typeof statSync>;
try {
entryStat = statSync(entryPath);
} catch {
return false;
}
if (entryStat.isDirectory()) {
if (!visit(entryPath)) return false;
continue;
}
if (entryStat.mtimeMs > newest) {
newest = entryStat.mtimeMs;
}
} }
return true;
}
return visit(srcDir) && newest !== Number.NEGATIVE_INFINITY ? newest : null;
}
function isSourceNewerThanDist(srcDir: string, distIndexPath: string): boolean {
try {
const distMtimeMs = statSync(distIndexPath).mtimeMs;
const srcMtimeMs = newestSourceMtimeMs(srcDir);
return srcMtimeMs !== null && srcMtimeMs > distMtimeMs;
} catch {
return false;
}
}
export function resolvePluginEntryPath(pluginDir: string): string | null {
const bundledPath = join(pluginDir, "bundled.js");
if (existsSync(bundledPath)) {
return bundledPath;
}
const distIndexPath = join(pluginDir, "dist", "index.js");
const srcDir = join(pluginDir, "src");
const srcIndexPath = join(srcDir, "index.ts");
const hasDist = existsSync(distIndexPath);
const hasSrc = existsSync(srcIndexPath);
if (hasDist && hasSrc) {
return isSourceNewerThanDist(srcDir, distIndexPath) ? srcIndexPath : distIndexPath;
}
if (hasDist) {
return distIndexPath;
}
if (hasSrc) {
return srcIndexPath;
} }
return null; return null;
} }

View File

@@ -2,9 +2,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { writeFile, mkdir } from "node:fs/promises"; import { writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { mkdtempSync, existsSync } from "node:fs"; import { mkdtempSync, existsSync, rmSync, utimesSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { PluginLoader } from "../plugin-loader.js"; import { PluginLoader, resolvePluginEntryPath } from "../plugin-loader.js";
import * as loggerModule from "../logger.js"; import * as loggerModule from "../logger.js";
const scanPluginSecurityMock = vi.fn(); const scanPluginSecurityMock = vi.fn();
@@ -126,6 +126,73 @@ function droidPluginModulePath(): string {
); );
} }
describe("resolvePluginEntryPath", () => {
let pluginDir: string;
beforeEach(() => {
pluginDir = makeTmpDir();
});
afterEach(() => {
rmSync(pluginDir, { recursive: true, force: true });
});
async function writeEntry(relative: string): Promise<string> {
const path = join(pluginDir, relative);
await mkdir(join(path, ".."), { recursive: true });
await writeFile(path, "// entry\n");
return path;
}
it("prefers fresher src/index.ts over stale dist when no bundle exists", async () => {
const dist = await writeEntry("dist/index.js");
const src = await writeEntry("src/index.ts");
const older = new Date("2026-01-01T00:00:00.000Z");
const newer = new Date("2026-01-01T00:01:00.000Z");
utimesSync(dist, older, older);
utimesSync(src, newer, newer);
expect(resolvePluginEntryPath(pluginDir)).toBe(src);
});
it("keeps dist/index.js when dist is newer than src", async () => {
const dist = await writeEntry("dist/index.js");
const src = await writeEntry("src/index.ts");
const older = new Date("2026-01-01T00:00:00.000Z");
const newer = new Date("2026-01-01T00:01:00.000Z");
utimesSync(dist, newer, newer);
utimesSync(src, older, older);
expect(resolvePluginEntryPath(pluginDir)).toBe(dist);
});
it("uses newest non-index src file for freshness and still returns src/index.ts", async () => {
const dist = await writeEntry("dist/index.js");
const src = await writeEntry("src/index.ts");
const settings = await writeEntry("src/settings.ts");
const older = new Date("2026-01-01T00:00:00.000Z");
const newer = new Date("2026-01-01T00:01:00.000Z");
utimesSync(dist, older, older);
utimesSync(src, older, older);
utimesSync(settings, newer, newer);
expect(resolvePluginEntryPath(pluginDir)).toBe(src);
});
it("always keeps bundled.js first regardless of dist or src freshness", async () => {
const bundled = await writeEntry("bundled.js");
const dist = await writeEntry("dist/index.js");
const src = await writeEntry("src/index.ts");
const older = new Date("2026-01-01T00:00:00.000Z");
const newer = new Date("2026-01-01T00:01:00.000Z");
utimesSync(bundled, older, older);
utimesSync(dist, older, older);
utimesSync(src, newer, newer);
expect(resolvePluginEntryPath(pluginDir)).toBe(bundled);
});
});
// Mock TaskStore for testing // Mock TaskStore for testing
const mockTaskStore = { const mockTaskStore = {
logActivity: vi.fn(), logActivity: vi.fn(),

View File

@@ -10,7 +10,7 @@
*/ */
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path"; import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
import { existsSync } from "node:fs"; import { existsSync, readdirSync, statSync } from "node:fs";
import { stat } from "node:fs/promises"; import { stat } from "node:fs/promises";
import { copyFile } from "node:fs/promises"; import { copyFile } from "node:fs/promises";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
@@ -53,10 +53,17 @@ let moduleImportVersion = 0;
/** /**
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM * Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
* does not allow directory imports, so the registered plugin path must be the * does not allow directory imports, so the registered plugin path must be the
* explicit file the loader will dynamic-import. Preference order: * explicit file the loader will dynamic-import. Resolution keeps ./bundled.js
* 1. ./bundled.js (esbuild-bundled, shipped in npm tarball) * unconditional because production npm tarballs ship that esbuild-bundled entry.
* 2. ./dist/index.js (legacy prebuilt fallback) * In dev/worktree contexts where no bundle exists, ./dist/index.js remains the
* 3. ./src/index.ts (workspace/dev fallback when no bundle exists) * prebuilt fallback unless any file under ./src/ is newer than dist/index.js;
* then ./src/index.ts wins so stale gitignored dist output cannot mask a source
* fix (FN-6615/FN-6596).
*
* FNXC:PluginLoader 2026-06-17-19:20:
* Prefer fresher src over stale dist only when bundled.js is absent. This keeps
* production tarballs on their bundled entry while preventing dev/worktree runs
* from silently loading old gitignored build output after a source fix.
* *
* Returns null when the directory exists but none of the loadable entry files * Returns null when the directory exists but none of the loadable entry files
* are present. Callers must treat that as a missing/unloadable plugin rather * are present. Callers must treat that as a missing/unloadable plugin rather
@@ -65,16 +72,74 @@ let moduleImportVersion = 0;
* Keep in sync with resolvePluginEntryPath in the CLI's * Keep in sync with resolvePluginEntryPath in the CLI's
* bundled-plugin-install.ts, which keeps a local copy so its fs mocks work. * bundled-plugin-install.ts, which keeps a local copy so its fs mocks work.
*/ */
export function resolvePluginEntryPath(pluginDir: string): string | null { function newestSourceMtimeMs(srcDir: string): number | null {
const candidates = [ let newest = Number.NEGATIVE_INFINITY;
join(pluginDir, "bundled.js"),
join(pluginDir, "dist", "index.js"), function visit(dir: string): boolean {
join(pluginDir, "src", "index.ts"), const entries = (() => {
]; try {
for (const candidate of candidates) { return readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
if (existsSync(candidate)) { } catch {
return candidate; return null;
}
})();
if (!entries) return false;
for (const entry of entries) {
const entryPath = join(dir, entry.name);
let entryStat: ReturnType<typeof statSync>;
try {
entryStat = statSync(entryPath);
} catch {
return false;
}
if (entryStat.isDirectory()) {
if (!visit(entryPath)) return false;
continue;
}
if (entryStat.mtimeMs > newest) {
newest = entryStat.mtimeMs;
}
} }
return true;
}
return visit(srcDir) && newest !== Number.NEGATIVE_INFINITY ? newest : null;
}
function isSourceNewerThanDist(srcDir: string, distIndexPath: string): boolean {
try {
const distMtimeMs = statSync(distIndexPath).mtimeMs;
const srcMtimeMs = newestSourceMtimeMs(srcDir);
return srcMtimeMs !== null && srcMtimeMs > distMtimeMs;
} catch {
return false;
}
}
export function resolvePluginEntryPath(pluginDir: string): string | null {
const bundledPath = join(pluginDir, "bundled.js");
if (existsSync(bundledPath)) {
return bundledPath;
}
const distIndexPath = join(pluginDir, "dist", "index.js");
const srcDir = join(pluginDir, "src");
const srcIndexPath = join(srcDir, "index.ts");
const hasDist = existsSync(distIndexPath);
const hasSrc = existsSync(srcIndexPath);
if (hasDist && hasSrc) {
return isSourceNewerThanDist(srcDir, distIndexPath) ? srcIndexPath : distIndexPath;
}
if (hasDist) {
return distIndexPath;
}
if (hasSrc) {
return srcIndexPath;
} }
return null; return null;
} }