diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index 3afb81b99..05accaf8f 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -285,10 +285,31 @@ function resolveEnvContextDir(cwd) { return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); } +function resolveTargetPath(cwd, targetPath) { + const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath); + if (fs.existsSync(abs)) return abs; + return findUniqueBareTarget(cwd, targetPath) || abs; +} + +function findUniqueBareTarget(cwd, targetPath) { + const absCwd = path.resolve(cwd); + const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath); + const rel = path.relative(absCwd, abs); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null; + const segments = rel.split(path.sep).filter(Boolean); + if (segments.length !== 1) return null; + const name = segments[0]; + const repoRoot = findMonorepoRoot(absCwd); + if (!repoRoot) return null; + const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name); + if (matches.length !== 1) return null; + return path.resolve(repoRoot, matches[0].path); +} + function resolveTargetDir(cwd, options = {}) { const targetPath = options && typeof options === 'object' ? options.targetPath : null; if (!targetPath || !String(targetPath).trim()) return cwd; - const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath); + const abs = resolveTargetPath(cwd, targetPath); try { const stat = fs.statSync(abs); return stat.isDirectory() ? abs : path.dirname(abs); @@ -1188,13 +1209,19 @@ async function cli() { throw err; } const targetProvided = hasTargetOption(cliOptions); - const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null; + const resolvedTargetPath = targetProvided + ? resolveTargetPath(process.cwd(), cliOptions.targetPath) + : null; + const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null; const selection = resolveTargetSelection(process.cwd(), cliOptions); if (selection) { process.stdout.write(buildTargetSelectionDirective(selection) + '\n'); process.exit(0); } - const ctx = loadContext(process.cwd(), cliOptions); + const ctx = loadContext( + process.cwd(), + resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions, + ); const updateDirective = await computeUpdateDirective(); if (!ctx.hasProduct) { @@ -1308,11 +1335,6 @@ function hasTargetOption(options) { return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim()); } -function pathExistsForTarget(cwd, targetPath) { - const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath); - return fs.existsSync(abs); -} - const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({ 'claude-code': ['.claude/settings.local.json', '.claude/settings.json'], codex: ['.codex/hooks.json'], diff --git a/tests/context.test.mjs b/tests/context.test.mjs index 7e1d8ca14..694af4c19 100644 --- a/tests/context.test.mjs +++ b/tests/context.test.mjs @@ -770,6 +770,95 @@ describe('loadContext (monorepo project context)', () => { assert.match(res.stdout, /MONOREPO_TARGET_REQUIRED/); }); + it('resolves a unique workspace child by bare basename via --target', () => { + writeMonorepo(); + write('apps/dashboard/PRODUCT.md', '# Dashboard product\n\n## Platform\n\nweb\n'); + const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', 'dashboard'], { + cwd: scratch, + encoding: 'utf8', + env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, + }); + assert.equal(res.status, 0, res.stderr); + assert.match(res.stdout, /# Dashboard product/); + assert.match(res.stdout, /"targetExists": true/); + assert.match(res.stdout, /"targetPath": "dashboard"/); + assert.doesNotMatch(res.stdout, /MONOREPO_TARGET_REQUIRED/); + + const ctx = loadContext(scratch, { targetPath: 'dashboard' }); + assert.equal(ctx.projectRoot, path.join(scratch, 'apps', 'dashboard')); + }); + + it('resolves a dotted workspace child name by bare basename via --target', () => { + write('package.json', JSON.stringify({ + private: true, + workspaces: ['src/*'], + }, null, 2)); + write('src/Cantaro.Web/PRODUCT.md', '# Cantaro Web product\n\n## Platform\n\nweb\n'); + write('src/Cantaro.Api/package.json', JSON.stringify({ name: 'cantaro-api' })); + const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', 'Cantaro.Web'], { + cwd: scratch, + encoding: 'utf8', + env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, + }); + assert.equal(res.status, 0, res.stderr); + assert.match(res.stdout, /# Cantaro Web product/); + assert.match(res.stdout, /"targetExists": true/); + assert.match(res.stdout, /"targetPath": "Cantaro\.Web"/); + + const ctx = loadContext(scratch, { targetPath: 'Cantaro.Web' }); + assert.equal(ctx.projectRoot, path.join(scratch, 'src', 'Cantaro.Web')); + }); + + it('does not guess when a bare basename matches multiple workspace children', () => { + write('package.json', JSON.stringify({ + private: true, + workspaces: ['apps/*', 'packages/*'], + }, null, 2)); + write('turbo.json', JSON.stringify({ tasks: {} })); + write('apps/web/src/App.jsx', 'export default null;\n'); + write('packages/web/src/index.ts', 'export {};\n'); + const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', 'web'], { + cwd: scratch, + encoding: 'utf8', + env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, + }); + assert.equal(res.status, 0, res.stderr); + assert.match(res.stdout, /"targetExists": false/); + assert.match(res.stdout, /MONOREPO_TARGET_REQUIRED/); + + const ctx = loadContext(scratch, { targetPath: 'web' }); + assert.equal(ctx.projectRoot, scratch); + }); + + it('resolves a unique workspace child when the target was already resolved against cwd', () => { + writeMonorepo(); + write('apps/dashboard/PRODUCT.md', '# Dashboard product\n'); + const ctx = loadContext(scratch, { targetPath: path.join(scratch, 'dashboard') }); + assert.equal(ctx.projectRoot, path.join(scratch, 'apps', 'dashboard')); + }); + + it('does not guess a nested product basename in a non-monorepo repo', () => { + write('packages/checkout/PRODUCT.md', '# Checkout product\n'); + write('packages/checkout/file.ts', 'export const x = 1;\n'); + const bare = loadContext(scratch, { targetPath: 'checkout' }); + assert.equal(bare.isMonorepo, false); + assert.equal(bare.projectRoot, scratch); + const explicit = loadContext(scratch, { targetPath: 'packages/checkout/file.ts' }); + assert.equal(explicit.projectRoot, path.join(scratch, 'packages', 'checkout')); + assert.match(explicit.product, /Checkout product/); + }); + + it('does not select a negated workspace child by basename', () => { + write('pnpm-workspace.yaml', 'packages:\n - "packages/*"\n - "!packages/internal"\n'); + write('PRODUCT.md', '# Root product\n'); + write('packages/ui/src/index.ts', 'export {};\n'); + write('packages/internal/PRODUCT.md', '# Internal product\n'); + write('packages/internal/src/index.ts', 'export {};\n'); + const ctx = loadContext(scratch, { targetPath: 'internal' }); + assert.equal(ctx.projectRoot, scratch); + assert.match(ctx.product, /Root product/); + }); + it('asks for app selection even when root PRODUCT.md is absent', () => { write('package.json', JSON.stringify({ private: true, @@ -812,6 +901,16 @@ describe('loadContext (impeccable projectRoots config)', () => { assert.equal(ctx.productPath, 'PRODUCT.md'); }); + it('resolves a unique projectRoots child by bare basename', () => { + writeSkinsConfig(); + write('docs/design/skins/neon-seoul/DESIGN.md', '# Neon Seoul design\n'); + write('docs/design/skins/marble/DESIGN.md', '# Marble design\n'); + const ctx = loadContext(scratch, { targetPath: 'neon-seoul' }); + assert.equal(ctx.projectRoot, path.join(scratch, 'docs', 'design', 'skins', 'neon-seoul')); + assert.match(ctx.design, /Neon Seoul design/); + assert.match(ctx.product, /Root product/); + }); + it('resolves a config-declared child from cwd inside the folder', () => { writeSkinsConfig(); write('docs/design/skins/marble/DESIGN.md', '# Marble design\n');