Skip to content
Closed
46 changes: 45 additions & 1 deletion UserGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,48 @@

## Janna - X

## Nate - X
## Nate - Finding related tests of a given file (`/associate`).

The `/associate` command gives developers the ability to choose specific files in the current codebase and explore how the functions within are being tested.

### How to use it

In an Opencode repository session, run `/associate [file path]`. The LLM will run a structured prompt based on the file given by the user. The LLM will then return a markdown file specifying the functions, their locations, the locations of their tests, and the way it tests the model. The response will also note functions that are not tested.

Some examples of correct command call:

/associate src/math.ts
/associate ./src/math.ts
/associate "src/files with spaces/math.ts"
/associate 'src/files with spaces/math.ts'
/associate 'absolute/path/to/current-project/src/math.ts
/associate src/link-to-math.ts
/associate

If a single valid file is included, the LLM will be queried.
- a valid file is inside the project and readable

### User testing

1. Start Opencode and ensure a model is configured. Then open a session rooted in the repository that needs inspecting.
2. Run `/associate [file path]` and confirm that it is accepted.
3. Verify that a markdown table is shared in the terminal separated into columns of Function, Source, Test(s), and association.
4. Check that the information for each function is correct including file location and line number. Association is split into direct, indirect, or untested. Manually verify that the claimed relationships are real.

### Automated tests

- [`packages/opencode/test/session/prompt.test.ts`](packages/opencode/test/session/prompt.test.ts) has 18 tests.
They cover valid inputs, response structure, potential errors, and exposed meta data.
- [`packages/tui/test/associate.e2e.test.ts`](packages/tui/test/associate.e2e.test.ts) has one end-to-end TUI test using a mock llm and mocked event transports. It covers a normal flow of a /associate call including simulating user inputs, server responses, and ensuring the TUI updates to asynchronous events. The test confirms that the command is recognized and parsed into parts and the response becomes visible in the terminal UI.


From Packages/opencode run:
```bash
bun test test/session/prompt.test.ts --test-name-pattern associate
bun typecheck
```
From packages/tui run:
```bash
bun test test/associate.e2e.test.ts
bun typecheck
```
12 changes: 9 additions & 3 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import PROMPT_ASSOCIATE from "./template/associate.txt"
import { LegacyEvent } from "@opencode-ai/schema/legacy-event"

type State = {
commands: Record<string, Info>
commands: Record<string, RuntimeInfo>
}

export const Event = {
Expand All @@ -33,6 +33,10 @@ export const Info = Schema.Struct({
}).annotate({ identifier: "Command" })

export type Info = Omit<Schema.Schema.Type<typeof Info>, "template"> & { template: Promise<string> | string }
type RuntimeInfo = Info & {
maxArguments?: number
fileArgument?: boolean
}

export function hints(template: string) {
const result: string[] = []
Expand All @@ -51,7 +55,7 @@ export const Default = {
} as const

export interface Interface {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly get: (name: string) => Effect.Effect<RuntimeInfo | undefined>
readonly list: () => Effect.Effect<Info[]>
}

Expand All @@ -67,7 +71,7 @@ const layer = Layer.effect(
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
const cfg = yield* config.get()
const bridge = yield* EffectBridge.make()
const commands: Record<string, Info> = {}
const commands: Record<string, RuntimeInfo> = {}

commands[Default.INIT] = {
name: Default.INIT,
Expand All @@ -93,6 +97,8 @@ const layer = Layer.effect(
description: "associate functions in a file with their tests",
source: "command",
template: PROMPT_ASSOCIATE,
maxArguments: 1,
fileArgument: true,
hints: hints(PROMPT_ASSOCIATE),
}

Expand Down
32 changes: 32 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1382,6 +1382,38 @@ const layer = Layer.effect(

const raw = input.arguments.match(argsRegex) ?? []
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
if (cmd.maxArguments !== undefined && args.length > cmd.maxArguments) {
const error = new NamedError.Unknown({
message: `Command "/${cmd.name}" accepts at most ${cmd.maxArguments} argument${cmd.maxArguments === 1 ? "" : "s"}.`,
})
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
throw error
}
if (cmd.fileArgument && args[0]) {
const ctx = yield* InstanceState.context
const root = yield* fsys.resolve(ctx.worktree)
const filepath = yield* fsys.resolve(path.isAbsolute(args[0]) ? args[0] : path.resolve(root, args[0]))
const relative = path.relative(root, filepath)
if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) {
const error = new NamedError.Unknown({ message: `File must be inside the project: "${args[0]}".` })
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
throw error
}
if (!(yield* fsys.isFile(filepath))) {
const error = new NamedError.Unknown({ message: `File not found: "${args[0]}".` })
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
throw error
}
const readable = yield* fsys.access(filepath, { readable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (!readable) {
const error = new NamedError.Unknown({ message: `File is not readable: "${args[0]}".` })
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
throw error
}
}
const templateCommand = yield* Effect.promise(async () => cmd.template)

const placeholders = templateCommand.match(placeholderRegex) ?? []
Expand Down
Loading
Loading