Files
oh-my-pi/packages/coding-agent/test/discovery/context-file-dedup.test.ts
T
sundbp 16d7f9aec0 feat: monorepo-friendly discovery for AGENTS.md and skills (#293)
* feat: monorepo-friendly discovery for AGENTS.md and skills

Support hierarchical config discovery in monorepos by walking up from
cwd through ancestor directories, combining files from all levels
instead of only checking the immediate working directory.

AGENTS.md context files:
- Change dedup key from file.level to file.level + depth, so context
  files at different directory levels coexist instead of shadowing
- Root AGENTS.md and sub-project AGENTS.md are both included in the
  system prompt, ordered from least specific to most specific

Skills:
- Modify all 4 providers with project-level skill directories (native,
  claude, codex, opencode) to walk up from cwd through ancestors,
  scanning for skills at each level
- Skills at closer directories win on name conflicts via existing
  name-based dedup

Repo root boundary:
- Add findRepoRoot() utility that detects .git to identify repo root
- Add repoRoot field to LoadContext, computed once in loadCapability()
- All walk-up traversals (AGENTS.md, skills, nearest config dir) stop
  at the repo root, preventing discovery from leaking outside the repo
- When not in a git repo, falls back to walking to filesystem root

Files changed:
- capability/fs.ts: add findRepoRoot()
- capability/types.ts: add repoRoot to LoadContext
- capability/index.ts: compute repoRoot in loadCapability()
- capability/context-file.ts: depth-aware dedup key
- discovery/agents-md.ts: bound walk-up at repo root
- discovery/builtin.ts: getAncestorDirs stopAt param, skill walk-up
- discovery/claude.ts: skill walk-up with repo root bound
- discovery/codex.ts: skill walk-up with repo root bound
- discovery/opencode.ts: skill walk-up with repo root bound
- extensibility/skills.ts: add repoRoot to inline LoadContext

* feat(agents-provider): add project-level discovery with ancestor walk-up for all capability types

The agents provider (.agent/.agents directories) previously only loaded
capabilities from the user home directory (~/.agent/, ~/.agents/). This
meant project-level .agents/ directories in monorepo roots were not
discovered when sessions started from subdirectories.

Add getProjectPathCandidates() helper that walks from cwd up to repoRoot,
scanning both .agent/ and .agents/ at each ancestor level. Apply this to
all six capability types: skills, rules, prompts, commands, context files
(AGENTS.md), and system prompts (SYSTEM.md). This matches the ancestor
walk-up behavior already present in the builtin (.omp), claude, codex,
and opencode providers.

All loaders now parallelize project-level and user-level scans via
Promise.all. Project-level results appear closest-first so dedup at the
capability layer picks the nearest override.

* fix(discovery): remove hard cap of 20 on ancestor directory walking

All walk-up loops already terminate naturally at repoRoot or filesystem
root. The depth < 20 / .slice(0, 20) / MAX_DEPTH caps were redundant
safety guards that would silently stop discovery in deeply nested
projects.

Removed from: agents.ts, agents-md.ts, builtin.ts, claude.ts, codex.ts,
opencode.ts, and corresponding test files.

* fix(agents-provider): set depth on project-level ContextFile entries for correct dedup

loadContextFiles was creating project-level ContextFile entries without
a depth field. Since contextFileCapability.key uses
`project:${file.depth ?? 0}`, all ancestor-level AGENTS.md files
collapsed to the same key and only the first survived dedup.

Compute depth via calculateDepth(cwd, ancestorDir) where ancestorDir is
two levels up from the file path (past the .agent/.agents config dir).
This gives each ancestor level a distinct dedup key.

* fix(discovery): stop ancestor walk-up at $HOME when not in a git repo

When repoRoot is null (no .git found), walk-up loops traversed all the
way to filesystem root. This caused $HOME to be scanned as a project-
level directory and then again as user-level, producing duplicates.

All providers now use `ctx.repoRoot ?? ctx.home` as the stop boundary:
stop at repoRoot if in a repo, otherwise stop at home. Applied to
agents.ts, agents-md.ts, builtin.ts, claude.ts, codex.ts, and
opencode.ts.

* fix(context-files): clamp depth >= 0 in dedup key and fix provider depth computations

The dedup key `project:${file.depth}` used raw depth, which could be
negative when providers computed it from config subdirectories (e.g.
.claude/, .github/, .gemini/) rather than the ancestor directory. This
caused same-scope cwd-level files to get distinct keys like project:-1
and project:0, bypassing dedup and injecting conflicting instructions.

Two-layer fix:
1. Key function: clamp to Math.max(0, depth) so any file at or below
   cwd is treated as cwd-scope (depth 0). Defensive against future
   providers.
2. Providers: fix root cause in claude.ts, gemini.ts, github.ts to
   compute depth from the ancestor directory (parent of the config
   subdir), not the config subdir itself.

---------

Co-authored-by: Can Bölük <can1357@users.noreply.github.com>
2026-03-04 22:36:55 +01:00

66 lines
2.7 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { type ContextFile, contextFileCapability } from "@oh-my-pi/pi-coding-agent/capability/context-file";
function makeContextFile(overrides: Partial<ContextFile> & Pick<ContextFile, "path" | "level">): ContextFile {
return {
content: `content of ${overrides.path}`,
depth: undefined,
_source: { provider: "test", providerName: "Test", path: overrides.path, level: overrides.level },
...overrides,
};
}
describe("contextFileCapability.key", () => {
const key = contextFileCapability.key.bind(contextFileCapability);
test("user-level files share the same key regardless of depth", () => {
const a = makeContextFile({ path: "/home/user/.omp/agent/AGENTS.md", level: "user" });
const b = makeContextFile({ path: "/home/user/.claude/CLAUDE.md", level: "user" });
expect(key(a)).toBe("user");
expect(key(b)).toBe("user");
expect(key(a)).toBe(key(b));
});
test("project-level files at the same depth share the same key", () => {
const a = makeContextFile({ path: "/repo/AGENTS.md", level: "project", depth: 0 });
const b = makeContextFile({ path: "/repo/.claude/CLAUDE.md", level: "project", depth: 0 });
expect(key(a)).toBe(key(b));
});
test("project-level files at different depths have different keys", () => {
const atCwd = makeContextFile({ path: "/repo/packages/app/AGENTS.md", level: "project", depth: 0 });
const atParent = makeContextFile({ path: "/repo/packages/AGENTS.md", level: "project", depth: 1 });
const atRoot = makeContextFile({ path: "/repo/AGENTS.md", level: "project", depth: 2 });
expect(key(atCwd)).not.toBe(key(atParent));
expect(key(atParent)).not.toBe(key(atRoot));
expect(key(atCwd)).not.toBe(key(atRoot));
});
test("project-level file with no depth uses 0 as default", () => {
const withDepth = makeContextFile({ path: "/repo/AGENTS.md", level: "project", depth: 0 });
const noDepth = makeContextFile({ path: "/repo/AGENTS.md", level: "project" });
expect(key(withDepth)).toBe(key(noDepth));
});
test("user key never collides with any project key", () => {
const user = makeContextFile({ path: "/home/user/.omp/AGENTS.md", level: "user" });
for (let depth = 0; depth < 20; depth++) {
const project = makeContextFile({ path: `/repo/AGENTS.md`, level: "project", depth });
expect(key(user)).not.toBe(key(project));
}
});
});
describe("contextFileCapability.validate", () => {
test("accepts valid context file", () => {
const file = makeContextFile({ path: "/repo/AGENTS.md", level: "project", depth: 0 });
expect(contextFileCapability.validate!(file)).toBeUndefined();
});
test("rejects missing path", () => {
const file = makeContextFile({ path: "", level: "project" });
expect(contextFileCapability.validate!(file)).toBe("Missing path");
});
});