Skip to content

feat(monorepo): widen dynamic rule walk to the git repository root - #31

Merged
code-yeongyu merged 3 commits into
code-yeongyu:mainfrom
tradem:feat/monorepo-repository-root-walk
Sep 24, 2026
Merged

code-yeongyu merged 3 commits into
code-yeongyu:mainfrom
tradem:feat/monorepo-repository-root-walk

Conversation

@tradem

@tradem tradem commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Monorepo/workspace support for dynamic rule discovery. findProjectRoot stops at the innermost project marker, so a target file inside a workspace member (Cargo workspace: a Cargo.toml per member crate; pnpm workspace: a package.json per package) resolves its project root to the member directory — the walk-up ends there and workspace/repository-level rule directories are never discovered. Those rules silently never fire for member code. This PR widens the dynamic (per-target) project root to the innermost enclosing git repository root (.git), so rules placed at member, workspace and repository level all participate.
  • Glob semantics stay keyed to the directory owning the rule file via the existing scopeRelative path base: a rule in <workspace>/.github/instructions/ matches workspace-relative globs, a rule in the repository root's .github/instructions/ matches repo-relative globs.
  • Follow-up guards made necessary by the wider walk: (1) single-file rules (AGENTS.md/CLAUDE.md) that pi already loaded natively are now deduplicated in the tool_result path as well; (2) since single-file rules match every target, they are now injected once per session (set cleared on session start/compaction) instead of once per read file. Static (always-on) discovery is unchanged. A .git in the user's home directory (dotfiles repositories) is ignored so rules never leak from ~ into marker-less projects.

Motivating example

repo/                        # .git
  backend/
    .github/instructions/rust.instructions.md   # applyTo: "crates/**/*.rs"
    crates/member/Cargo.toml                     # per-member marker
    crates/member/src/lib.rs

Reading backend/crates/member/src/lib.rs currently injects nothing (project root = backend/crates/member); with this change it injects rust.instructions.md and the walk's single-file rules, regardless of the session cwd.

Verification

  • npm run check (typecheck + biome)
  • npm test (unit tests — 275 passing, incl. 7 new)
  • npm run test:integration (integration tests — 46 passing)
  • npm pack --dry-run (release sanity)
  • pi -e ./src/index.ts smoke-tested locally — verified end-to-end against a real Cargo-workspace monorepo (headless pi sessions reading member files, non-member workspace files, and repository-level files; per-read injection sets and scope isolation asserted, plus negative case: workspace rule scoped to scripts/** does not match member .rs reads)

Rule-loading impact

  • Rule discovery / precedence documented in README (monorepo/git-root widening under "Project rule directories")
  • CHANGELOG entry added under [Unreleased]
  • TUI widget behavior unchanged (no widget changes in this PR)

New tests: unit tests for widenToRepositoryRoot in test/project-root.test.ts and engine-level tests with a real marker-nested workspace layout (test/engine-monorepo.test.ts), following the existing fixture helpers and #given/#when/#then conventions.

Design notes / open questions for the maintainer:

  • The home-directory .git guard is a pragmatic safeguard against dotfiles repositories; an alternative would be making the widening opt-in. I kept it default-on because plain single-project repos are unaffected (their project root already contains .git, so widenToRepositoryRoot is a no-op) and the monorepo case silently losing rules is harder to debug than the widening.
  • Static (session-level) discovery was intentionally left unchanged; making it walk to the repository root as well would change which single-file rules land in the system prompt for nested-cwd sessions.

Summary by cubic

Widens dynamic rule discovery to the enclosing git repository root so rules placed at repository, workspace, and member levels all fire for files inside workspace members (Cargo/pnpm workspaces). Previously the walk stopped at the innermost project marker, so workspace-level rules silently never applied to member code.

Monorepo rule discovery

  • Dynamic per-target project root now widens to the innermost .git; static discovery is unchanged.
  • Target paths are realpathed so the walk and candidate discovery agree on symlink roots (e.g. macOS /var vs /private/var).
  • Glob semantics stay keyed to the directory owning the rule file via the existing scopeRelative path base.
  • A .git in the user's home directory (dotfiles repositories) is ignored so rules don't leak from ~.

Single-file rule injection

  • Single-file rules (AGENTS.md/CLAUDE.md) are now injected once per session instead of once per read file; the set clears on session start and compaction.
  • Context files pi already loaded natively into the system prompt are now deduplicated in the tool_result path as well.

Written for commit 1c7a86f. Summary will update on new commits.

Review in cubic

findProjectRoot stops at the innermost project marker. In monorepos
with nested markers (Cargo workspaces: a Cargo.toml per member crate,
pnpm workspaces: per-package package.json) a target file inside a
member resolves its project root to the member directory, so the
rule walk never reaches workspace- or repository-level
.github/instructions/ directories and those rules silently never
fire for member code.

Widen the dynamic (per-target) project root to the innermost
enclosing .git repository root instead:

- engine.loadDynamicRules and fingerprintDynamicTargets wrap the
  per-target findProjectRoot result with widenToRepositoryRoot.
- Globs stay keyed to the directory owning the rule file via the
  existing scopeRelative path base: a rule in
  backend/.github/instructions/ matches backend-relative patterns,
  a rule in the repository-root .github/instructions/ matches
  repo-relative patterns.
- A .git in the user's home directory (dotfiles repositories) is
  ignored so rules never leak from ~ into marker-less projects.
- Static (always-on) discovery is unchanged.
- index.ts now dedups natively loaded context files in the
  tool_result path too: with the walk reaching repository level,
  single-file rules (AGENTS.md/CLAUDE.md) from levels pi already
  loaded natively must not be re-injected per matching read.

Tests: unit tests for widenToRepositoryRoot (project-root.test.ts)
and end-to-end engine tests with a real Cargo-workspace layout
(engine-monorepo.test.ts) covering workspace-level rules, repository
-level rules via repo-relative globs, negative scoping, and discovery
of single-file rules on the walk.
Single-file rules (AGENTS.md/CLAUDE.md) match every dynamic target. With
the walk now reaching repository level, a root-launched session that
reads N backend files would re-inject backend/AGENTS.md N times (the
per-target dynamic cache keys on the target path). Track realpaths of
already-injected single-file rules at session level and clear the set
on session start and compaction alongside the engine session state.

Glob rules keep per-target re-injection semantics (a pipeline file and
a photo module file each get their matching instructions).
@tradem

tradem commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Preempting a likely objection — "GitHub Copilot only loads instructions from the repository root, so widening breaks Copilot compatibility."

I think that argument is worth addressing head-on, because the comparison is only partially apt:

  • pi-rules is not a Copilot emulator. It already supports four rule styles (.omo/rules/, .claude/rules/, .cursor/rules/, .github/instructions/) — three of which have no Copilot equivalent at all — and its own documented semantics (per-target project root resolution with walk-up stack collection, single-file rules such as AGENTS.md matching every read) diverge from Copilot's root-only, session-start loading by design. "Apply rules where the file being edited lives" is the extension's core idea; the Copilot-style source is one of its inputs, not its contract.
  • For plain repositories nothing changes: when the marker-based project root already contains .git (the normal single-repo case), widenToRepositoryRoot is a no-op, and walk-up behavior is byte-identical to today. Root-only loading semantics hold exactly as before.
  • The widening only kicks in for nested project markers — a Cargo workspace (per-crate Cargo.toml) or pnpm workspace (per-package package.json) — where the current behavior means repository- and workspace-level rule directories silently never fire for member code. My testing showed rules placed at backend/.github/instructions/ injecting for exactly nothing under backend/crates/**, which is the common case for the primary audience of this extension (Rust/JS monorepos).

That said, if the default-on widening feels too aggressive for the project's direction, I'm happy to rework this into an opt-in (env var / registered flag, e.g. PI_RULES_REPOSITORY_ROOT_WALK=1), so Copilot-exact default semantics are preserved and monorepo users enable the widened walk explicitly. I'd keep the tool_result-path dedup and the once-per-session single-file injection in either variant, since both harden the dynamic path independently of the widening.

Happy to implement whichever variant you prefer — thanks for the extension either way, it slots into the pi workflow really nicely.


Co-authored-by: glm-5.3 (neuralwatt)

@code-yeongyu code-yeongyu left a comment

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.

The widening design is right: walk-up stops at the first .git, so it does not escape above the innermost repository root. The filesystem-root, dirname-stall, and home-directory guards keep the walk O(depth) and bounded on deep trees. Static discovery staying marker-based is a reasonable compatibility choice.

I cannot squash-merge as-is: biome format fails on src/index.ts, and four unit tests fail (macOS /var vs /private/var path canonicalization, plus engine-monorepo tests that compare non-canonical write() paths and do not isolate homeDir, so real user-home rules leak into the assertion).

Please address the inline comments. I will land this once check+unit tests are green.

Comment thread src/rules/project-root.ts
}

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).

Comment thread src/index.ts Outdated
engine.commitDynamicTargetFingerprints(fingerprints);
for (const rule of loaded.rules) {
if (nativeContextPaths.has(rule.path) || nativeContextPaths.has(rule.realPath)) {
engine.markStaticInjected(rule);

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.

Biome check fails here: extra indent on markStaticInjected, and the contextFiles loop on line 103 exceeds lineWidth 120. Please run biome check --write (do not disable the rule).

Comment thread CHANGELOG.md Outdated
- 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.
### Added

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.

Duplicate ### Added heading left over from the rebase. Drop this line so the Unreleased Added section is a single list.

Comment thread test/engine-monorepo.test.ts Outdated

// then
const paths = result.rules.map((rule) => rule.path);
expect(paths).toContain(rustRule);

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.

These assertions compare fs.write() paths (non-canonical tmpdir) to rule.path (often realpathed). On macOS that is /var/folders vs /private/var, so toContain fails even when the rule was loaded. Pass homeDir: fs.mkdir("home") on findCandidates so user-home rules cannot leak, and compare via realpath/canonical paths. The third test expecting length 0 currently sees 3 rules for the same reason.

Comment thread test/project-root.test.ts
const result = widenToRepositoryRoot(memberRoot);

// then
expect(result).toBe(canonicalPath(repositoryRoot));

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.

canonicalPath(repositoryRoot) is /private/var/... while widenToRepositoryRoot returns resolve() (/var/folders/...). Either realpath inside widenToRepositoryRoot or assert against resolve(repositoryRoot) consistently. Please also cover: (1) home-directory .git is ignored, (2) walk stops at innermost .git and does not continue to a parent repo.

Canonicalize paths so the git-root walk and candidate discovery agree on
macOS symlink roots, and stop asserting against non-canonical tmp paths.

@code-yeongyu code-yeongyu left a comment

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.

Follow-up landed: realpath the git-root walk, canonicalize discovery targets so macOS symlink roots stay inside the repo, biome format on src/index.ts, changelog heading, isolated homeDir in engine-monorepo tests, and coverage that the walk stops at the innermost .git. Unit + integration tests pass locally. Squash-merging.

@code-yeongyu

Copy link
Copy Markdown
Owner

GitHub Actions did not start jobs for this PR: workflow run https://github.com/code-yeongyu/pi-rules/actions/runs/35965336806 concluded action_required with zero jobs (billing/spending gate). Local gates on this head (npm run check, npm test 277, npm run test:integration 46) passed. Merging with admin bypass; the refresh PR will re-run CI on main after the Bun 1.4.2 workflow lands.

@code-yeongyu
code-yeongyu merged commit f14aa3b into code-yeongyu:main Sep 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants