refactor(core): consolidate references
This commit is contained in:
parent
137992ca85
commit
f76ca58a54
63 changed files with 683 additions and 2722 deletions
63
packages/core/src/config/plugin/reference.ts
Normal file
63
packages/core/src/config/plugin/reference.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigReference } from "../reference"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { Reference } from "../../reference"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
|
||||
export const Plugin = {
|
||||
id: PluginV2.ID.make("core/config-reference"),
|
||||
effect: Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const references = yield* Reference.Service
|
||||
const update = yield* references.transform()
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of (yield* config.entries()).filter(
|
||||
(entry): entry is Config.Document => entry.type === "document",
|
||||
)) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? new Reference.LocalSource({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(localPath(directory, global.home, typeof entry === "string" ? entry : entry.path)),
|
||||
})
|
||||
: new Reference.GitSource({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
yield* update((editor) => {
|
||||
for (const [name, source] of entries) editor.add(name, source)
|
||||
})
|
||||
}),
|
||||
}
|
||||
|
||||
function validAlias(name: string) {
|
||||
return name.length > 0 && !/[\/\s`,]/.test(name)
|
||||
}
|
||||
|
||||
function local(entry: ConfigReference.Entry): entry is string | ConfigReference.Local {
|
||||
return typeof entry === "string"
|
||||
? entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")
|
||||
: "path" in entry
|
||||
}
|
||||
|
||||
function localPath(directory: string, home: string, value: string) {
|
||||
if (value.startsWith("~/")) return path.join(home, value.slice(2))
|
||||
return path.isAbsolute(value) ? value : path.resolve(directory, value)
|
||||
}
|
||||
|
|
@ -16,33 +16,3 @@ export type Entry = typeof Entry.Type
|
|||
|
||||
export const Info = Schema.Record(Schema.String, Entry)
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export type NormalizedEntry =
|
||||
| { readonly kind: "local"; readonly path: string }
|
||||
| { readonly kind: "git"; readonly repository: string; readonly branch?: string }
|
||||
| { readonly kind: "invalid"; readonly message: string }
|
||||
|
||||
export type NormalizedInfo = Record<string, NormalizedEntry>
|
||||
|
||||
export function validateAlias(name: string) {
|
||||
if (name.length === 0) return "Reference alias must not be empty"
|
||||
if (/[\/\s`,]/.test(name)) return "Reference alias must not contain /, whitespace, comma, or backtick"
|
||||
}
|
||||
|
||||
export function normalizeEntry(entry: Entry): NormalizedEntry {
|
||||
if (typeof entry === "string") {
|
||||
if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) return { kind: "local", path: entry }
|
||||
return { kind: "git", repository: entry }
|
||||
}
|
||||
if ("path" in entry) return { kind: "local", path: entry.path }
|
||||
return { kind: "git", repository: entry.repository, branch: entry.branch }
|
||||
}
|
||||
|
||||
export function normalize(info: Info): NormalizedInfo {
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).map(([name, entry]) => {
|
||||
const message = validateAlias(name)
|
||||
return [name, message ? { kind: "invalid" as const, message } : normalizeEntry(entry)]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { EventV2 } from "./event"
|
|||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { ProjectReference } from "./project-reference"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
import { Protected } from "./filesystem/protected"
|
||||
import { Ripgrep } from "./filesystem/ripgrep"
|
||||
|
|
@ -17,7 +16,6 @@ import { ToolOutputStore } from "./tool-output-store"
|
|||
|
||||
export const ReadInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
})
|
||||
export type ReadInput = typeof ReadInput.Type
|
||||
|
||||
|
|
@ -134,7 +132,6 @@ export class ReadPath extends Schema.Class<ReadPath>("FileSystem.ReadPath")({
|
|||
|
||||
export const ListInput = Schema.Struct({
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
})
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
|
|
@ -158,7 +155,6 @@ export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget"
|
|||
real: Schema.String,
|
||||
root: Schema.String,
|
||||
resource: Schema.String,
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
type: Schema.Literals(["file", "directory"]),
|
||||
}) {}
|
||||
|
||||
|
|
@ -239,7 +235,6 @@ export const layer = Layer.effect(
|
|||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Effect.serviceOption(Global.Service)
|
||||
const references = yield* ProjectReference.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
||||
const ignored = ignore()
|
||||
|
|
@ -251,21 +246,12 @@ export const layer = Layer.effect(
|
|||
.readFileString(path.join(location.project.directory, ".ignore"))
|
||||
.pipe(Effect.catch(() => Effect.succeed("")))
|
||||
if (ignorefile) ignored.add(ignorefile)
|
||||
const select = Effect.fnUntraced(function* (reference?: string) {
|
||||
if (!reference) return { directory: location.directory, root }
|
||||
const resolved = yield* references.get(reference)
|
||||
if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
|
||||
if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
|
||||
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
|
||||
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
|
||||
})
|
||||
const resolve = Effect.fnUntraced(function* (input?: string, reference?: string) {
|
||||
const resolve = Effect.fnUntraced(function* (input?: string) {
|
||||
const managed = path.join(
|
||||
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
|
||||
ToolOutputStore.MANAGED_DIRECTORY,
|
||||
)
|
||||
if (input && path.isAbsolute(input)) {
|
||||
if (reference) return yield* Effect.die(new Error("Absolute paths cannot use a project reference"))
|
||||
if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_"))
|
||||
return yield* Effect.die(new Error("Absolute path is not managed tool output"))
|
||||
const real = yield* fs.realPath(input).pipe(Effect.orDie)
|
||||
|
|
@ -274,9 +260,9 @@ export const layer = Layer.effect(
|
|||
return yield* Effect.die(new Error("Path escapes managed tool output"))
|
||||
return { absolute: input, real, directory: managed, root: managedRoot }
|
||||
}
|
||||
const selected = yield* select(reference)
|
||||
const absolute = path.resolve(selected.directory, input ?? ".")
|
||||
if (!FSUtil.contains(selected.directory, absolute))
|
||||
const selected = { directory: location.directory, root }
|
||||
const absolute = path.resolve(location.directory, input ?? ".")
|
||||
if (!FSUtil.contains(location.directory, absolute))
|
||||
return yield* Effect.die(new Error("Path escapes the location"))
|
||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
||||
if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||
|
|
@ -335,24 +321,24 @@ export const layer = Layer.effect(
|
|||
})
|
||||
|
||||
const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) {
|
||||
const target = yield* resolve(input.path, input.reference)
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
|
||||
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
|
||||
return new ReadPath({
|
||||
type,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
resource: relative,
|
||||
})
|
||||
})
|
||||
const resolveFile = Effect.fnUntraced(function* (input: ReadInput) {
|
||||
const target = yield* resolve(input.path, input.reference)
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
|
||||
return {
|
||||
real: target.real,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
resource: relative,
|
||||
}
|
||||
})
|
||||
const content = (target: { readonly real: string }, bytes: Uint8Array) =>
|
||||
|
|
@ -510,25 +496,24 @@ export const layer = Layer.effect(
|
|||
)
|
||||
})
|
||||
const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) {
|
||||
const directory = yield* resolve(input.path, input.reference)
|
||||
const directory = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
|
||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||
const relative = path.relative(directory.root, directory.real).replaceAll("\\", "/") || "."
|
||||
return new ListTarget({
|
||||
...directory,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
resource: relative,
|
||||
})
|
||||
})
|
||||
const resolveRoot = Effect.fn("FileSystem.resolveRoot")(function* (input: ListInput = {}) {
|
||||
const target = yield* resolve(input.path, input.reference)
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
|
||||
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
|
||||
return new RootTarget({
|
||||
...target,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
reference: input.reference,
|
||||
resource: relative,
|
||||
type,
|
||||
})
|
||||
})
|
||||
|
|
@ -644,5 +629,4 @@ export const layer = Layer.effect(
|
|||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provideMerge(ProjectReference.locationLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { Watcher } from "./filesystem/watcher"
|
|||
import { LocationMutation } from "./location-mutation"
|
||||
import { LocationSearch } from "./location-search"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { ProjectReference } from "./project-reference"
|
||||
import { Reference } from "./reference"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
import { Pty } from "./pty"
|
||||
import { SkillV2 } from "./skill"
|
||||
|
|
@ -52,7 +52,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||
location,
|
||||
Policy.locationLayer,
|
||||
Config.locationLayer,
|
||||
ProjectReference.locationLayer,
|
||||
Reference.locationLayer,
|
||||
PluginV2.locationLayer,
|
||||
Catalog.locationLayer,
|
||||
CommandV2.locationLayer,
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
|||
|
||||
/**
|
||||
* Location-scoped raw search substrate. Search authority is selected only by
|
||||
* FileSystem, preserving Location-relative paths and named read
|
||||
* references. Model formatting, leaf-tool permissions, and HTTP transport stay
|
||||
* FileSystem, preserving Location-relative paths. Model formatting, leaf-tool permissions, and HTTP transport stay
|
||||
* outside this service so future GlobTool, GrepTool, and HTTP consumers can
|
||||
* share the same bounded filesystem behavior.
|
||||
*
|
||||
|
|
@ -106,7 +105,7 @@ export const layer = Layer.effect(
|
|||
return {
|
||||
path: RelativePath.make(relative),
|
||||
canonical,
|
||||
resource: root.reference === undefined ? relative : `${root.reference}:${relative}`,
|
||||
resource: relative,
|
||||
mtime: info.mtime.pipe(
|
||||
Option.map((date) => date.getTime()),
|
||||
Option.getOrElse(() => 0),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Config } from "../config"
|
|||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { EventV2 } from "../event"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
|
|
@ -25,6 +26,7 @@ import { EnvPlugin } from "./env"
|
|||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Reference } from "../reference"
|
||||
|
||||
type Plugin = {
|
||||
id: PluginV2.ID
|
||||
|
|
@ -42,6 +44,7 @@ type Plugin = {
|
|||
| Config.Service
|
||||
| ModelsDev.Service
|
||||
| SkillV2.Service
|
||||
| Reference.Service
|
||||
>
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +70,7 @@ export const layer = Layer.effect(
|
|||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const references = yield* Reference.Service
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
||||
|
|
@ -85,6 +89,7 @@ export const layer = Layer.effect(
|
|||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(SkillV2.Service, skill),
|
||||
Effect.provideService(Reference.Service, references),
|
||||
Effect.provideService(PluginV2.Service, plugin),
|
||||
),
|
||||
})
|
||||
|
|
@ -104,6 +109,7 @@ export const layer = Layer.effect(
|
|||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
}).pipe(Effect.withSpan("PluginBoot.boot"))
|
||||
|
||||
yield* boot.pipe(
|
||||
|
|
@ -124,4 +130,5 @@ export const locationLayer = layer.pipe(
|
|||
Layer.provideMerge(Config.locationLayer),
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
Layer.provideMerge(Reference.locationLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,243 +0,0 @@
|
|||
export * as ProjectReference from "./project-reference"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Flag } from "./flag/flag"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { Repository } from "./repository"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
|
||||
export type Resolved =
|
||||
| { readonly name: string; readonly kind: "local"; readonly path: string }
|
||||
| {
|
||||
readonly name: string
|
||||
readonly kind: "git"
|
||||
readonly repository: string
|
||||
readonly reference: Repository.RemoteReference
|
||||
readonly path: string
|
||||
readonly branch?: string
|
||||
}
|
||||
| { readonly name: string; readonly kind: "invalid"; readonly repository?: string; readonly message: string }
|
||||
|
||||
type Valid = Exclude<Resolved, { kind: "invalid" }>
|
||||
|
||||
export type Mention =
|
||||
| {
|
||||
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
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Resolved[]>
|
||||
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
|
||||
readonly resolveMention: (value: string) => Effect.Effect<Mention | undefined, RepositoryCache.Error>
|
||||
readonly ensurePath: (target?: string) => Effect.Effect<void, RepositoryCache.Error>
|
||||
readonly containsManagedPath: (target?: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectReference") {}
|
||||
|
||||
type Materializer = {
|
||||
readonly name: string
|
||||
readonly repository: string
|
||||
readonly path: string
|
||||
readonly run: Effect.Effect<void, RepositoryCache.Error>
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_REFERENCES) return Service.of(inert)
|
||||
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const references = resolveAll({
|
||||
references: ConfigReference.normalize(
|
||||
Object.assign(
|
||||
{},
|
||||
...(yield* config.entries())
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.map((document) => document.info.references ?? {}),
|
||||
),
|
||||
),
|
||||
directory: location.project.directory,
|
||||
home: global.home,
|
||||
repos: global.repos,
|
||||
})
|
||||
const materializers = yield* Effect.forEach(
|
||||
uniqueGitReferences(references),
|
||||
Effect.fnUntraced(function* (reference) {
|
||||
return {
|
||||
name: reference.name,
|
||||
repository: reference.repository,
|
||||
path: reference.path,
|
||||
run: yield* Effect.cached(
|
||||
cache
|
||||
.ensure({ reference: reference.reference, branch: reference.branch, refresh: true })
|
||||
.pipe(Effect.asVoid),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.forEach(
|
||||
materializers,
|
||||
(materializer) =>
|
||||
materializer.run.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize project reference", {
|
||||
name: materializer.name,
|
||||
repository: materializer.repository,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ concurrency: 4, discard: true },
|
||||
).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 })
|
||||
yield* materializers.find((materializer) => contains(materializer.path, normalized))?.run ?? Effect.void
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
list: Effect.fn("ProjectReference.list")(function* () {
|
||||
return references
|
||||
}),
|
||||
get: Effect.fn("ProjectReference.get")(function* (name: string) {
|
||||
return references.find((reference) => reference.name === name)
|
||||
}),
|
||||
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
|
||||
}),
|
||||
resolveMention: Effect.fn("ProjectReference.resolveMention")(function* (value: string) {
|
||||
const [name, ...rest] = value.split("/")
|
||||
const target = rest.length ? rest.join("/") : undefined
|
||||
const reference = references.find((reference) => reference.name === name)
|
||||
if (!reference) return
|
||||
if (reference.kind === "invalid") return { name, kind: "invalid", target, message: reference.message }
|
||||
if (reference.kind === "git") yield* ensurePath(reference.path)
|
||||
if (!target) return { name, kind: "reference", reference, path: reference.path }
|
||||
|
||||
const resolved = path.resolve(reference.path, target)
|
||||
if (!FSUtil.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 }
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
|
||||
|
||||
const inert: Interface = {
|
||||
list: () => Effect.succeed([]),
|
||||
get: () => Effect.succeed(undefined),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath: () => Effect.void,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
}
|
||||
|
||||
export function resolveAll(input: {
|
||||
references: ConfigReference.NormalizedInfo
|
||||
directory: string
|
||||
home: string
|
||||
repos: string
|
||||
}) {
|
||||
const seen = new Map<string, { name: string; branch?: string }>()
|
||||
return Object.entries(input.references).map(([name, reference]): Resolved => {
|
||||
const resolved = resolve({ name, reference, directory: input.directory, home: input.home, repos: input.repos })
|
||||
if (resolved.kind !== "git") return resolved
|
||||
const existing = seen.get(resolved.path)
|
||||
if (!existing) {
|
||||
seen.set(resolved.path, { name, branch: resolved.branch })
|
||||
return resolved
|
||||
}
|
||||
if (existing.branch === resolved.branch) return resolved
|
||||
return {
|
||||
name,
|
||||
kind: "invalid",
|
||||
repository: resolved.repository,
|
||||
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${existing.branch ?? "default branch"} and @${name} requests ${resolved.branch ?? "default branch"}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
const reference = Repository.parse(input.reference.repository)
|
||||
if (!reference || !Repository.isRemote(reference)) {
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "invalid",
|
||||
repository: input.reference.repository,
|
||||
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "git",
|
||||
repository: input.reference.repository,
|
||||
reference,
|
||||
path: Repository.cachePath(input.repos, reference),
|
||||
branch: input.reference.branch,
|
||||
}
|
||||
}
|
||||
|
||||
function localPath(directory: string, home: string, value: string) {
|
||||
if (value.startsWith("~/")) return path.join(home, value.slice(2))
|
||||
return path.isAbsolute(value) ? value : path.resolve(directory, value)
|
||||
}
|
||||
|
||||
function uniqueGitReferences(references: Resolved[]) {
|
||||
const seen = new Set<string>()
|
||||
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
|
||||
if (reference.kind !== "git" || seen.has(reference.path)) return false
|
||||
seen.add(reference.path)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function normalizePath(target?: string) {
|
||||
if (!target) return
|
||||
return process.platform === "win32" ? FSUtil.normalizePath(target) : target
|
||||
}
|
||||
|
||||
function contains(parent: string, child: string) {
|
||||
return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
|
||||
}
|
||||
114
packages/core/src/reference.ts
Normal file
114
packages/core/src/reference.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
export * as Reference from "./reference"
|
||||
|
||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { castDraft } from "immer"
|
||||
import { Global } from "./global"
|
||||
import { EventV2 } from "./event"
|
||||
import { Repository } from "./repository"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { State } from "./state"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Reference.Info")({
|
||||
name: Schema.String,
|
||||
path: AbsolutePath,
|
||||
source: Schema.suspend(() => Source),
|
||||
}) {}
|
||||
|
||||
export class LocalSource extends Schema.Class<LocalSource>("Reference.LocalSource")({
|
||||
type: Schema.Literal("local"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class GitSource extends Schema.Class<GitSource>("Reference.GitSource")({
|
||||
type: Schema.Literal("git"),
|
||||
repository: Schema.String,
|
||||
branch: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Source = typeof Source.Type
|
||||
|
||||
export const Event = {
|
||||
Updated: EventV2.define({ type: "reference.updated", schema: {} }),
|
||||
}
|
||||
|
||||
type Data = {
|
||||
sources: Map<string, Source>
|
||||
}
|
||||
|
||||
type Editor = {
|
||||
add(name: string, source: Source): void
|
||||
remove(name: string): void
|
||||
list(): readonly [string, Source][]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Reference") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const events = yield* EventV2.Service
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const materialized = new Map<string, Info>()
|
||||
const state = State.create<Data, Editor>({
|
||||
initial: () => ({ sources: new Map() }),
|
||||
editor: (draft) => ({
|
||||
add: (name, source) => draft.sources.set(name, castDraft(source)),
|
||||
remove: (name) => draft.sources.delete(name),
|
||||
list: () => Array.from(draft.sources.entries()) as [string, Source][],
|
||||
}),
|
||||
finalize: (editor) =>
|
||||
Effect.gen(function* () {
|
||||
materialized.clear()
|
||||
const seen = new Map<string, string | undefined>()
|
||||
for (const [name, source] of editor.list()) {
|
||||
if (source.type === "local") {
|
||||
materialized.set(name, new Info({ name, path: source.path, source }))
|
||||
continue
|
||||
}
|
||||
const repository = Repository.parse(source.repository)
|
||||
if (!repository || !Repository.isRemote(repository)) continue
|
||||
if (source.branch) {
|
||||
try {
|
||||
Repository.validateBranch(source.branch)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
const target = Repository.cachePath(global.repos, repository)
|
||||
if (seen.has(target) && seen.get(target) !== source.branch) continue
|
||||
seen.set(target, source.branch)
|
||||
materialized.set(name, new Info({ name, path: AbsolutePath.make(target), source }))
|
||||
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference", {
|
||||
name,
|
||||
repository: source.repository,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
}
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return Array.from(materialized.values())
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
|
@ -349,6 +349,5 @@ const toMessage = (input: Admitted) =>
|
|||
text: input.prompt.text,
|
||||
files: input.prompt.files,
|
||||
agents: input.prompt.agents,
|
||||
references: input.prompt.references,
|
||||
time: { created: input.timeCreated },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -132,7 +132,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
text: event.data.prompt.text,
|
||||
files: event.data.prompt.files,
|
||||
agents: event.data.prompt.agents,
|
||||
references: event.data.prompt.references,
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ export class User extends Schema.Class<User>("Session.Message.User")({
|
|||
text: Prompt.fields.text,
|
||||
files: Prompt.fields.files,
|
||||
agents: Prompt.fields.agents,
|
||||
references: Prompt.fields.references,
|
||||
type: Schema.Literal("user"),
|
||||
time: Schema.Struct({
|
||||
created: V2Schema.DateTimeUtcFromMillis,
|
||||
|
|
|
|||
|
|
@ -29,32 +29,18 @@ export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.Agent
|
|||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ReferenceAttachment extends Schema.Class<ReferenceAttachment>("Prompt.ReferenceAttachment")({
|
||||
name: Schema.String,
|
||||
kind: Schema.Literals(["local", "git", "invalid"]),
|
||||
uri: Schema.String.pipe(Schema.optional),
|
||||
repository: Schema.String.pipe(Schema.optional),
|
||||
branch: Schema.String.pipe(Schema.optional),
|
||||
target: Schema.String.pipe(Schema.optional),
|
||||
targetUri: Schema.String.pipe(Schema.optional),
|
||||
problem: Schema.String.pipe(Schema.optional),
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Prompt extends Schema.Class<Prompt>("Prompt")({
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||
references: Schema.Array(ReferenceAttachment).pipe(Schema.optional),
|
||||
}) {
|
||||
static readonly equivalence = Schema.toEquivalence(Prompt)
|
||||
|
||||
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents" | "references">) {
|
||||
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents">) {
|
||||
return new Prompt({
|
||||
text: input.text,
|
||||
...(input.files === undefined ? {} : { files: input.files }),
|
||||
...(input.agents === undefined ? {} : { agents: input.agents }),
|
||||
...(input.references === undefined ? {} : { references: input.references }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
|||
metadata: {
|
||||
...message.metadata,
|
||||
...(message.agents?.length ? { agents: message.agents } : {}),
|
||||
...(message.references?.length ? { references: message.references } : {}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
* Model-facing V2 exact-edit leaf. Relative paths resolve within the active
|
||||
* Location. Absolute paths inside that Location are accepted, while explicit
|
||||
* absolute external paths retain mutation capability through a separate
|
||||
* external_directory approval before edit approval. Named project references
|
||||
* are read-oriented and deliberately are not accepted by mutation tools.
|
||||
* external_directory approval before edit approval.
|
||||
*/
|
||||
export * as EditTool from "./edit"
|
||||
|
||||
|
|
@ -21,7 +20,7 @@ export const name = "edit"
|
|||
export const Input = Schema.Struct({
|
||||
path: Schema.String.annotate({
|
||||
description:
|
||||
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
|
||||
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
|
||||
}),
|
||||
oldString: Schema.String.annotate({ description: "Exact text to replace" }),
|
||||
newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }),
|
||||
|
|
@ -100,7 +99,7 @@ export const layer = Layer.effectDiscard(
|
|||
[name]: Tool.withPermission(
|
||||
Tool.make({
|
||||
description:
|
||||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
|
||||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@ export const Input = Schema.Struct({
|
|||
path: LocationSearch.FilesInput.fields.path.annotate({
|
||||
description: "Relative directory to search. Defaults to the active Location.",
|
||||
}),
|
||||
reference: LocationSearch.FilesInput.fields.reference.annotate({
|
||||
description: "Named project reference to search instead of the active Location",
|
||||
}),
|
||||
limit: LocationSearch.FilesInput.fields.limit.annotate({
|
||||
description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
|
||||
}),
|
||||
|
|
@ -41,8 +38,6 @@ export const toModelOutput = (output: ModelOutput) => {
|
|||
/**
|
||||
* Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
|
||||
* LocationSearch resolves the current root and owns containment and traversal.
|
||||
*
|
||||
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
|
||||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -55,20 +50,19 @@ export const layer = Layer.effectDiscard(
|
|||
.register({
|
||||
[name]: Tool.make({
|
||||
description:
|
||||
"Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
input: Input,
|
||||
output: LocationSearch.FilesResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* filesystem.resolveRoot({ path: input.path, reference: input.reference })
|
||||
const root = yield* filesystem.resolveRoot({ path: input.path })
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: input.reference,
|
||||
path: input.path,
|
||||
limit: input.limit,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,9 +18,6 @@ export const Input = Schema.Struct({
|
|||
path: LocationSearch.GrepInput.fields.path.annotate({
|
||||
description: "Relative file or directory to search. Defaults to the active Location.",
|
||||
}),
|
||||
reference: LocationSearch.GrepInput.fields.reference.annotate({
|
||||
description: "Named project reference to search instead of the active Location",
|
||||
}),
|
||||
include: LocationSearch.GrepInput.fields.include.annotate({
|
||||
description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
|
||||
}),
|
||||
|
|
@ -56,8 +53,6 @@ export const toModelOutput = (output: Output) => {
|
|||
/**
|
||||
* Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
|
||||
* LocationSearch resolves the current root and owns containment and ripgrep execution.
|
||||
*
|
||||
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
|
||||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -70,7 +65,7 @@ export const layer = Layer.effectDiscard(
|
|||
.register({
|
||||
[name]: Tool.make({
|
||||
description:
|
||||
"Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
|
||||
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
|
||||
input: Input,
|
||||
output: LocationSearch.GrepResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
|
|
@ -83,7 +78,6 @@ export const layer = Layer.effectDiscard(
|
|||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: input.reference,
|
||||
path: input.path,
|
||||
include: input.include,
|
||||
limit: input.limit,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
* Model-facing V2 file-write leaf. Relative paths resolve within the active
|
||||
* Location. Absolute paths inside that Location are accepted, while explicit
|
||||
* absolute external paths retain mutation capability through a separate
|
||||
* external_directory approval before edit approval. Named project references
|
||||
* are read-oriented and deliberately are not accepted by mutation tools.
|
||||
* external_directory approval before edit approval.
|
||||
*/
|
||||
export * as WriteTool from "./write"
|
||||
|
||||
|
|
@ -21,7 +20,7 @@ export const name = "write"
|
|||
export const Input = Schema.Struct({
|
||||
path: Schema.String.annotate({
|
||||
description:
|
||||
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
|
||||
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
|
||||
}),
|
||||
content: Schema.String.annotate({ description: "Content to write to the file" }),
|
||||
})
|
||||
|
|
@ -55,7 +54,7 @@ export const layer = Layer.effectDiscard(
|
|||
[name]: Tool.withPermission(
|
||||
Tool.make({
|
||||
description:
|
||||
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
|
||||
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
|
|
|
|||
|
|
@ -7,25 +7,14 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
|||
import { Location } from "@opencode-ai/core/location"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const inertReferences = ProjectReference.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
get: () => Effect.succeed(undefined),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath: () => Effect.void,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
})
|
||||
|
||||
function provide(
|
||||
directory: string,
|
||||
references = inertReferences,
|
||||
filesystem = FSUtil.defaultLayer,
|
||||
data = Global.Path.data,
|
||||
) {
|
||||
|
|
@ -36,7 +25,6 @@ function provide(
|
|||
filesystem,
|
||||
Ripgrep.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
Layer.succeed(ProjectReference.Service, references),
|
||||
Global.layerWith({ data }),
|
||||
),
|
||||
),
|
||||
|
|
@ -69,7 +57,7 @@ describe("FileSystem", () => {
|
|||
expect((yield* service.resolveRoot({ path: output })).real).toBe(output)
|
||||
expect(yield* Effect.exit(service.read({ path: unrelated }))).toMatchObject({ _tag: "Failure" })
|
||||
expect(yield* Effect.exit(service.read({ path: managed }))).toMatchObject({ _tag: "Failure" })
|
||||
}).pipe(provide(worktree, inertReferences, FSUtil.defaultLayer, data))
|
||||
}).pipe(provide(worktree, FSUtil.defaultLayer, data))
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -258,7 +246,7 @@ describe("FileSystem", () => {
|
|||
}
|
||||
yield* Effect.promise(() => fs.rename(text, text + ".moved"))
|
||||
yield* Effect.promise(() => fs.rename(binary, binary + ".moved"))
|
||||
}).pipe(provide(directory, inertReferences, filesystem))
|
||||
}).pipe(provide(directory, filesystem))
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -344,7 +332,7 @@ describe("FileSystem", () => {
|
|||
next: 3,
|
||||
})
|
||||
expect(realPaths.filter((target) => target !== directory)).toEqual([path.join(directory, "alpha.txt")])
|
||||
}).pipe(provide(directory, inertReferences, filesystem))
|
||||
}).pipe(provide(directory, filesystem))
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -380,7 +368,7 @@ describe("FileSystem", () => {
|
|||
|
||||
expect((yield* service.listPage({ limit: 32 })).entries).toHaveLength(32)
|
||||
expect(maximum).toBe(16)
|
||||
}).pipe(provide(directory, inertReferences, filesystem))
|
||||
}).pipe(provide(directory, filesystem))
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -402,9 +390,8 @@ describe("FileSystem", () => {
|
|||
),
|
||||
)
|
||||
|
||||
test("rejects empty list aliases and page limits over 2000", () => {
|
||||
test("rejects page limits over 2000", () => {
|
||||
const decode = Schema.decodeUnknownSync(FileSystem.ListPageInput)
|
||||
expect(() => decode({ reference: "" })).toThrow()
|
||||
expect(() => decode({ limit: 2_001 })).toThrow()
|
||||
})
|
||||
|
||||
|
|
@ -461,125 +448,4 @@ describe("FileSystem", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("reads and lists paths relative to a local project reference", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(path.join(docs, "README.md"), "docs")
|
||||
})
|
||||
const service = yield* FileSystem.Service
|
||||
|
||||
expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({
|
||||
type: "text",
|
||||
content: "docs",
|
||||
})
|
||||
expect(yield* service.list({ reference: "docs" })).toMatchObject([{ path: "README.md", type: "file" }])
|
||||
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("materializes Git references before filesystem access", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
const ensured: string[] = []
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(path.join(docs, "README.md"), "docs")
|
||||
})
|
||||
expect(
|
||||
yield* (yield* FileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
|
||||
).toMatchObject({ content: "docs" })
|
||||
expect(ensured).toEqual([docs])
|
||||
}).pipe(
|
||||
provide(
|
||||
directory,
|
||||
references(
|
||||
{
|
||||
sdk: {
|
||||
name: "sdk",
|
||||
kind: "git",
|
||||
repository: "owner/repo",
|
||||
reference: Repository.parseRemote("owner/repo"),
|
||||
path: docs,
|
||||
},
|
||||
},
|
||||
(target) => Effect.sync(() => ensured.push(target ?? "")),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects unknown, invalid, and escaping project reference paths", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(docs))
|
||||
const service = yield* FileSystem.Service
|
||||
expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true)
|
||||
expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true)
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* service.read({ reference: "docs", path: RelativePath.make("../outside") }).pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
}).pipe(
|
||||
provide(
|
||||
directory,
|
||||
references({
|
||||
docs: { name: "docs", kind: "local", path: docs },
|
||||
invalid: { name: "invalid", kind: "invalid", message: "invalid reference" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects aliases when project references are disabled", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
expect(Exit.isFailure(yield* (yield* FileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit))).toBe(
|
||||
true,
|
||||
)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects symlink escapes from project references", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
const outside = path.join(directory, "outside.txt")
|
||||
return Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(outside, "outside")
|
||||
await fs.symlink(outside, path.join(docs, "link.txt"))
|
||||
})
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* (yield* FileSystem.Service)
|
||||
.read({ reference: "docs", path: RelativePath.make("link.txt") })
|
||||
.pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function references(
|
||||
entries: Record<string, ProjectReference.Resolved>,
|
||||
ensurePath: ProjectReference.Interface["ensurePath"] = () => Effect.void,
|
||||
) {
|
||||
return ProjectReference.Service.of({
|
||||
list: () => Effect.succeed(Object.values(entries)),
|
||||
get: (name) => Effect.succeed(entries[name]),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { Global } from "../src/global"
|
|||
import { ModelsDev } from "../src/models-dev"
|
||||
import { Npm } from "../src/npm"
|
||||
import { Project } from "../src/project"
|
||||
import { ProjectReference } from "../src/project-reference"
|
||||
import { Reference } from "../src/reference"
|
||||
import { LocationSearch } from "../src/location-search"
|
||||
import { ToolRegistry } from "../src/tool/registry"
|
||||
import { ApplicationTools } from "../src/tool/application-tools"
|
||||
|
|
@ -71,7 +71,7 @@ describe("LocationServiceMap", () => {
|
|||
const update = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
yield* PluginBoot.Service.use((boot) => boot.wait())
|
||||
yield* ProjectReference.Service
|
||||
yield* Reference.Service
|
||||
yield* LocationSearch.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const transform = yield* catalog.transform()
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ describe("LocationMutation", () => {
|
|||
),
|
||||
)
|
||||
|
||||
test("keeps project references outside the mutation input API", () => {
|
||||
test("ignores unknown mutation input fields", () => {
|
||||
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
|
||||
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||
path: "README.md",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { FileSystem } from "@opencode-ai/core/filesystem"
|
|||
import { LocationSearch } from "@opencode-ai/core/location-search"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
|
@ -16,15 +15,12 @@ import { tmpdir } from "./fixture/tmpdir"
|
|||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const inertReferences = references({})
|
||||
|
||||
function provide(directory: string, projectReferences = inertReferences, data = Global.Path.data) {
|
||||
function provide(directory: string, data = Global.Path.data) {
|
||||
const dependencies = Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
FileSystemRipgrep.defaultLayer,
|
||||
AppProcess.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
Layer.succeed(ProjectReference.Service, projectReferences),
|
||||
Global.layerWith({ data }),
|
||||
)
|
||||
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
|
||||
|
|
@ -56,7 +52,7 @@ describe("LocationSearch", () => {
|
|||
const search = yield* LocationSearch.Service
|
||||
const result = yield* search.grep({ pattern: "FAIL", path: output })
|
||||
expect(result.items).toMatchObject([{ canonical: output, line: 2, lines: "FAIL here\n" }])
|
||||
}).pipe(provide(directory, inertReferences, data))
|
||||
}).pipe(provide(directory, data))
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -83,7 +79,7 @@ describe("LocationSearch", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("searches files under a relative subdirectory and named local reference", () =>
|
||||
it.live("searches files under a relative subdirectory", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
return Effect.gen(function* () {
|
||||
|
|
@ -98,11 +94,7 @@ describe("LocationSearch", () => {
|
|||
expect(
|
||||
(yield* search.files({ pattern: "*.ts", path: RelativePath.make("src") })).items.map((item) => item.path),
|
||||
).toEqual([RelativePath.make("src/active.ts")])
|
||||
const guide = yield* Effect.promise(() => fs.realpath(path.join(docs, "guide.md")))
|
||||
expect((yield* search.files({ pattern: "*.md", reference: "docs" })).items).toMatchObject([
|
||||
{ path: RelativePath.make("guide.md"), resource: "docs:guide.md", canonical: guide },
|
||||
])
|
||||
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
|
||||
}).pipe(provide(directory))
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -264,13 +256,3 @@ describe("LocationSearch", () => {
|
|||
expect(() => decode({ pattern: "*", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
function references(entries: Record<string, ProjectReference.Resolved>) {
|
||||
return ProjectReference.Service.of({
|
||||
list: () => Effect.succeed(Object.values(entries)),
|
||||
get: (name) => Effect.succeed(entries[name]),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath: () => Effect.void,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,299 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigReference } from "@opencode-ai/core/config/reference"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
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 },
|
||||
Effect.sync(() => {
|
||||
expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(true)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap(() =>
|
||||
withEnv(
|
||||
{ OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: "false" },
|
||||
Effect.sync(() => {
|
||||
expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("normalizes aliases and resolves relative local paths from the project root", () =>
|
||||
withTmp((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const nested = path.join(project, "packages", "app")
|
||||
yield* Effect.promise(() => fs.mkdir(nested, { recursive: true }))
|
||||
|
||||
const references = ProjectReference.resolveAll({
|
||||
references: ConfigReference.normalize({
|
||||
docs: { path: "./docs" },
|
||||
home: "~/notes",
|
||||
sdk: { repository: "owner/repo", branch: "main" },
|
||||
shorthand: "owner/other",
|
||||
invalid: "not-a-repo",
|
||||
"bad/name": "owner/repo",
|
||||
}),
|
||||
directory: project,
|
||||
home: path.join(tmp.path, "home"),
|
||||
repos: path.join(tmp.path, "repos"),
|
||||
})
|
||||
|
||||
expect(references).toMatchObject([
|
||||
{ name: "docs", kind: "local", path: path.join(project, "docs") },
|
||||
{ name: "home", kind: "local", path: path.join(tmp.path, "home", "notes") },
|
||||
{ name: "sdk", kind: "git", branch: "main" },
|
||||
{ name: "shorthand", kind: "git" },
|
||||
{ name: "invalid", kind: "invalid", repository: "not-a-repo" },
|
||||
{ name: "bad/name", kind: "invalid" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("marks same-cache references with different branches invalid", () =>
|
||||
Effect.sync(() => {
|
||||
const references = ProjectReference.resolveAll({
|
||||
references: ConfigReference.normalize({
|
||||
main: { repository: "owner/repo", branch: "main" },
|
||||
dev: { repository: "github.com/owner/repo", branch: "dev" },
|
||||
alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
|
||||
}),
|
||||
directory: "/project",
|
||||
home: "/home",
|
||||
repos: "/repos",
|
||||
})
|
||||
|
||||
expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"])
|
||||
expect(references[1]?.kind === "invalid" ? references[1].message : "").toContain("conflicts with @main")
|
||||
}),
|
||||
)
|
||||
|
||||
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")
|
||||
})
|
||||
|
||||
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)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
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)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("starts Git materialization in the background without blocking the location layer", () =>
|
||||
withTmp((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
yield* withReferences(
|
||||
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")),
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer({
|
||||
directory: tmp.path,
|
||||
project: tmp.path,
|
||||
repos: path.join(tmp.path, "repos"),
|
||||
documents: [document({ sdk: "owner/repo" })],
|
||||
ensure: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function document(references: ConfigReference.Info) {
|
||||
return new Config.Document({ type: "document", info: Schema.decodeUnknownSync(Config.Info)({ references }) })
|
||||
}
|
||||
|
||||
function result(
|
||||
repos: string,
|
||||
calls: RepositoryCache.EnsureInput[],
|
||||
input: RepositoryCache.EnsureInput,
|
||||
): RepositoryCache.Result {
|
||||
calls.push(input)
|
||||
return {
|
||||
repository: input.reference.label,
|
||||
host: input.reference.host,
|
||||
remote: input.reference.remote,
|
||||
localPath: Repository.cachePath(repos, input.reference),
|
||||
status: "cached",
|
||||
branch: input.branch,
|
||||
}
|
||||
}
|
||||
|
||||
function testLayer(input: {
|
||||
directory: string
|
||||
project: string
|
||||
repos: string
|
||||
documents: Config.Document[]
|
||||
ensure: RepositoryCache.Interface["ensure"]
|
||||
}) {
|
||||
return ProjectReference.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
Global.layerWith({ home: path.join(input.directory, "home"), repos: input.repos }),
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(input.directory) },
|
||||
{ projectDirectory: AbsolutePath.make(input.project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(input.documents) })),
|
||||
Layer.succeed(RepositoryCache.Service, RepositoryCache.Service.of({ ensure: input.ensure })),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(body: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
body,
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
}
|
||||
|
||||
function withReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
|
||||
return withEnv({ OPENCODE_EXPERIMENTAL_REFERENCES: "true" }, body)
|
||||
}
|
||||
|
||||
function withoutReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
|
||||
return withEnv({ OPENCODE_EXPERIMENTAL: undefined, OPENCODE_EXPERIMENTAL_REFERENCES: undefined }, body)
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(env: Record<string, string | undefined>, body: Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]))
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
return previous
|
||||
}),
|
||||
() => body,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
57
packages/core/test/reference.test.ts
Normal file
57
packages/core/test/reference.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Scope } from "effect"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const cache = Layer.mock(RepositoryCache.Service, {
|
||||
ensure: () => Effect.die("unexpected Git materialization"),
|
||||
})
|
||||
|
||||
describe("Reference", () => {
|
||||
it.effect("registers normalized sources for the owning scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const scope = yield* Scope.make()
|
||||
const update = yield* references.transform().pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const path = AbsolutePath.make("/docs")
|
||||
yield* update((editor) => editor.add("docs", new Reference.LocalSource({ type: "local", path })))
|
||||
|
||||
expect(yield* references.list()).toEqual([
|
||||
new Reference.Info({ name: "docs", path, source: new Reference.LocalSource({ type: "local", path }) }),
|
||||
])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* references.list()).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provide(Reference.layer),
|
||||
Effect.provide(cache),
|
||||
Effect.provide(EventV2.defaultLayer),
|
||||
Effect.provide(Global.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("derives Git paths without exposing cache operations", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const update = yield* references.transform()
|
||||
const repository = Repository.parseRemote("owner/repo")
|
||||
const source = new Reference.GitSource({ type: "git", repository: "owner/repo", branch: "main" })
|
||||
yield* update((editor) => editor.add("sdk", source))
|
||||
|
||||
expect(yield* references.list()).toEqual([
|
||||
new Reference.Info({ name: "sdk", path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)), source }),
|
||||
])
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(Reference.layer),
|
||||
Effect.provide(cache),
|
||||
Effect.provide(EventV2.defaultLayer),
|
||||
Effect.provide(Global.defaultLayer),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -4,7 +4,7 @@ import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
|||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { AgentAttachment, FileAttachment, ReferenceAttachment } from "@opencode-ai/core/session/prompt"
|
||||
import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
|
|
@ -17,7 +17,6 @@ const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.
|
|||
describe("toLLMMessages", () => {
|
||||
test("maps every top-level V2 Session message type", () => {
|
||||
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
|
||||
const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.AgentSwitched({
|
||||
|
|
@ -44,7 +43,6 @@ describe("toLLMMessages", () => {
|
|||
text: "Inspect this image",
|
||||
files: [file],
|
||||
agents: [new AgentAttachment({ name: "build" })],
|
||||
references: [reference],
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.Synthetic({
|
||||
|
|
@ -84,7 +82,7 @@ describe("toLLMMessages", () => {
|
|||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
|
||||
],
|
||||
metadata: { agents: [{ name: "build" }], references: [reference] },
|
||||
metadata: { agents: [{ name: "build" }] },
|
||||
}),
|
||||
)
|
||||
expect(messages.slice(2).map((message) => message.content)).toEqual([
|
||||
|
|
|
|||
|
|
@ -43,12 +43,10 @@ const filesystem = Layer.succeed(
|
|||
Effect.sync(() => {
|
||||
resolutions.push(input)
|
||||
const relative = input.path ?? RelativePath.make(".")
|
||||
const resource = input.reference === undefined ? relative : `${input.reference}:${relative}`
|
||||
return new FileSystem.RootTarget({
|
||||
real: `/project/${relative}`,
|
||||
root: "/project",
|
||||
resource,
|
||||
reference: input.reference,
|
||||
resource: relative,
|
||||
type: "directory",
|
||||
})
|
||||
}),
|
||||
|
|
@ -122,10 +120,10 @@ describe("GlobTool", () => {
|
|||
action: "glob",
|
||||
resources: ["**/*.ts"],
|
||||
save: ["*"],
|
||||
metadata: { root: "src", reference: undefined, path: "src", limit: 12 },
|
||||
metadata: { root: "src", path: "src", limit: 12 },
|
||||
},
|
||||
])
|
||||
expect(resolutions).toEqual([{ path: RelativePath.make("src"), reference: undefined }])
|
||||
expect(resolutions).toEqual([{ path: RelativePath.make("src") }])
|
||||
expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
|
||||
}),
|
||||
)
|
||||
|
|
@ -169,39 +167,6 @@ describe("GlobTool", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("searches named references with root and reference metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
result = new LocationSearch.FilesResult({
|
||||
items: [
|
||||
new LocationSearch.File({
|
||||
path: RelativePath.make("guide.md"),
|
||||
canonical: "/project/docs/guide.md",
|
||||
resource: "docs:guide.md",
|
||||
mtime: 1,
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
partial: false,
|
||||
})
|
||||
|
||||
expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.md", reference: "docs" }))).toEqual({
|
||||
type: "text",
|
||||
value: "docs:guide.md",
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "glob",
|
||||
resources: ["*.md"],
|
||||
save: ["*"],
|
||||
metadata: { root: "docs:.", reference: "docs", path: undefined, limit: undefined },
|
||||
},
|
||||
])
|
||||
expect(searches).toEqual([{ pattern: "*.md", reference: "docs" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("formats bounded and partial results without discarding structured output", () =>
|
||||
Effect.sync(() => {
|
||||
const output = new LocationSearch.FilesResult({
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgr
|
|||
import { LocationSearch } from "@opencode-ai/core/location-search"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
|
|
@ -39,8 +38,7 @@ const filesystem = Layer.succeed(
|
|||
new FileSystem.RootTarget({
|
||||
real: `/project/${input.path ?? "."}`,
|
||||
root: "/project",
|
||||
resource: input.reference === undefined ? (input.path ?? ".") : `${input.reference}:${input.path ?? "."}`,
|
||||
reference: input.reference,
|
||||
resource: input.path ?? ".",
|
||||
type: "directory",
|
||||
}),
|
||||
),
|
||||
|
|
@ -115,23 +113,12 @@ const reset = () => {
|
|||
result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
|
||||
}
|
||||
|
||||
function references(entries: Record<string, ProjectReference.Resolved>) {
|
||||
return ProjectReference.Service.of({
|
||||
list: () => Effect.succeed(Object.values(entries)),
|
||||
get: (name) => Effect.succeed(entries[name]),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath: () => Effect.void,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
})
|
||||
}
|
||||
|
||||
function provideLive(directory: string, projectReferences = references({})) {
|
||||
function provideLive(directory: string) {
|
||||
const dependencies = Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
FileSystemRipgrep.defaultLayer,
|
||||
AppProcess.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
Layer.succeed(ProjectReference.Service, projectReferences),
|
||||
)
|
||||
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
|
||||
const search = LocationSearch.layer.pipe(
|
||||
|
|
@ -170,29 +157,13 @@ describe("GrepTool", () => {
|
|||
action: "grep",
|
||||
resources: ["needle"],
|
||||
save: ["*"],
|
||||
metadata: { root: "src", reference: undefined, path: RelativePath.make("src"), include: "*.ts", limit: 2 },
|
||||
metadata: { root: "src", path: RelativePath.make("src"), include: "*.ts", limit: 2 },
|
||||
},
|
||||
])
|
||||
expect(searches).toEqual([{ pattern: "needle", path: RelativePath.make("src"), include: "*.ts", limit: 2 }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("delegates named reference grep and exposes the canonical selected root in metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
|
||||
yield* execute({ pattern: "guide", path: "docs", reference: "manual", include: "*.md" })
|
||||
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: ["guide"],
|
||||
metadata: { root: "manual:docs", reference: "manual", path: RelativePath.make("docs"), include: "*.md" },
|
||||
})
|
||||
expect(searches).toEqual([
|
||||
{ pattern: "guide", path: RelativePath.make("docs"), reference: "manual", include: "*.md" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not search when permission is denied", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
|
|
@ -248,7 +219,7 @@ describe("GrepTool", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
runtimeIt.live("greps active Location and named-reference files with include globs", () =>
|
||||
runtimeIt.live("greps active Location files with include globs", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
|
|
@ -259,23 +230,15 @@ describe("GrepTool", () => {
|
|||
reset()
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "src"))
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(path.join(tmp.path, "src", "index.ts"), "needle ts\n")
|
||||
await fs.writeFile(path.join(tmp.path, "src", "notes.txt"), "needle txt\n")
|
||||
await fs.writeFile(path.join(docs, "guide.md"), "needle docs\n")
|
||||
})
|
||||
|
||||
expect(yield* execute({ pattern: "needle", path: "src", include: "*.ts" })).toEqual({
|
||||
type: "text",
|
||||
value: "Found 1 matches\nsrc/index.ts:\n Line 1: needle ts\n",
|
||||
})
|
||||
expect(yield* execute({ pattern: "needle", reference: "docs", include: "*.md" })).toEqual({
|
||||
type: "text",
|
||||
value: "Found 1 matches\ndocs:guide.md:\n Line 1: needle docs\n",
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(provideLive(tmp.path, references({ docs: { name: "docs", kind: "local", path: docs } }))),
|
||||
)
|
||||
}).pipe(Effect.provide(provideLive(tmp.path)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const filesystem = Layer.succeed(
|
|||
? Effect.succeed(
|
||||
new FileSystem.ReadPath({
|
||||
type: resolvedType,
|
||||
resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`,
|
||||
resource: input.path,
|
||||
}),
|
||||
)
|
||||
: Effect.die(resolveFailure),
|
||||
|
|
@ -457,20 +457,6 @@ describe("ReadTool", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("authorizes project references with their canonical identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
|
||||
})
|
||||
|
||||
expect(assertions).toMatchObject([{ resources: ["docs:README.md"] }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves unexpected resolution defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue