From 79d52947657b71ab17c7b30518e550f1cc0fc341 Mon Sep 17 00:00:00 2001 From: Abdul Wahab <32850166+abdulwahabone@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:13:12 +0500 Subject: [PATCH] Fix: honor --target for nested products in non-monorepo repos (#377) * Fix: honor --target for nested products in non-monorepo repos Closes #376. Resolve projectRoot from the target path when no monorepo marker is present, and inherit missing context files from the repo root when the active project is nested below it. Co-authored-by: Cursor * Recognize nested-product context in .agents/context/ and docs/ fallback dirs Addresses PR #377 review: nearestTargetContextRoot only matched canonical PRODUCT.md/DESIGN.md directly in a directory, so nested products keeping context in the documented fallback locations were never selected. Reuse resolveLocalContextDir so the walk honors the same lookup order. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- skill/scripts/context.mjs | 40 ++++++++++++++++++--- tests/context.test.mjs | 75 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index 11f2aabe0..9715525db 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -7,9 +7,13 @@ * using the existing code as context. * * Path resolution (first match wins): - * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 1. Active project root, if PRODUCT.md or DESIGN.md is there. An explicit + * --target selects the active project: the workspace child in a + * monorepo, or the nearest directory around the target carrying + * canonical context files in an ordinary repo (issue #376). * 2. Active project .agents/context/ then docs/ - * 3. Monorepo root context, using the same order, as a per-file fallback + * 3. Repo root context, using the same order, as a per-file fallback + * whenever the active project is nested below it * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user * escape hatch, only consulted when defaults are empty * 5. Active project root as a "nothing found" default @@ -88,7 +92,10 @@ function resolveContext(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const project = resolveProject(absCwd, options); const projectContextDir = resolveLocalContextDir(project.projectRoot); - const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot + // Per-file inheritance from the repo root whenever the active project is + // nested below it: monorepo workspace children and explicit-target nested + // products in ordinary repos behave the same way. + const rootContextDir = project.repoRoot !== project.projectRoot ? resolveLocalContextDir(project.repoRoot) : null; @@ -165,7 +172,7 @@ function resolveProject(cwd = process.cwd(), options = {}) { if (!repoRoot) { return { targetDir, - projectRoot: absCwd, + projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, repoRoot: absCwd, isMonorepo: false, }; @@ -471,6 +478,31 @@ function isExcludedByWorkspacePattern(relSegments, patterns) { }); } +// An explicit --target in an ordinary (non-monorepo) repository must still +// select a nested product's own context (issue #376). Walk from the target up +// to — but not including — the invocation root and return the nearest +// directory carrying context files, in the canonical spot or a fallback dir +// (resolveLocalContextDir covers both). Context files only, not package.json: +// without the monorepo root-context fallback, a package.json marker would +// strand targets inside plain subpackages away from the root PRODUCT.md. The +// cwd's own fallback context dirs (.agents/context, docs) hold the root +// project's context, not a nested product, so they never count. +// Returns null when nothing nested is found, keeping the cwd default. +function nearestTargetContextRoot(absCwd, targetDir) { + if (!isPathInside(targetDir, absCwd)) return null; + const rootFallbackDirs = FALLBACK_DIRS.map((rel) => path.resolve(absCwd, rel)); + let dir = path.resolve(targetDir); + while (dir && dir !== absCwd) { + if (!rootFallbackDirs.includes(dir) && resolveLocalContextDir(dir)) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + function nearestProjectLikeRoot(repoRoot, targetDir) { let dir = path.resolve(targetDir); const stop = path.resolve(repoRoot); diff --git a/tests/context.test.mjs b/tests/context.test.mjs index 540908d5e..e37426eb0 100644 --- a/tests/context.test.mjs +++ b/tests/context.test.mjs @@ -278,11 +278,12 @@ describe('loadContext (monorepo project context)', () => { it('does not reuse stale project resolution after workspace markers change', () => { write('PRODUCT.md', '# Root product\n'); - write('apps/dashboard/PRODUCT.md', '# Dashboard product\n'); + write('apps/dashboard/package.json', JSON.stringify({ name: 'dashboard' }, null, 2)); write('apps/dashboard/src/App.jsx', 'export default null;\n'); const before = loadContext(scratch, { targetPath: 'apps/dashboard/src/App.jsx' }); assert.equal(before.projectRoot, scratch); + assert.equal(before.isMonorepo, false); assert.match(before.product, /Root product/); write('package.json', JSON.stringify({ @@ -292,7 +293,77 @@ describe('loadContext (monorepo project context)', () => { const after = loadContext(scratch, { targetPath: 'apps/dashboard/src/App.jsx' }); assert.equal(after.projectRoot, path.join(scratch, 'apps', 'dashboard')); - assert.match(after.product, /Dashboard product/); + assert.equal(after.isMonorepo, true); + assert.match(after.product, /Root product/); + }); + + it('resolves an explicit target onto a nested product context in a non-monorepo repo', () => { + write('nested/product/PRODUCT.md', '# Nested product\n'); + write('nested/product/DESIGN.md', '# Nested design\n'); + write('nested/product/file.ts', 'export const x = 1;\n'); + + const ctx = loadContext(scratch, { targetPath: 'nested/product/file.ts' }); + assert.equal(ctx.isMonorepo, false); + assert.equal(ctx.projectRoot, path.join(scratch, 'nested', 'product')); + assert.equal(ctx.repoRoot, scratch); + assert.match(ctx.product, /Nested product/); + assert.match(ctx.design, /Nested design/); + assert.equal(ctx.productPath, path.join('nested', 'product', 'PRODUCT.md')); + assert.equal(ctx.designPath, path.join('nested', 'product', 'DESIGN.md')); + }); + + it('resolves a nested target whose context lives in .agents/context/', () => { + write('nested/product/.agents/context/PRODUCT.md', '# Nested product\n'); + write('nested/product/file.ts', 'export const x = 1;\n'); + + const ctx = loadContext(scratch, { targetPath: 'nested/product/file.ts' }); + assert.equal(ctx.isMonorepo, false); + assert.equal(ctx.projectRoot, path.join(scratch, 'nested', 'product')); + assert.match(ctx.product, /Nested product/); + assert.equal(ctx.productPath, path.join('nested', 'product', '.agents', 'context', 'PRODUCT.md')); + }); + + it('resolves a nested target whose context lives in docs/', () => { + write('nested/product/docs/DESIGN.md', '# Nested design\n'); + write('nested/product/file.ts', 'export const x = 1;\n'); + + const ctx = loadContext(scratch, { targetPath: 'nested/product/file.ts' }); + assert.equal(ctx.isMonorepo, false); + assert.equal(ctx.projectRoot, path.join(scratch, 'nested', 'product')); + assert.match(ctx.design, /Nested design/); + assert.equal(ctx.designPath, path.join('nested', 'product', 'docs', 'DESIGN.md')); + }); + + it('does not treat the root fallback context dirs as a nested product', () => { + write('.agents/context/PRODUCT.md', '# Root product\n'); + write('.agents/context/notes/file.md', '# Notes\n'); + + const ctx = loadContext(scratch, { targetPath: '.agents/context/notes/file.md' }); + assert.equal(ctx.projectRoot, scratch); + assert.match(ctx.product, /Root product/); + }); + + it('inherits missing root context per-file for a nested target in a non-monorepo repo', () => { + write('DESIGN.md', '# Root design\n'); + write('nested/product/PRODUCT.md', '# Nested product\n'); + write('nested/product/file.ts', 'export const x = 1;\n'); + + const ctx = loadContext(scratch, { targetPath: 'nested/product/file.ts' }); + assert.equal(ctx.projectRoot, path.join(scratch, 'nested', 'product')); + assert.match(ctx.product, /Nested product/); + assert.match(ctx.design, /Root design/); + assert.equal(ctx.designPath, 'DESIGN.md'); + }); + + it('keeps the repo root project for targets without nearby context files', () => { + write('PRODUCT.md', '# Root product\n'); + write('src/App.jsx', 'export default null;\n'); + + const ctx = loadContext(scratch, { targetPath: 'src/App.jsx' }); + assert.equal(ctx.isMonorepo, false); + assert.equal(ctx.projectRoot, scratch); + assert.match(ctx.product, /Root product/); + assert.equal(ctx.productPath, 'PRODUCT.md'); }); it('does not escape a nested git repo to an ancestor workspace', () => {