Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Monorepo / Cargo-workspace support for dynamic rule discovery: the per-target project
root is widened to the enclosing git repository root (`widenToRepositoryRoot`). Target
files inside workspace members (nested `Cargo.toml`/`package.json` markers) now discover
`.github/instructions/` rule directories from the workspace and repository levels as
well; each rule's glob semantics stay keyed to the directory owning the rule file via the
scopeRelative path base. Static (always-on) discovery is unchanged.
- Native context dedup in the `tool_result` path: single-file rules (AGENTS.md/CLAUDE.md)
that pi already loaded natively into the system prompt are no longer re-injected per
matching file read when dynamic discovery walks to the repository root.

- Matcher cache reset and stats helpers for deterministic cache verification.

### Changed
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ After installation, restart pi (or run `/reload` inside an interactive session).

These use **walk-up stack semantics**: from the target file's directory up to the project root, rules at every level are collected. Closer directories win in precedence.

**Monorepos / workspaces:** `findProjectRoot` stops at the innermost project marker, so a
target inside a workspace member (e.g. `crates/<member>/src/*.rs`, where each member has
its own `Cargo.toml`) would never reach workspace-level rule directories. For dynamic
(per-target) discovery the walk is therefore widened to the innermost enclosing **git
repository root** (`.git`): rules placed at the repository, workspace or member level all
participate, and each rule's globs match relative to the directory that owns the rule file
(a rule in `backend/.github/instructions/` matches `backend`-relative globs, a rule in the
repository root's `.github/instructions/` matches repo-relative globs). A `.git` in the
user's home directory (dotfiles repositories) is ignored. Static (session-level) discovery
is unchanged.

### Project single-file rules

| File | Style |
Expand Down
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 38 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@ export default function piRulesExtension(pi: ExtensionAPI): void {
});
registerSlashCommands(pi, engine);

/**
* Absolute paths (and realpaths) of context files pi loaded natively into the system
* prompt. Rebuilt on every before_agent_start. Dynamic discovery walks to the repository
* root, so single-file rules (AGENTS.md/CLAUDE.md) from levels the agent has natively
* loaded must be deduplicated in the tool_result path as well — otherwise each matching
* read re-injects a context file that is already in the system prompt.
*/
const nativeContextPaths = new Set<string>();

/**
* Realpaths of single-file rules (AGENTS.md/CLAUDE.md) already injected dynamically in
* this session. Single-file rules match every target, so without session-level dedup
* each newly read file would re-inject the same context document. Cleared on session
* start and compaction together with the engine session state.
*/
const injectedSingleFileRules = new Set<string>();

function syncConfigFromFlags(): void {
const disabled = pi.getFlag("pi-rules-disabled");
const mode = pi.getFlag("pi-rules-mode");
Expand All @@ -59,6 +76,7 @@ export default function piRulesExtension(pi: ExtensionAPI): void {
pi.on("session_start", async (event, ctx) => {
syncConfigFromFlags();
engine.resetSession(ctx.cwd);
injectedSingleFileRules.clear();
if (engine.config.disabled) {
return undefined;
}
Expand All @@ -69,6 +87,7 @@ export default function piRulesExtension(pi: ExtensionAPI): void {

pi.on("session_compact", async (_event, ctx) => {
engine.resetSession(ctx.cwd);
injectedSingleFileRules.clear();
pi.appendEntry("pi-rules.scan", { cwd: ctx.cwd, reason: "compact" });
return undefined;
});
Expand All @@ -80,9 +99,11 @@ export default function piRulesExtension(pi: ExtensionAPI): void {
}

const loaded = engine.loadStaticRules(ctx.cwd);
const nativeContextPaths = new Set(
event.systemPromptOptions.contextFiles?.flatMap((contextFile) => pathKeys(contextFile.path)) ?? [],
);
nativeContextPaths.clear();
for (const path of event.systemPromptOptions.contextFiles?.flatMap((contextFile) => pathKeys(contextFile.path)) ??
[]) {
nativeContextPaths.add(path);
}
for (const rule of loaded.rules) {
if (nativeContextPaths.has(rule.path) || nativeContextPaths.has(rule.realPath)) {
engine.markStaticInjected(rule);
Expand Down Expand Up @@ -131,8 +152,18 @@ export default function piRulesExtension(pi: ExtensionAPI): void {
pendingFingerprints.map((target) => target.targetPath),
);
engine.commitDynamicTargetFingerprints(fingerprints);
for (const rule of loaded.rules) {
if (nativeContextPaths.has(rule.path) || nativeContextPaths.has(rule.realPath)) {
engine.markStaticInjected(rule);
}
}
const rules = loaded.rules.filter(
(rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(firstTargetPath, rule),
(rule) =>
!nativeContextPaths.has(rule.path) &&
!nativeContextPaths.has(rule.realPath) &&
!engine.isStaticInjected(rule) &&
!engine.isDynamicInjected(firstTargetPath, rule) &&
!(rule.isSingleFile && injectedSingleFileRules.has(rule.realPath)),
);
if (rules.length === 0) {
return undefined;
Expand All @@ -142,6 +173,9 @@ export default function piRulesExtension(pi: ExtensionAPI): void {
const block = engine.formatDynamic(rules, displayPath(ctx.cwd, firstPendingTarget));
for (const rule of rules) {
engine.markDynamicInjected(firstTargetPath, rule);
if (rule.isSingleFile) {
injectedSingleFileRules.add(rule.realPath);
}
}

return { content: [...event.content, { type: "text", text: block }] };
Expand Down
38 changes: 29 additions & 9 deletions src/rules/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { formatDynamicBlock, formatStaticBlock } from "./formatter.js";
import { hashContent, matchRule } from "./matcher.js";
import { sortCandidates } from "./ordering.js";
import { parseRule } from "./parser.js";
import { widenToRepositoryRoot } from "./project-root.js";
import type { LoadedRule, MatchReason, PiRulesConfig, RuleCandidate, RuleDiagnostic, SessionState } from "./types.js";

interface LoadedRuleContent {
Expand Down Expand Up @@ -162,12 +163,21 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
const realPathCache: RealPathCache = new Map();
const rootSingleFileSelections = new Set<string>();
for (const targetFile of targetFiles) {
const projectRoot = shouldCacheLookups
? findProjectRootCached(projectRootCache, targetFile, deps.findProjectRoot)
: deps.findProjectRoot(targetFile);
// Cargo/pnpm workspaces nest project markers (e.g. a per-member Cargo.toml), so the
// marker-based root would stop the walk below workspace-level rule directories.
// Widen to the enclosing git repository root so rules at repository and workspace
// level participate; scopeRelative keeps globs keyed to the rule file's scope.
// Canonicalize so macOS /var vs /private/var (and other symlink roots) still sit
// inside the realpathed project root used by findProjectRoot.
const discoveryTarget = canonicalizePath(targetFile);
const projectRoot = widenToRepositoryRoot(
shouldCacheLookups
? findProjectRootCached(projectRootCache, discoveryTarget, deps.findProjectRoot)
: deps.findProjectRoot(discoveryTarget),
);
const findOptions: Parameters<EngineDeps["findCandidates"]>[0] = {
projectRoot,
targetFile,
targetFile: discoveryTarget,
...(discoveryCache === undefined ? {} : { cache: discoveryCache }),
...(disabledSources === undefined ? {} : { disabledSources }),
};
Expand All @@ -193,7 +203,7 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
const matchReason = matchDynamicRuleCached(
dynamicMatchCache,
projectRoot,
targetFile,
discoveryTarget,
candidate,
loadedRule,
deps.matchRule ?? matchRule,
Expand Down Expand Up @@ -235,13 +245,15 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
const fingerprints: DynamicTargetFingerprint[] = [];

for (const targetFile of uniqueStrings(targetPaths)) {
const projectRoot =
cwdProjectRoot !== null && isSameOrChildPath(targetFile, cwdProjectRoot)
const discoveryTarget = canonicalizePath(targetFile);
const projectRoot = widenToRepositoryRoot(
cwdProjectRoot !== null && isSameOrChildPath(discoveryTarget, cwdProjectRoot)
? cwdProjectRoot
: deps.findProjectRoot(targetFile);
: deps.findProjectRoot(discoveryTarget),
);
const findOptions: Parameters<EngineDeps["findCandidates"]>[0] = {
projectRoot,
targetFile,
targetFile: discoveryTarget,
cache: discoveryCache,
...(disabledSources === undefined ? {} : { disabledSources }),
};
Expand Down Expand Up @@ -683,6 +695,14 @@ function uniqueStrings(values: ReadonlyArray<string>): string[] {
return [...new Set(values)];
}

function canonicalizePath(path: string): string {
try {
return realpathSync.native(path);
} catch {
return resolve(path);
}
}

function storeLastLoad(
state: SessionState,
rules: ReadonlyArray<LoadedRule>,
Expand Down
56 changes: 56 additions & 0 deletions src/rules/project-root.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { existsSync, realpathSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";

import { PROJECT_MARKERS } from "./constants.js";
Expand Down Expand Up @@ -40,3 +41,58 @@ export function findProjectRoot(startPath: string, markers: ReadonlyArray<string
currentDirectory = dirname(currentDirectory);
}
}

/**
* Widen a marker-based project root to the enclosing git repository root.
*
* Monorepos (e.g. Cargo workspaces, pnpm workspaces) nest project markers: a
* file inside `crates/<member>/src/` resolves its project root to the member
* crate (`crates/<member>/Cargo.toml`), which stops the rule walk before it
* reaches workspace-level `.github/instructions/` directories. Walking up to
* the innermost `.git` instead lets rules placed at repository, workspace or
* member level all participate — the scopeRelative path base keeps each rule's
* glob semantics keyed to the directory that owns the rule file.
*
* A `.git` in the user's home directory (dotfiles repositories) is ignored: it
* is not a meaningful rule boundary and would otherwise leak rules from `~`
* into every marker-less project below it. When no `.git` exists above, the
* project root is returned unchanged (plain upstream behaviour).
*/
export function widenToRepositoryRoot(projectRoot: string | null): string | null {
if (projectRoot === null) {
return null;
}

const repositoryFilesystemRoot = resolve("/");
const homeDirectory = resolveExistingPath(homedir());
let currentDirectory = resolveExistingPath(projectRoot);

while (true) {
if (currentDirectory === homeDirectory) {
return projectRoot;
}

if (existsSync(join(currentDirectory, ".git"))) {
return currentDirectory;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Walk correctly returns the first .git found walking up, so it cannot escape above the innermost repo root. Please realpath the walk (same as findProjectRoot) before comparing/returning: on macOS resolve() stays on /var/folders while tests and findProjectRoot use /private/var, so widenToRepositoryRoot currently returns a non-canonical path and the nested-member test fails. Also add a test that a nested .git wins over a parent .git (must not keep walking).

}

if (currentDirectory === repositoryFilesystemRoot) {
return projectRoot;
}

const parentDirectory = dirname(currentDirectory);
if (parentDirectory === currentDirectory) {
return projectRoot;
}

currentDirectory = parentDirectory;
}
}

function resolveExistingPath(path: string): string {
try {
return realpathSync.native(resolve(path));
} catch {
return resolve(path);
}
}
114 changes: 114 additions & 0 deletions test/engine-monorepo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { readFileSync, realpathSync } from "node:fs";
import { describe, expect, it } from "vitest";

import type { EngineDeps } from "../src/rules/engine.js";
import { createEngine, defaultConfig } from "../src/rules/engine.js";
import { findRuleCandidates } from "../src/rules/finder.js";
import { findProjectRoot } from "../src/rules/project-root.js";
import { createTempFs, type TempFs } from "./helpers/temp-fs.js";

function canonicalPath(path: string): string {
return realpathSync.native(path);
}

function createMonorepoDeps(fs: TempFs): EngineDeps {
const homeDir = fs.mkdir("home");
return {
findProjectRoot,
findCandidates: (options) => findRuleCandidates({ ...options, homeDir }),
readFile: (path) => {
try {
return readFileSync(path, "utf-8");
} catch {
return null;
}
},
extractToolPaths: () => [],
};
}

/**
* Monorepo semantics: a Cargo/pnpm workspace member (nested project marker) must still
* discover rule directories from the workspace and repository levels because
* `loadDynamicRules` widens the per-target project root to the enclosing git root
* (see `widenToRepositoryRoot`).
*/
describe("loadDynamicRules (monorepo workspace)", () => {
it("#given workspace member target with workspace-level rules #when loadDynamicRules #then workspace rule and single-file rules from walk levels are returned", () => {
// given
const fs = createTempFs("pi-rules-monorepo-");
try {
fs.mkdir("repo/.git");
fs.write("repo/backend/Cargo.toml", "");
const rustRule = fs.write(
"repo/backend/.github/instructions/rust.instructions.md",
'---\napplyTo: "crates/**/*.rs"\n---\nBackend Rust rule.\n',
);
const agents = fs.write("repo/backend/AGENTS.md", "# Backend AGENTS\n");
fs.write("repo/backend/crates/member/Cargo.toml", "");
const targetPath = fs.write("repo/backend/crates/member/src/lib.rs", "pub fn f() {}\n");

const engine = createEngine(defaultConfig(), createMonorepoDeps(fs));

// when
const result = engine.loadDynamicRules(fs.path("repo"), [targetPath]);

// then
const paths = result.rules.map((rule) => canonicalPath(rule.path));
expect(paths).toContain(canonicalPath(rustRule));
expect(paths).toContain(canonicalPath(agents));
} finally {
fs.cleanup();
}
});

it("#given workspace member target #when loadDynamicRules #then repository-level rules match via repo-relative globs", () => {
// given
const fs = createTempFs("pi-rules-monorepo-");
try {
fs.mkdir("repo/.git");
fs.write("repo/backend/Cargo.toml", "");
const rootLevelRule = fs.write(
"repo/.github/instructions/scripts.instructions.md",
'---\napplyTo: "backend/crates/**/*.rs"\n---\nRepository Rust rule.\n',
);
fs.write("repo/backend/crates/member/Cargo.toml", "");
const targetPath = fs.write("repo/backend/crates/member/src/lib.rs", "pub fn f() {}\n");

const engine = createEngine(defaultConfig(), createMonorepoDeps(fs));

// when
const result = engine.loadDynamicRules(fs.path("repo"), [targetPath]);

// then
expect(result.rules.map((rule) => canonicalPath(rule.path))).toContain(canonicalPath(rootLevelRule));
} finally {
fs.cleanup();
}
});

it("#given workspace-level globs scoped to another subtree #when loadDynamicRules #then rule does not match", () => {
// given
const fs = createTempFs("pi-rules-monorepo-");
try {
fs.mkdir("repo/.git");
fs.write("repo/backend/Cargo.toml", "");
fs.write(
"repo/backend/.github/instructions/scripts.instructions.md",
'---\napplyTo: "scripts/**"\n---\nScripts rule.\n',
);
fs.write("repo/backend/crates/member/Cargo.toml", "");
const targetPath = fs.write("repo/backend/crates/member/src/lib.rs", "pub fn f() {}\n");

const engine = createEngine(defaultConfig(), createMonorepoDeps(fs));

// when
const result = engine.loadDynamicRules(fs.path("repo"), [targetPath]);

// then
expect(result.rules.map((rule) => rule.path)).toHaveLength(0);
} finally {
fs.cleanup();
}
});
});
Loading