diff --git a/packages/core/src/project-reference.ts b/packages/core/src/project-reference.ts index 84b659d52..58b0e7ab9 100644 --- a/packages/core/src/project-reference.ts +++ b/packages/core/src/project-reference.ts @@ -26,9 +26,21 @@ export type Resolved = type Valid = Exclude export type Mention = - | { readonly name: string; readonly kind: "reference"; readonly reference: Valid; readonly target?: string; readonly path: string } + | { + readonly name: string + readonly kind: "reference" + readonly reference: Valid + readonly target?: string + readonly path: string + } | { readonly name: string; readonly kind: "invalid"; readonly target?: string; readonly message: string } - | { readonly name: string; readonly kind: "missing"; readonly target: string; readonly path: string; readonly message: string } + | { + readonly name: string + readonly kind: "missing" + readonly target: string + readonly path: string + readonly message: string + } export interface Interface { readonly list: () => Effect.Effect @@ -73,7 +85,9 @@ export const layer = Layer.effect( repository: reference.repository, path: reference.path, run: yield* Effect.cached( - cache.ensure({ reference: reference.reference, branch: reference.branch, refresh: true }).pipe(Effect.asVoid), + cache + .ensure({ reference: reference.reference, branch: reference.branch, refresh: true }) + .pipe(Effect.asVoid), ), } }), @@ -90,13 +104,12 @@ export const layer = Layer.effect( ), ), { concurrency: 4, discard: true }, - ).pipe( - Effect.forkScoped, - ) + ).pipe(Effect.forkScoped) const ensurePath = Effect.fn("ProjectReference.ensurePath")(function* (target?: string) { const normalized = normalizePath(target) - if (!normalized) return yield* Effect.forEach(materializers, (materializer) => materializer.run, { discard: true }) + if (!normalized) + return yield* Effect.forEach(materializers, (materializer) => materializer.run, { discard: true }) yield* materializers.find((materializer) => contains(materializer.path, normalized))?.run ?? Effect.void }) @@ -110,7 +123,9 @@ export const layer = Layer.effect( ensurePath, containsManagedPath: Effect.fn("ProjectReference.containsManagedPath")(function* (target?: string) { const normalized = normalizePath(target) - return normalized ? references.some((reference) => reference.kind === "git" && contains(reference.path, normalized)) : false + return normalized + ? references.some((reference) => reference.kind === "git" && contains(reference.path, normalized)) + : false }), resolveMention: Effect.fn("ProjectReference.resolveMention")(function* (value: string) { const [name, ...rest] = value.split("/") @@ -122,8 +137,10 @@ export const layer = Layer.effect( if (!target) return { name, kind: "reference", reference, path: reference.path } const resolved = path.resolve(reference.path, target) - if (!AppFileSystem.contains(reference.path, resolved)) return { name, kind: "invalid", target, message: "Reference target escapes its root" } - if (!(yield* fs.existsSafe(resolved))) return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" } + if (!AppFileSystem.contains(reference.path, resolved)) + return { name, kind: "invalid", target, message: "Reference target escapes its root" } + if (!(yield* fs.existsSafe(resolved))) + return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" } return { name, kind: "reference", reference, target, path: resolved } }), }) @@ -140,7 +157,12 @@ const inert: Interface = { containsManagedPath: () => Effect.succeed(false), } -export function resolveAll(input: { references: ConfigReference.NormalizedInfo; directory: string; home: string; repos: string }) { +export function resolveAll(input: { + references: ConfigReference.NormalizedInfo + directory: string + home: string + repos: string +}) { const seen = new Map() return Object.entries(input.references).map(([name, reference]): Resolved => { const resolved = resolve({ name, reference, directory: input.directory, home: input.home, repos: input.repos }) @@ -160,7 +182,13 @@ export function resolveAll(input: { references: ConfigReference.NormalizedInfo; }) } -export function resolve(input: { name: string; reference: ConfigReference.NormalizedEntry; directory: string; home: string; repos: string }): Resolved { +export function resolve(input: { + name: string + reference: ConfigReference.NormalizedEntry + directory: string + home: string + repos: string +}): Resolved { if (input.reference.kind === "invalid") return { name: input.name, kind: "invalid", message: input.reference.message } if (input.reference.kind === "local") { return { name: input.name, kind: "local", path: localPath(input.directory, input.home, input.reference.path) } diff --git a/packages/core/test/project-reference.test.ts b/packages/core/test/project-reference.test.ts index 081e4117b..c1cf943aa 100644 --- a/packages/core/test/project-reference.test.ts +++ b/packages/core/test/project-reference.test.ts @@ -18,13 +18,15 @@ import { it } from "./lib/effect" describe("ProjectReference", () => { it.live("uses the broad experimental flag unless references are explicitly configured", () => - withEnv({ OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: undefined }, + withEnv( + { OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: undefined }, Effect.sync(() => { expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(true) }), ).pipe( Effect.flatMap(() => - withEnv({ OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: "false" }, + withEnv( + { OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: "false" }, Effect.sync(() => { expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(false) }), @@ -85,84 +87,100 @@ describe("ProjectReference", () => { ) it.live("merges config aliases and exposes mention and managed-path operations", () => - withoutReferences(withTmp((tmp) => { - const calls: RepositoryCache.EnsureInput[] = [] - const project = path.join(tmp.path, "project") - const nested = path.join(project, "packages", "app") - const docs = path.join(project, "docs") - const repos = path.join(tmp.path, "repos") - return Effect.gen(function* () { - yield* Effect.promise(async () => { - await fs.mkdir(nested, { recursive: true }) - await fs.mkdir(docs) - await fs.writeFile(path.join(docs, "README.md"), "docs") - }) + withoutReferences( + withTmp((tmp) => { + const calls: RepositoryCache.EnsureInput[] = [] + const project = path.join(tmp.path, "project") + const nested = path.join(project, "packages", "app") + const docs = path.join(project, "docs") + const repos = path.join(tmp.path, "repos") + return Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(nested, { recursive: true }) + await fs.mkdir(docs) + await fs.writeFile(path.join(docs, "README.md"), "docs") + }) - yield* withReferences( - Effect.gen(function* () { - const references = yield* ProjectReference.Service - const git = path.join(repos, "github.com", "owner", "repo") + yield* withReferences( + Effect.gen(function* () { + const references = yield* ProjectReference.Service + const git = path.join(repos, "github.com", "owner", "repo") - expect(yield* references.list()).toMatchObject([ - { name: "docs", kind: "local", path: docs }, - { name: "sdk", kind: "git", path: git }, - ]) - expect(yield* references.resolveMention("docs/README.md")).toMatchObject({ - name: "docs", - kind: "reference", - target: "README.md", - path: path.join(docs, "README.md"), - }) - expect(yield* references.resolveMention("docs/missing.md")).toMatchObject({ name: "docs", kind: "missing" }) - expect(yield* references.resolveMention("docs/../outside.md")).toMatchObject({ name: "docs", kind: "invalid" }) - expect(yield* references.resolveMention("unknown")).toBeUndefined() - expect(yield* references.resolveMention("sdk")).toMatchObject({ name: "sdk", kind: "reference", path: git }) - expect(yield* references.containsManagedPath(path.join(git, "README.md"))).toBe(true) - expect(yield* references.containsManagedPath(path.join(docs, "README.md"))).toBe(false) - yield* references.ensurePath() - expect(calls).toHaveLength(1) - }).pipe( - Effect.provide( - testLayer({ - directory: nested, - project, - repos, - documents: [ - document({ docs: { path: "./old-docs" }, sdk: "owner/old" }), - document({ docs: { path: "./docs" }, sdk: { repository: "owner/repo", branch: "main" } }), - ], - ensure: (input) => Effect.sync(() => result(repos, calls, input)), - }), + expect(yield* references.list()).toMatchObject([ + { name: "docs", kind: "local", path: docs }, + { name: "sdk", kind: "git", path: git }, + ]) + expect(yield* references.resolveMention("docs/README.md")).toMatchObject({ + name: "docs", + kind: "reference", + target: "README.md", + path: path.join(docs, "README.md"), + }) + expect(yield* references.resolveMention("docs/missing.md")).toMatchObject({ + name: "docs", + kind: "missing", + }) + expect(yield* references.resolveMention("docs/../outside.md")).toMatchObject({ + name: "docs", + kind: "invalid", + }) + expect(yield* references.resolveMention("unknown")).toBeUndefined() + expect(yield* references.resolveMention("sdk")).toMatchObject({ + name: "sdk", + kind: "reference", + path: git, + }) + expect(yield* references.containsManagedPath(path.join(git, "README.md"))).toBe(true) + expect(yield* references.containsManagedPath(path.join(docs, "README.md"))).toBe(false) + yield* references.ensurePath() + expect(calls).toHaveLength(1) + }).pipe( + Effect.provide( + testLayer({ + directory: nested, + project, + repos, + documents: [ + document({ docs: { path: "./old-docs" }, sdk: "owner/old" }), + document({ docs: { path: "./docs" }, sdk: { repository: "owner/repo", branch: "main" } }), + ], + ensure: (input) => Effect.sync(() => result(repos, calls, input)), + }), + ), ), - ), - ) - }) - })), + ) + }) + }), + ), ) it.live("is inert while the runtime flag is disabled", () => - withoutReferences(withTmp((tmp) => { - const calls: RepositoryCache.EnsureInput[] = [] - return Effect.gen(function* () { - const references = yield* ProjectReference.Service - expect(yield* references.list()).toEqual([]) - expect(yield* references.get("sdk")).toBeUndefined() - expect(yield* references.resolveMention("sdk")).toBeUndefined() - expect(yield* references.containsManagedPath(path.join(tmp.path, "repos", "github.com", "owner", "repo"))).toBe(false) - yield* references.ensurePath() - expect(calls).toEqual([]) - }).pipe( - Effect.provide( - testLayer({ - directory: tmp.path, - project: tmp.path, - repos: path.join(tmp.path, "repos"), - documents: [document({ sdk: "owner/repo" })], - ensure: (input) => Effect.sync(() => result(path.join(tmp.path, "repos"), calls, input)), - }), - ), - ) - })), + withoutReferences( + withTmp((tmp) => { + const calls: RepositoryCache.EnsureInput[] = [] + return Effect.gen(function* () { + const references = yield* ProjectReference.Service + expect(yield* references.list()).toEqual([]) + expect(yield* references.get("sdk")).toBeUndefined() + expect(yield* references.resolveMention("sdk")).toBeUndefined() + expect( + yield* references.containsManagedPath(path.join(tmp.path, "repos", "github.com", "owner", "repo")), + ).toBe(false) + yield* references.ensurePath() + expect(calls).toEqual([]) + }).pipe( + Effect.provide( + testLayer({ + directory: tmp.path, + project: tmp.path, + repos: path.join(tmp.path, "repos"), + documents: [document({ sdk: "owner/repo" })], + ensure: (input) => Effect.sync(() => result(path.join(tmp.path, "repos"), calls, input)), + }), + ), + ) + }), + ), ) it.live("starts Git materialization in the background without blocking the location layer", () => @@ -173,7 +191,10 @@ describe("ProjectReference", () => { Effect.gen(function* () { expect(yield* (yield* ProjectReference.Service).list()).toHaveLength(1) yield* Deferred.await(started).pipe( - Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.die(new Error("refresh did not start")) }), + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => Effect.die(new Error("refresh did not start")), + }), ) }).pipe( Effect.provide( @@ -196,7 +217,11 @@ function document(references: ConfigReference.Info) { return new Config.Loaded({ source: { type: "memory" }, info: Schema.decodeUnknownSync(Config.Info)({ references }) }) } -function result(repos: string, calls: RepositoryCache.EnsureInput[], input: RepositoryCache.EnsureInput): RepositoryCache.Result { +function result( + repos: string, + calls: RepositoryCache.EnsureInput[], + input: RepositoryCache.EnsureInput, +): RepositoryCache.Result { calls.push(input) return { repository: input.reference.label, @@ -223,10 +248,16 @@ function testLayer(input: { Layer.succeed( Location.Service, Location.Service.of( - location({ directory: AbsolutePath.make(input.directory) }, { projectDirectory: AbsolutePath.make(input.project) }), + location( + { directory: AbsolutePath.make(input.directory) }, + { projectDirectory: AbsolutePath.make(input.project) }, + ), ), ), - Layer.succeed(Config.Service, Config.Service.of({ directories: () => Effect.succeed([]), get: () => Effect.succeed(input.documents) })), + Layer.succeed( + Config.Service, + Config.Service.of({ directories: () => Effect.succeed([]), get: () => Effect.succeed(input.documents) }), + ), Layer.succeed(RepositoryCache.Service, RepositoryCache.Service.of({ ensure: input.ensure })), ), ), @@ -234,7 +265,11 @@ function testLayer(input: { } function withTmp(body: (tmp: Awaited>) => Effect.Effect) { - return Effect.acquireUseRelease(Effect.promise(() => tmpdir()), body, (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]())) + return Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + body, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) } function withReferences(body: Effect.Effect) {